# AGIL Calls — API (REST + WebSocket)

Версия: **0.1.0** · База: `http://localhost:8787` (все пути с префиксом `/v1`,
кроме `/openapi.json`, `/openapi.admin.json`, `/docs`, `/ws`). Машиночитаемая спека —
**две**: `GET /openapi.json` — только публичные эндпоинты (без admin-путей и схемы
`x-api-key`); `GET /openapi.admin.json` — полная спека (внутренний доступ).
Интерактивный офлайн-эксплорер — `GET /docs`. Артефакты для клиентов:
`docs/openapi.json` + `docs/openapi.admin.json` (`npm run openapi -w @agil/calls-server`).
Публикация docs/OpenAPI/admin управляется env — см. §17 и `docs/api-exposure.md`.

## Содержание

1. [Аутентификация и права](#1-аутентификация-и-права)
2. [Формат ошибок](#2-формат-ошибок)
3. [Публичный REST](#3-публичный-rest)
4. [Join и пароли](#4-join-и-пароли)
5. [Файлы, записи, STT](#5-файлы-записи-stt)
6. [Внешний чат (без WebSocket)](#6-внешний-чат-без-websocket)
7. [Admin REST](#7-admin-rest)
8. [Доступы, AI- и knowledge-агенты](#8-доступы-ai--и-knowledge-агенты)
9. [Вебхуки (мы → компания)](#9-вебхуки-мы--компания)
10. [WebSocket-сигналинг](#10-websocket-сигналинг)
11. [Mesh-согласование](#11-mesh-согласование)
12. [Лимиты и безопасность](#12-лимиты-и-безопасность)
13. [Коды ошибок](#13-коды-ошибок)

---

## 1. Аутентификация и права

| Креденшл | Как передаётся | Где |
|---|---|---|
| Admin API-ключ тенанта | `x-api-key: dev-key` | конфиг тенанта, `/v1/admin/*`, `POST /v1/tokens` |
| Join-токен участника | `authorization: Bearer <jwt>` | WS, `POST /v1/rooms/:id/chat`, файлы/записи/STT |
| Подписанная ссылка | `?sig=<base64url>&exp=<epoch ms>` | `GET /v1/files/:id`, `GET /v1/recordings/:id` |
| Приглашение | `?token=<jwt>` в `joinUrl`/`embedUrl` | WS |
| Анонимный доступ | без заголовков | `GET /v1/health`, `/v1/ice`, `GET config`, `POST /v1/rooms`, `POST join` (при `AGIL_ALLOW_ANONYMOUS=1`) |

`join` возвращает токен (HS256, TTL 4 ч: `AGIL_TOKEN_TTL_SEC`) и **эффективные
права** `permissions: FeatureKey[]` — тот же список уходит в `welcome.self.permissions`.
Права считает `resolveAccess` из `@agil/protocol`:

```
roleDefaults[role]  →  userOverrides[userId]  →  token.permissions (заменяет всё)
                    →  − deniedFeatures  →  ∩ включённые config.features.*
```

- `security.accessPolicy.mode='strict'` — только явные выдачи;
- токен с `permissions` фиксирует точный список (решает выпускающий);
- клиент **обязан** скрывать кнопки, которых нет в `permissions`; сервер
  параллельно применяет те же правила (chat, assistant, API-инъекция чата).

Пример admin-запроса:

```bash
curl -X POST http://localhost:8787/v1/tokens \
  -H 'content-type: application/json' -H 'x-api-key: dev-key' \
  -d '{"tenantId":"demo","roomId":"<room>","participant":{"name":"Alisa","role":"moderator"},
       "permissions":["chat","screenShare","fileShare"]}'
```

```json
{ "token": "eyJ...", "expiresAt": 1760000000000, "tenantId": "demo",
  "roomId": "<room>", "permissions": ["chat", "fileShare", "screenShare"] }
```

## 2. Формат ошибок

Все ошибки REST — JSON одного вида:

```json
{ "error": { "code": "password_invalid", "message": "Wrong room password" } }
```

`details` (массив issues zod или объект) добавляется для `validation_failed`.
Каждый ответ содержит `x-request-id`. Коды и статусы — в [§13](#13-коды-ошибок).

CORS: по умолчанию `access-control-allow-origin: *` без credentials; если у тенанта
задан `integrations.allowedOrigins`, сервер echoes origin только из списка
(всегда `vary: origin`). Разрешены заголовки `content-type, x-api-key, authorization,
x-file-name, x-recording-meta, x-language, x-request-id, x-agil-country`.

## 3. Публичный REST

### `GET /v1/health`

```bash
curl -s http://localhost:8787/v1/health | jq
```
```json
{ "ok": true, "name": "agil-calls-server", "version": "0.1.0",
  "uptimeSec": 42, "rooms": 1, "tenants": 1,
  "security": { "geoEnabled": false, "integrityEnabled": false,
                "sttConfigured": false, "aiAgentConfigured": false,
                "recordingsDir": "E:/Claude/VideoCalls/.data/recordings" } }
```

### `GET /v1/ice`

```json
{ "iceServers": [ { "urls": "stun:stun.l.google.com:19302" } ] }
```

В проде добавьте TURN (`AGIL_ICE_SERVERS` — JSON-массив с `username`/`credential`).

### `GET /v1/tenants/:tenantId/config` (публично)

```bash
curl -s http://localhost:8787/v1/tenants/demo/config | jq '{etag:.etag, primary:.config.theme.colors.primary}'
```
Неизвестный тенант → валидный дефолтный конфиг. Виджет грузит это **до** join
(тема/фичи/лейаут), затем получает `config` ещё раз в join/welcome.

### `PUT|PATCH /v1/tenants/:tenantId/config` (`x-api-key`)

- `PUT` — полный `TenantConfig`, `PATCH` — deep-partial (тема, фичи, security,
  `session.chat`, `layout.chrome`, `integrations`, ...);
- ключ должен принадлежать этому тенанту, иначе `403`;
- невалидная схема → `400` + `details[].path`;
- изменение конфига брокдачится в WS: `{"type":"config","config":{...}}`;
- внешние URL (`session.aiAgent.endpoint`, `session.knowledgeAgent.endpoint`,
  `integrations.webhooks[].url`) проходят SSRF-политику.

```bash
curl -X PATCH http://localhost:8787/v1/tenants/demo/config \
  -H 'content-type: application/json' -H 'x-api-key: dev-key' \
  -d '{"branding":{"productName":"ACME Calls"},
       "theme":{"colors":{"primary":"#0b5fff"}},
       "session":{"chat":{"ui":"external","allowHostInjection":true},
                  "knowledgeAgent":{"enabled":true,"endpoint":"https://kb.acme.ru/ask"}}}'
```

### `POST /v1/rooms` (анонимно при `AGIL_ALLOW_ANONYMOUS=1`)

Тело: `{tenantId?, roomId?, name?, password?, maxParticipants?, ttlSec?, configOverrides?}`.
Ответ (`200`):

```json
{ "room": { "id":"4f0c...","tenantId":"demo","name":"Demo","createdAt":1760000000000,"locked":false },
  "joinUrl": "http://localhost:8787/?room=4f0c...&tenant=demo&token=eyJ...",
  "embedUrl": "http://localhost:8787/embed.html?room=4f0c...&tenant=demo&token=eyJ...",
  "token": "eyJ...", "expiresAt": 1760014400000, "passwordProtected": false }
```

Для комнаты с паролем токен в ответе не выдаётся — его получают через join/инвайт.

### `POST|DELETE /v1/admin/rooms/:roomId/password` (`x-api-key`)

Смена/установка/снятие пароля комнаты из дашборда:

```bash
# установить свой пароль
curl -X POST http://localhost:8787/v1/admin/rooms/<roomId>/password \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"password":"new-secret-123"}'
# → { "updated": true, "passwordProtected": true, "generated": false }

# сгенерировать: body {} или {"password":""} → вернёт пароль ОДИН раз
curl -X POST http://localhost:8787/v1/admin/rooms/<roomId>/password \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' -d '{}'
# → { "updated": true, "passwordProtected": true, "generated": true, "password": "K7mQ2pX9rT4wZa" }

# снять пароль (комната снова публичная)
curl -X DELETE http://localhost:8787/v1/admin/rooms/<roomId>/password -H 'x-api-key: dev-key'
# → { "updated": true, "passwordProtected": false, "removed": true }
```

Пароль хранится только scrypt-хэшем; `GET /v1/admin/rooms/:id` и списки комнат
отдают лишь `passwordProtected:true` и никогда — пароль или `passwordHash`.
Сгенерированный пароль возвращается **один раз** (в ответе на POST), аудит
`admin.action` kind `room.password.changed` пишется без пароля. Валидация длины
та же, что при создании (1..200 символов); неизвестная комната → `404 not_found`.

### `GET /v1/rooms/:roomId` (`x-api-key`), `DELETE /v1/rooms/:roomId` (`x-api-key`)

`GET` → `{room, participants, createdAt}`. `DELETE` → `{closed:true}` (WS получают `bye`).

### `GET /v1/rooms/:roomId/messages?limit=100` (`x-api-key`)

`{messages: ChatMessage[]}` (история хранит последние 200 сообщений).

## 4. Join и пароли

### `POST /v1/rooms/:roomId/join`

Тело: `{name?, password?, participantId?, locale?}`; опционально
`authorization: Bearer <invite token>` (тогда пароль не требуется).

Проверки по порядку: rate-limit (`AGIL_JOIN_RATE_LIMIT_PER_MIN`, по умолчанию 120/мин на IP+комнату — с запасом для офисного NAT; брутфорс пароля ограничен отдельным lockout) → lockout после серии
неверных паролей → lock комнаты (`423`) → geo-политика (`403 geo_blocked`) →
пароль (`400 password_required` / `401 password_invalid`, событие
`password.failed` в аудите) → waiting room/moderator-first (`403`) → лимит
участников (`409 room_full`) → выпуск токена + аудит `join.allowed`.

```bash
# без пароля
curl -s -X POST http://localhost:8787/v1/rooms/demo-room/join \
  -H 'content-type: application/json' -d '{"name":"Гость"}'

# с паролем и гео-заголовком (только при AGIL_TRUST_PROXY=1)
curl -s -X POST http://localhost:8787/v1/rooms/secured/join \
  -H 'content-type: application/json' \
  -H 'x-forwarded-for: 203.0.113.7' -H 'x-agil-country: RU' \
  -d '{"name":"Alisa","password":"super-secret-1"}'
```

Ответ:

```json
{ "token":"eyJ...", "expiresAt":1760014400000,
  "room":{ "id":"secured","tenantId":"demo","name":"Secured","createdAt":1760000000000,"locked":false },
  "participant":{ "id":"uuid","name":"Alisa","role":"guest" },
  "config":{ "...": "TenantConfig" },
  "passwordAccepted": true,
  "permissions": ["chat","reactions","screenShare","..."] }
```

Гео-политика: `security.geo.enabled`, `allowedCountries`/`blockedCountries`
(ISO-3166 alpha-2), `blockVpn`. Пустой `allowedCountries` = разрешены все; при
непустом allow-list неизвестная страна блокируется. Страна из заголовков
(`x-agil-country`, `cf-ipcountry`, `x-vercel-ip-country`) принимается только при
`AGIL_TRUST_PROXY=1`; иначе источник — `ipapi` или `none`.

### Инвайты (admin)

`POST /v1/admin/rooms/:roomId/invites` `{ttlSec?, role?}` → `InviteLink`
`{roomId, joinUrl, embedUrl, token, expiresAt, passwordProtected}`.

## 5. Файлы, записи, STT

### `POST /v1/files` (`Bearer` join-токен)

Сырое тело + `content-type` + `x-file-name` (можно percent-encoded). Лимит —
`min(session.fileShare.maxFileSizeMb, AGIL_MAX_UPLOAD_MB)`.

```bash
curl -X POST http://localhost:8787/v1/files \
  -H "authorization: Bearer $TOKEN" \
  -H 'content-type: text/plain' -H 'x-file-name: notes.txt' \
  --data-binary 'hello agil calls'
```
```json
{ "id":"<uuid>", "tenantId":"demo", "roomId":"<room>", "name":"notes.txt",
  "size":16, "mime":"text/plain",
  "url":"http://localhost:8787/v1/files/<uuid>?sig=...&exp=1760086400000",
  "createdAt":1760000000000, "expiresAt":1760086400000, "uploadedBy":"<pid>" }
```

`GET /v1/files/:id?sig=&exp=` — байты (`content-disposition: attachment`).
Битая/просроченная подпись → `403`; небезопасный id → `404`; MIME html/svg/js
запрещены (`415 unsupported_mime`). Событие аудита `file.shared`, вебхук `file.shared`.

### Записи

`POST /v1/recordings` (`Bearer`) — сырое `video/webm|audio/webm|video/mp4` +
`x-recording-meta: {"roomId":"...","durationSec":123.4,"mimeType":"video/webm"}`.
Требует `session.recording.enabled && storeOnServer`, иначе `403`.
Ответ — `RecordingRecord` (с подписанным `url`); `GET /v1/recordings/:id?sig=&exp=`.
Admin: `GET /v1/admin/recordings?roomId=`, `DELETE /v1/admin/recordings/:id`.
Вебхук `recording.ready`, аудит `recording.saved`.

### `POST /v1/stt/transcribe` (`Bearer`)

Сырое аудио (`audio/webm`, `audio/wav`, ...), `x-language: ru|en`.
Не настроен `AGIL_STT_URL` → `501 stt_not_configured` (подсказка в `message`).
Ответ `SttResponse {text, provider:'server', language?, durationSec?, segments?}`.

## 6. Внешний чат (без WebSocket)

Чат может принадлежать компании (`session.chat.ui = 'builtin'|'external'|'hidden'`).
API-инъекция работает и при `external`, и при `hidden` — в соответствии с
`allowHostInjection` / `allowSystemMessages`, независимо от UI.

### `POST /v1/rooms/:roomId/chat` (`Bearer` join-токен)

```bash
curl -X POST http://localhost:8787/v1/rooms/demo-room/chat \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"text":"Клиент в очереди","id":"crm-42","from":{"name":"CRM","role":"moderator"}}'
```
```json
{ "message": { "id":"crm-42","roomId":"demo-room",
               "from":{"id":"<sub>","name":"CRM","role":"moderator"},
               "text":"Клиент в очереди","ts":1760000000000,"kind":"text" } }
```

- требует эффективную фичу `chat`;
- `kind:'system'` — нужны `session.chat.allowSystemMessages` **и** роль
  host/moderator (иначе `403`); автор по умолчанию `{name:"Система", role:"moderator"}`;
- текстовые сообщения — `session.chat.allowHostInjection` (иначе `403`);
- `id` — идемпотентность: повтор возвращает уже созданное сообщение;
- сообщение бродкастится всем по WS (`{"type":"chat","message":...}`), попадает в
  историю, аудируется (`chat.sent`) и уходит вебхуком `chat.message`;
- текст 1..4000 символов (`400` иначе), комната locked → `423`.

### `POST /v1/admin/rooms/:roomId/chat` (`x-api-key`)

То же тело, но для бота/CRM; автор по умолчанию `{id:"system", name:"Система",
role:"moderator"}`. Гейты `allowSystemMessages`/`allowHostInjection` соблюдаются,
аудит `chat.sent` + `admin.action`, вебхук `chat.message`.

## 7. Admin REST

Все запросы — `x-api-key`, тенант берётся из ключа; чужая комната → `403`,
неизвестная → `404`. Лимит `AGIL_ADMIN_RATE_LIMIT_PER_MIN` (120/мин).

| Метод | Путь | Ответ |
|---|---|---|
| GET | `/v1/admin/overview` | `AdminOverview` (KPI, страны) |
| GET | `/v1/admin/rooms` | `{rooms: AdminRoomSummary[]}` |
| POST | `/v1/admin/rooms` | `CreateRoomResponse` (пароль только хэшем) |
| GET | `/v1/admin/rooms/:roomId` | `RoomDetail` (участники, чат, ссылки) |
| DELETE | `/v1/admin/rooms/:roomId` | `{closed:true}` |
| POST | `/v1/admin/rooms/:roomId/invites` | `InviteLink` |
| POST | `/v1/admin/rooms/:roomId/lock` | `{locked}` |
| POST | `/v1/admin/rooms/:roomId/kick` | `{kicked:true}` (WS закрывается) |
| POST | `/v1/admin/rooms/:roomId/chat` | `ChatPostResponse` |
| GET | `/v1/admin/rooms/:roomId/transcript` | `TranscriptResponse` |
| GET | `/v1/admin/rooms/:roomId/analysis` | `AnalysisResponse` |
| POST | `/v1/admin/rooms/:roomId/analysis/run` | `AnalysisResponse` (принудительный прогон) |
| GET | `/v1/admin/rooms/:roomId/locations` | локации + GPS-координаты (только admin) |
| GET | `/v1/admin/audit?limit=&offset=&type=&severity=&roomId=` | `AuditListResponse {entries,total,offset,limit}` |
| GET | `/v1/admin/security` | `SecurityOverview` |
| GET | `/v1/admin/recordings?roomId=` | `{recordings}` |
| DELETE | `/v1/admin/recordings/:id` | `{deleted:true}` |
| GET | `/v1/admin/files?roomId=` | `{files}` |

```bash
KEY='x-api-key: dev-key'
curl -s -X POST http://localhost:8787/v1/admin/rooms \
  -H "$KEY" -H 'content-type: application/json' \
  -d '{"name":"Планёрка","password":"pass1234","maxParticipants":8}' | jq

curl -s "http://localhost:8787/v1/admin/audit?severity=warn&limit=20&offset=0" -H "$KEY" | jq '.total, .entries[0]'
```

`CreateRoomResponse` никогда не содержит пароль или `passwordHash` — только
`passwordProtected:true`. Брутфорс пароля фиксируется (`password.failed`,
`lockout`), kick немедленно закрывает WebSocket (`bye{reason:"kicked"}` + close 1008).

## 8. Доступы, AI- и knowledge-агенты

### Access policy

```bash
curl -s http://localhost:8787/v1/admin/access-policy -H "$KEY" | jq
curl -X PUT http://localhost:8787/v1/admin/access-policy \
  -H "$KEY" -H 'content-type: application/json' \
  -d '{"policy":{"deniedFeatures":["translation"],"roleDefaults":{"guest":["chat","reactions"]}}}'
```
Ответ: `{policy, featureKeys, effective:{host:[],moderator:[],guest:[]}}`.
Аудит — `access.updated`.

### AI-агент (анализ транскрипта, §13)

`GET /v1/admin/ai-agent` → `AiAgentSettingsResponse {config, endpointConfigured,
apiKeyConfigured}` (при ненастроенном агенте — дефолтный конфиг, не ошибка).

`PUT /v1/admin/ai-agent` `{config: Partial<AiAgentConfig>}` — merge в
`session.aiAgent`; ключ только как имя env-переменной (`apiKeyEnv`).

Поток: caption-сегменты → локальные правила `ComplianceEngine` (offline) →
при `mode:'external'|'hybrid'` HTTP POST на `endpoint` с HMAC-подписью
(`x-agil-signature` = hex HMAC-SHA256 сырого тела, `x-agil-request-id`,
`x-agil-tenant`) → `findings` + `notes` → broadcast `{"type":"analysis"}`,
audit, вебхуки `analysis.ready` / `compliance.finding`.

Пример запроса к **внешнему AI-агенту** (наша сторона → endpoint компании):

```http
POST https://ai.company.ru/agil/analyze
x-agil-request-id: 8f2c...
x-agil-tenant: demo
x-agil-signature: 9f2a... (hex HMAC-SHA256 тела)
content-type: application/json
```
```json
{
  "requestId": "8f2c...",
  "tenantId": "demo",
  "roomId": "demo-room",
  "roomName": "Планёрка",
  "locale": "ru",
  "role": "participant",
  "participants": [ { "id":"p1","name":"Alisa","role":"host","verified":false } ],
  "segments": [
    { "id":"s1","participantId":"p1","participantName":"Alisa",
      "text":"Пароль от прод-базы — в тикете SEC-42.","start":12.4,"end":15.1,
      "isFinal":true,"lang":"ru","ts":1760000000000 }
  ],
  "policy": { "rules":[{"id":"confidential","name":"Конфиденциально","type":"phrase",
             "pattern":"пароль","severity":"critical","action":"flag","enabled":true}],
              "policyText":"" },
  "meetingContext": { "startedAt":1760000000000, "hostName":"Alisa" }
}
```

Ожидаемый ответ (валидируется zod `parseAiAgentResponse`, длина строк
ограничивается, поля не исполняются):

```json
{
  "requestId": "8f2c...",
  "summary": "Обсуждали инцидент и доступ к прод-базе.",
  "findings": [
    { "ruleId":"confidential","ruleName":"Конфиденциально","severity":"critical",
      "action":"flag","participantId":"p1",
      "excerpt":"Пароль от прод-базы — в тикете SEC-42.",
      "confidence":0.92,"recommendation":"Удалить фразу из записи и напомнить политику." }
  ],
  "notes": {
    "summary":"Инцидент SEC-42, доступ к БД.",
    "excerpts":[{"id":"e1","kind":"risk","text":"Пароль от прод-базы — в тикете SEC-42.",
                 "participantName":"Alisa","start":12.4,"end":15.1,
                 "note":"Утечка секрета в транскрипте"}],
    "actionItems":[{"id":"a1","text":"Ротировать пароль прод-базы","owner":"Alisa"}],
    "decisions":["Провести внеплановый аудит доступа"],
    "risks":["Компрометация прод-базы"],"topics":["безопасность","доступ"],
    "sentiment":"negative"
  },
  "provider":"company-llm","model":"corp-llm-1"
}
```

Локальный fallback (`LocalNotesExtractor`) всегда доступен: `notes.excerpts` —
дословные цитаты с таймкодами и спикером (без пересказа AI).

### Knowledge-агент (вопросы по wiki компании)

```bash
curl -s http://localhost:8787/v1/admin/knowledge-agent -H "$KEY" | jq
curl -X PUT http://localhost:8787/v1/admin/knowledge-agent \
  -H "$KEY" -H 'content-type: application/json' \
  -d '{"config":{"enabled":true,"endpoint":"https://kb.company.ru/agil/ask",
       "apiKeyEnv":"AGIL_KNOWLEDGE_AGENT_KEY","allowedRoles":["host","moderator"],
       "requirePermission":true,"citations":true}}'

curl -s -X POST http://localhost:8787/v1/admin/knowledge/ask \
  -H "$KEY" -H 'content-type: application/json' \
  -d '{"question":"Как оформить отпуск?","role":"host","userId":"u-1"}' | jq
```
```json
{ "answer":"Отпуск оформляется в HR-портале: раздел «Отпуска».",
  "citations":[{"title":"HR wiki — Отпуска","url":"https://wiki.company.ru/hr/leave",
                "snippet":"Заявление за 14 дней"}],
  "confidence":0.87, "provider":"company-rag", "model":"rag-1" }
```

Запрос к компании — `KnowledgeQueryRequest` (HMAC-подпись как у AI-агента),
ответ валидируется `parseKnowledgeQueryResponse`; цитаты — недоверенные ссылки
(UI: `rel="noopener noreferrer"`). Права: `session.knowledgeAgent.enabled`,
`allowedRoles`, при `requirePermission` — фича `knowledgeAgent`. В звонке то же
доступно через WS `assistant-query` (см. §10) с лимитом 10 вопросов/мин.
Если агент включён, но не заданы endpoint/ключ — HTTP `501 knowledge_not_configured`
(не 403): UI показывает «Ассистент знаний не настроен».

### Экспорт заметок и анализа

`GET /v1/admin/rooms/:id/notes` → JSON `{roomId, notes, updatedAt}`;
`GET /v1/admin/rooms/:id/analysis` → JSON (`AnalysisResponse`). Для кнопок
«Скачать» оба поддерживают `?format=md` (Markdown) и `&download=1`
(`content-disposition: attachment`).

## 9. Вебхуки (мы → компания)

Настройка: `PUT /v1/admin/webhooks` `{webhooks: WebhookEndpointConfig[]}` (полная
замена, до 20), `GET /v1/admin/webhooks` → `{webhooks, recentDeliveries}` (лог 200
последних in-memory), `POST /v1/admin/webhooks/test` `{endpointId}` → `WebhookTestResponse`.

`WebhookEndpointConfig`: `{id, url, events[], secretEnv, enabled, retries, timeoutMs, description?}`.
`secretEnv` — имя env-переменной с HMAC-секретом (сам секрет в конфиг не попадает).

События: `room.created, room.closed, participant.joined, participant.left,
chat.message, file.shared, recording.ready, transcript.ready, analysis.ready,
knowledge.answer, compliance.finding, security.event`.

Доставка: `POST` JSON `WebhookPayload`:

```json
{ "id":"<delivery-uuid>", "type":"analysis.ready", "ts":1760000000000,
  "tenantId":"demo", "roomId":"demo-room",
  "data": { "findings": 2, "summary":"Инцидент SEC-42", "provider":"local" } }
```

Заголовки: `x-agil-signature: <hex HMAC-SHA256(secret, `${timestamp}.${rawBody}`)>`,
`x-agil-delivery: <id>`, `x-agil-event: <type>`, `x-agil-timestamp: <epoch-ms>`.
Подпись покрывает timestamp (окно ±300 s) — пересланная старая доставка
отвергается, replay невозможен. Таймаут/ретраи — из endpoint (`retries` 0..10,
`timeoutMs` 500..30000) с экспоненциальным backoff; итог в аудите
(`webhook.delivered` / `webhook.failed`). URL проходит SSRF-политику
(в production — только https и публичные хосты).

Проверка подписи на стороне компании (или готовый
`verifyWebhookSignature` из `@agil/security`):

```js
const timestamp = req.headers['x-agil-timestamp'];
const expected = crypto.createHmac('sha256', process.env.COMPANY_WEBHOOK_SECRET)
  .update(`${timestamp}.${rawBody}`, 'utf8').digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers['x-agil-signature']));
const fresh = Math.abs(Date.now() - Number(timestamp)) <= 300_000;
if (!ok || !fresh) return res.status(401).end();
```

## 10. WebSocket-сигналинг

Подключение: `ws://localhost:8787/ws?token=<jwt>` (предпочтительно) либо, при
`AGIL_ALLOW_ANONYMOUS=1`, `?room=<id>&tenant=demo&name=Гость&role=guest`.
Непосредственно после connect сервер присылает `welcome` либо `error` (fatal) и
закрывает. Max payload 1 МиБ, heartbeat 30 с, лимит 40 сообщений/с
(3 нарушения → close 1008), невалидный JSON → `error bad_request` без разрыва.

```bash
npx wscat -c "ws://localhost:8787/ws?token=$TOKEN"
> {"type":"ping","ts":1}
< {"type":"pong","ts":1}
> {"type":"chat","id":"c1","text":"привет"}
```

### Клиент → сервер

| type | Поля | Смысл |
|---|---|---|
| `hello` | `{name?, locale?}` | уточнить имя/локаль |
| `sdp` | `{to, description:{type:'offer'\|'answer', sdp}}` | relay SDP пиру |
| `ice` | `{to, candidate:{...}}` | relay ICE |
| `chat` | `{id, text(1..4000), attachment?}` | чат (id — UUID для дедупа) |
| `state` | `{state: Partial<{mic,cam,screen,handRaised,network}>}` | состояние медиа |
| `reaction` | `{emoji, ts?}` | реакция |
| `caption` | `{text, isFinal, lang?}` | субтитр → транскрипт (STT) |
| `integrity` | `{status, score?, flags?}` | liveness/anti-deepfake сигнал |
| `location` | `{lat, lon, accuracyM?, consent:true}` | GPS при `security.geo.gpsEnabled` |
| `ping` | `{ts}` | keepalive |
| `history` | `{limit?}` | история чата |
| `analysis-request` | `{sinceTs?}` | принудительный прогон AI-пайплайна |
| `assistant-query` | `{id, question}` | вопрос knowledge-агенту |
| `bye` | `{}` | выйти |

### Сервер → клиент

| type | Поля |
|---|---|
| `welcome` | `{self, room, participants[], config, iceServers[], serverTime, history[]}` |
| `peer-joined` | `{participant}` |
| `peer-left` | `{participantId, reason?}` |
| `sdp` | `{from, description}` |
| `ice` | `{from, candidate}` |
| `chat` | `{message}` — всем, включая автора |
| `chat-history` | `{messages}` |
| `state` | `{participantId, state}` |
| `reaction` | `{participantId, emoji, ts}` |
| `caption` | `{participantId, text, isFinal, ts}` |
| `integrity` | `{participantId, status, score?, flags, ts}` |
| `location` | `{participantId, location:{country?,city?,source,accuracyM?}, ts}` — без координат |
| `room` | `{room, participants}` |
| `config` | `{config}` — при изменении конфига тенанта |
| `analysis` | `{roomId, findings[], summary?, ts}` |
| `assistant-answer` | `{id, answer, citations[], confidence?, error?, ts}` |
| `error` | `{code, message, fatal?}` |
| `pong` | `{ts}` |
| `bye` | `{reason}` |

`welcome.self.permissions` — эффективные фичи; `welcome.history` — последние 200
сообщений (при `features.chatHistoryOnJoin`).

`assistant-answer.error`: `forbidden` (нет прав/роли/агент выключен),
`rate_limited` (10/мин на участника), `unavailable`/`internal_error` (агент компании
не ответил). Денай-сообщение — `session.knowledgeAgent.denyMessage`.

## 11. Mesh-согласование

```
A ──welcome(participants=[B]) / peer-joined(B)──┐
B ──welcome(participants=[A]) / peer-joined(A)──┤  обе стороны: RTCPeerConnection + addTrack
                                                ▼
Инициатор = участник с лексикографически меньшим participant.id
   initiator: createOffer → sdp{to: B} ──► relay ──► B
   B: setRemote(offer) → createAnswer → sdp{to: A} ──► relay ──► A
   обе: onicecandidate → ice{to: peer} ──► relay ──► peer.addIceCandidate
   renegotiation (screen share): perfect negotiation + rollback на glare
   деградация: quality.evaluateQuality() → reduced video/audio-only/chat-fallback,
               ICE-restart и reconnect с backoff из fallback.lowBandwidth.autoReconnect
```

Сервер — детерминированный relay (без состояния SDP): он только проверяет, что
`to` есть в комнате (`error unknown_peer`), и пересылает `from`. Топология mesh:
каждый с каждым; `limits.maxParticipants` ограничивает комнату.

## 12. Лимиты и безопасность

| Что | Лимит |
|---|---|
| JSON body | 1 МиБ → `413` |
| Файл | `min(session.fileShare.maxFileSizeMb, AGIL_MAX_UPLOAD_MB)` |
| Запись | `AGIL_MAX_RECORDING_MB` (200) |
| Join | `AGIL_JOIN_RATE_LIMIT_PER_MIN` (120/мин) на IP+комнату; lockout: `maxFailedJoinAttempts` за `lockoutSec` |
| Admin API | `AGIL_ADMIN_RATE_LIMIT_PER_MIN` (120/мин) |
| Файлы/STT | 30/мин на тенант |
| Knowledge | 10/мин на участника |
| WS | 40 сообщений/с; 3 нарушения → close 1008 |
| Чат | 1..4000 символов; история — 200 сообщений |

Заголовки ответов: `x-content-type-options: nosniff`,
`referrer-policy: strict-origin-when-cross-origin`, `x-frame-options: SAMEORIGIN`,
`permissions-policy: camera=(self), microphone=(self), geolocation=(self)`,
`cross-origin-opener-policy: same-origin`, HSTS при `AGIL_TRUST_PROXY=1`.
`/embed.html` — CSP `frame-ancestors` из `integrations.allowedOrigins`
(пустой список = разрешено всем; для прода задайте список).
Аудит: `<AGIL_DATA_DIR>/audit/<tenant>.jsonl`, IP — только `hashIp`. Пароли и
ключи не логируются; `/v1/health` не раскрывает имена env-переменных.

## 13. Коды ошибок

| HTTP | `error.code` | Когда |
|---|---|---|
| 400 | `validation_failed` | кривой JSON/zod-issues/URL/неизвестный audit-фильтр |
| 400 | `password_required` | у комнаты есть пароль, но он не передан |
| 401 | `unauthorized` | нет/невалиден `x-api-key` или join-токен |
| 401 | `password_invalid` | неверный пароль комнаты |
| 403 | `forbidden` | чужой тенант, запрещённая фича/роль, `geo_blocked`-политика, выключенная инъекция чата |
| 403 | `feature_disabled` | функция выключена флагом тенанта (`features.*`: файлы/запись/STT/чат/…), даже если роль её разрешает; UI и API проверяют одинаково |
| 403 | `geo_blocked` | страна/AVPN/неизвестная страна при allow-list |
| 404 | `not_found` | комната/запись/файл/документ не найдены |
| 409 | `room_full` | достигнут `maxParticipants` |
| 413 | `file_too_large` | тело/файл больше лимита |
| 415 | `unsupported_mime` | запрещённый MIME или не аудио для STT |
| 423 | `room_locked` | комната залочена admin'ом |
| 429 | `rate_limited` | rate-limit или lockout после паролей |
| 501 | `stt_not_configured` | не задан `AGIL_STT_URL` |
| 501 | `knowledge_not_configured` | knowledge-агент включён, но не заданы endpoint/ключ (UI: «Ассистент знаний не настроен») |
| 501 | `assistant_local_only` | in-call ассистент в `local`-режиме (движок в виджете) или hybrid-fallback |
| 501 | `provider_unsupported` | слот провайдера не поддерживает external в MVP (recording) |
| 502 | `provider_failed` | внешний провайдер/AI/knowledge/STT не ответил (при `fallback:'fail'`) |
| 502 | `internal_error` | внешний STT/AI/knowledge не ответил |
| 500 | `internal_error` | непредвиденная ошибка (детали — в логе, не клиенту) |

WS-коды: `bad_request | unauthorized | room_full | room_locked | unknown_peer |
rate_limited | internal_error`; fatal-ошибки сопровождаются close 1008.

---

## 14. In-call AI-ассистент (с правами участника)

`POST /v1/rooms/:roomId/assistant` — `authorization: Bearer <join token>`.
Ассистент действует **от имени подключённого пользователя**: сервер передаёт
эффективные `permissions`, а затем фильтрует предложенные моделью действия по тем
же правилам (клиентская проверка — только UX). Rate-limit: 10 запросов/мин на
участника. Промпт и ответ в аудит **не пишутся** (только событие `assistant.usage`).

```bash
curl -X POST http://localhost:8787/v1/rooms/demo-room/assistant \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"text":"Помоги включить запись","locale":"ru",
       "state":{"participants":2,"recording":false,"mic":"on"}}'
```

```json
{ "id":"reply-uuid",
  "text":"Нажмите «Запись» в нижней панели — я подсвечу кнопку.",
  "actions":[{"tool":"start_recording"},{"tool":"open_chat"}],
  "spotlight":[{"target":"recording","text":"Кнопка записи","step":0}],
  "source":"external","ts":1760000000000 }
```

Проверки: `session.inCallAgent.enabled` (иначе `403`); `mode:'local'` → `501
assistant_local_only` (движок выполняется в виджете офлайн); `external`/`hybrid` →
подписанный POST на `session.inCallAgent.endpoint` (ключ из env по `apiKeyEnv`,
HMAC `x-agil-signature`), тело `InCallAssistantRequest` (participant.permissions,
`tools = allowedTools`, `systemPrompt`, компактный `state`). Ответ валидируется
`parseInCallAssistantResponse` (недоверенный вход), действия фильтруются
`filterAssistantActions` и обрезаются до `maxActionsPerReply`; `spotlight`
отключается конфигом. Ошибка endpoint: `hybrid` → `501 assistant_local_only`
(fallback в виджете), `external` → `502 provider_failed`.

Admin: `GET|PUT /v1/admin/in-call-agent` (`x-api-key`) — merge в
`session.inCallAgent`; ответ `{config, endpointConfigured, apiKeyConfigured}`
(секрет — только имя env-переменной).

## 15. Заменяемые провайдеры (builtin → external)

`TenantConfig.providers` — 6 слотов: `chat`, `files`, `stt`, `notifications`,
`recording`, `backgrounds`. По умолчанию всё работает автономно (builtin);
компания может заменить нужное, не форкая продукт (`docs/extensibility.md`).

```bash
curl -s http://localhost:8787/v1/admin/providers -H 'x-api-key: dev-key' | jq
curl -X PUT http://localhost:8787/v1/admin/providers \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"providers":{"files":{"mode":"external",
        "providerName":"s3","endpoint":"https://company.ru/agil/files",
        "apiKeyEnv":"AGIL_FILES_PROVIDER_KEY","timeoutMs":5000,
        "fallback":"builtin","options":{}}}}'
curl -X POST http://localhost:8787/v1/admin/providers/test \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"slot":"files","payload":{"probe":true}}'
```

Статус слота: `{slot, mode, providerName, configured, available, fallback, note?}`.
PUT валидирует внешний endpoint через SSRF-политику (http(s); в «secure» режиме —
только https и публичные хосты), `apiKeyEnv` — только имя env-переменной.
`mode:'external'` требует непустой endpoint; `disabled` выключает возможность
(API отвечает `403`), `recording` в MVP поддерживает только builtin
(`provider_unsupported` → `501`).

**Конверт провайдера** (мы → endpoint компании): `POST` JSON
`{capability, action, requestId, tenantId, roomId?, payload}` с заголовками
`x-agil-signature` (hex HMAC-SHA256 сырого тела; секрет из env по `apiKeyEnv`),
`x-agil-capability`, `x-agil-request-id`, `x-agil-tenant`, `content-type`.
Ответ: `{ok:true, data}`; `data` валидируется адаптером. При ошибке/таймауте/
невалидном ответе: `fallback:'builtin'` — прозрачное использование builtin
(лог + аудит `admin.action` kind `provider.fallback`), `fallback:'fail'` — `502
provider_failed`. Payload’ы: `chat.post {text,kind,from,id?}` / `chat.history
{limit}`; `files.put {name,mime,size,dataBase64,uploadedBy}` / `files.get {id,sig,
exp}` → `data {file,dataBase64}` / `files.list` / `files.remove`; `stt.transcribe
{mime,language,audioBase64}`; `notifications.send {type,message,data}` (best-effort,
никогда не блокирует звонок; сбой → аудит `webhook.failed` kind
`provider.notification_failed`); `backgrounds.list` → `data {backgrounds}`.

Уведомления builtin — только UI-события; external получает `participant.joined`,
`participant.left`, `room.closed`, `recording.ready`, `analysis.ready`.
Файлы/STT/запись маршрутизируются сервером прозрачно для виджета.

### `GET /v1/tenants/:tenantId/backgrounds` (публично, без auth)

`{backgrounds:[{id,name,url}], provider:'builtin'|'<external>'}` — builtin берёт
`session.virtualBackgrounds`, external — список компании. `disabled` → `403`.

## 16. Звонки из мессенджера (direct/group)

`RoomInfo.mode` (`direct` | `group`, по умолчанию `group`) и `RoomInfo.context`
(`{messenger?, conversationId?, directPeerId?, title?}`) принимаются в
`POST /v1/rooms` и `POST /v1/admin/rooms`:

```bash
curl -X POST http://localhost:8787/v1/rooms \
  -H 'content-type: application/json' \
  -d '{"name":"Звонок из Slack","mode":"direct",
       "context":{"messenger":"slack","conversationId":"C-42","title":"Чат с клиентом"}}'
```

Правила: `mode:'direct'` — 1:1, `maxParticipants` принудительно ≤ 2 (если не задан —
2); `group` — лимит из конфига тенанта. `mode`/`context` возвращаются в room/join/
welcome/admin room detail/public room info и уходят в вебхук `room.created`
(`data.mode`, `data.context`). `context` — строки с лимитами, без секретов.

Карточка звонка в мессенджере (рекомендуемый сценарий): бот создаёт direct-комнату,
получает `joinUrl`/`embedUrl`, отправляет карточку в чат (conversationId в context),
подписывается на `room.created`/`room.closed`/`participant.*` вебхуки и удаляет
карточку по `room.closed`. См. `docs/messenger.md`.

## 17. Управление публикацией docs/OpenAPI/admin

Полная таблица — `docs/api-exposure.md`. Ключевое:

| Env | Default | Поведение |
|---|---|---|
| `AGIL_DOCS_ENABLED` | `1` в dev (в production по умолчанию `0`) | `0` → `/docs`, `/docs/:file`, `/openapi.json`, `/openapi.admin.json` отдают одинаковый `404` (без утечки «docs есть») |
| `AGIL_DOCS_ALLOW_IPS` | пусто = все | CIDR-список (`10.0.0.0/8,203.0.113.0/24`); проверка по resolved IP (с учётом `AGIL_TRUST_PROXY`); вне списка → `404` |
| `AGIL_DOCS_PORT` / `AGIL_DOCS_HOST` | основной порт | отдельный HTTP-порт только для docs/OpenAPI (docs-сервис); на основном порту docs тогда `404` |
| `AGIL_DOCS_RATE_LIMIT_PER_MIN` | `120` | rate-limit docs/OpenAPI на IP (`429`) |
| `AGIL_ADMIN_API_ENABLED` | `1` | `0` → весь `/v1/admin/*` = `404` (публичный API работает) |
| `AGIL_ADMIN_ALLOW_IPS` | пусто = все | allowlist admin-API; вне списка → `403` |
| `AGIL_MCP_HTTP_ENABLED` / `AGIL_MCP_HTTP_PORT` / `AGIL_MCP_HTTP_TOKEN` / `AGIL_MCP_HTTP_ALLOW_IPS` | off / — | флаги HTTP-MCP (сам `/mcp` реализует пакет `@agil/mcp` на своём порту); `/v1/health` отдаёт `security.mcpHttpEnabled` |

`/openapi.json` содержит только публичные пути и не содержит security-scheme
`apiKey`; admin-спека — только `/openapi.admin.json` (и требует docs-доступа).
Ответы docs/OpenAPI — `cache-control: no-store`/`no-cache`, секретов в спеке нет.
`/v1/health` отдаёт `security.docsEnabled`, `security.adminApiEnabled`,
`security.mcpHttpEnabled`.

---

## 18. Масштабирование: TURN-пул, мульти-нода, SFU, вебинары

MVP работает **single-node mesh**; конфигурация масштабирования принимается,
валидируется, отдаётся в admin API и **влияет на практическую часть**:
`welcome.iceServers` (STUN + весь TURN-пул с credential из env) и
`RoomInfo.mediaMode`/`maxParticipants` при создании комнаты. Реальная
оркестрация кластера/SFU/стриминга — следующий этап (`docs/scaling.md` §9).

`TenantConfig.scale`: `mediaMode` (`mesh|sfu|broadcast`), `maxParticipants`
(2..50000, дефолт комнаты), `maxVideoSubscriptions`, `signaling` (nodeId,
redisUrl, pubsubChannel, stickySessions, nodes[]), `sfu` (provider, url,
apiKeyEnv, apiSecretEnv, region, maxParticipants), `turn[]` (id, urls[],
username, credentialEnv, region, weight), `loadBalancer` (strategy, healthPath,
stickyCookie), `webinar` (enabled, hostOnlyPublish, viewerLimit, chatMode,
reactions, raiseHandQueue), `streaming` (enabled, provider, rtmpUrl, hlsUrl,
latencyMode), `cdn` (enabled, baseUrl, provider).

```bash
# статус: конфиг, эффективный mediaMode, кластер, TURN-пул (configured-флаги), SFU/webinar/streaming
curl -s http://localhost:8787/v1/admin/scale -H 'x-api-key: dev-key' | jq

# merge конфигурации (deep-partial, массивы заменяются)
curl -X PUT http://localhost:8787/v1/admin/scale \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"scale":{
        "mediaMode":"sfu",
        "maxParticipants":500,
        "signaling":{"nodes":[{"id":"node-2","url":"https://calls-2.example","region":"ru-spb","weight":100}],"stickySessions":true},
        "sfu":{"provider":"livekit","url":"wss://sfu.example","apiKeyEnv":"AGIL_SFU_API_KEY","apiSecretEnv":"AGIL_SFU_API_SECRET"},
        "turn":[{"id":"turn-msk","urls":["turn:turn-msk.example:3478?transport=udp","turns:turn-msk.example:5349"],
                 "username":"agil","credentialEnv":"AGIL_TURN_MSK_SECRET","region":"ru-msk","weight":100}]}}'

# статус кластера (MVP: multiNodeActive=false)
curl -s http://localhost:8787/v1/admin/cluster/status -H 'x-api-key: dev-key' | jq

# конфигурационная проверка TURN (без реального allocate в MVP)
curl -X POST http://localhost:8787/v1/admin/scale/turn/test \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' -d '{"id":"turn-msk"}'
# → {"id":"turn-msk","ok":true,"latencyMs":1,"detail":"TURN configuration looks valid ..."}
```

Правила валидации: `turn[].urls` — только `turn:|turns:|stun:|stuns:` (TURN
адвертизируется браузеру, сервер его не вызывает — приватные корпоративные TURN
разрешены); `signaling.nodes[].url`, `sfu.url`, `streaming.hlsUrl`,
`cdn.baseUrl` — SSRF-политика (в production-режиме TLS + публичные хосты,
приватные/loopback/metadata запрещены); `streaming.rtmpUrl` — `rtmp:|rtmps:`;
`sfu.apiKeyEnv/apiSecretEnv`, `turn[].credentialEnv` — **только имена**
env-переменных (секреты в конфиг/ответы не попадают, статус отдаёт лишь
`credentialConfigured`).

`ScaleStatusResponse`: `{config, mediaMode (эффективный), cluster:{nodeId,nodes,
strategy,redisConfigured,stickySessions}, turn:[{id,urls,region,weight,
usernameConfigured,credentialConfigured}], sfuConfigured, webinarEnabled,
streamingProvider, limitations[]}` — `limitations` честно перечисляет, что не
реализовано (cluster/SFU/webinar media enforcement/streaming/TURN probing).
`ClusterStatusResponse`: `{nodeId,nodes,strategy,redisConfigured,stickySessions,
multiNodeActive:false}`. `TURN test`: `{id,ok,latencyMs,detail}` (проверка схемы
URL, username, наличия credential-env; allocate не выполняется).

Практические эффекты: `welcome.iceServers = STUN (AGIL_ICE_SERVERS) + TURN-пул
config.scale.turn` (credential из env; пустой env → без credential);
`mediaMode` комнаты = `effectiveMediaMode(scale, roomMode)` (webinar+non-mesh →
`broadcast`; direct → `sfu` только если scale sfu); дефолтный
`maxParticipants` комнаты = `scale.maxParticipants` (direct ≤ 2); `GET /v1/ice`
отдаёт тот же пул для дефолтного тенанта; `/v1/health` →
`security.mediaMode`, `security.scaleConfigured`.

---

## 19. Роли: встроенные и кастомные

Три встроенные роли — `host` (организатор), `moderator`, `guest` — задаются
`security.accessPolicy.roleDefaults` и меняются через `PUT /v1/admin/access-policy`.
Компания может создавать **свои роли** (до 30) с произвольным набором фич; токен
выпускается с `participant.roleId`, и эффективные права считаются по фичам роли
(затем действуют `deniedFeatures`, права токена и включённые `features.*`).

```bash
# список: встроенные + кастомные (с числом активных участников)
curl -s http://localhost:8787/v1/admin/roles -H 'x-api-key: dev-key' | jq

# создать роль
curl -X POST http://localhost:8787/v1/admin/roles \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"id":"streamer","name":"Стример","description":"Ведущий трансляции",
       "baseRole":"moderator","features":["chat","screenShare","liveStreaming"],
       "canModerate":true,"color":"#7c3aed"}'

# выпустить токен с этой ролью
curl -X POST http://localhost:8787/v1/tokens \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"tenantId":"demo","roomId":"<room>","participant":{"name":"Анна","roleId":"streamer"}}'
# → { token, permissions:[...], roleId:"streamer", roleName:"Стример" }

# изменить / удалить кастомную роль
curl -X PUT http://localhost:8787/v1/admin/roles/streamer \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"name":"Стример v2","baseRole":"moderator","features":["chat","screenShare"]}'
curl -X DELETE http://localhost:8787/v1/admin/roles/streamer -H 'x-api-key: dev-key'
# → { "deleted": true, "affectedParticipants": 1 }
```

Правила: `id` — `^[a-z0-9][a-z0-9_-]*$` (1..40); встроенные id
`host|moderator|guest` зарезервированы (POST → 400, PUT/DELETE → 403); дубль id →
409; лимит 30 → 409. `RoleDefinition`: `{id,name,description,baseRole,features,
canModerate,color,builtin,assigned?}`. `welcome.self.roleName` показывает
отображаемое имя кастомной роли; при удалении роли участники остаются в комнате и
откатываются на `baseRole` (их токены продолжают работать; новые токены с удалённым
`roleId` → 400 `validation_failed`). Аудит: `admin.action` kind
`role.created|role.updated|role.deleted`.


---

## 20. Ассистент панели (DeepSeek) и Guard критических изменений

### 20.1. Каталог промптов

`GET /v1/admin/assistant/prompts` (x-api-key) → `{prompts:[{role,title,description,starters,systemPrompt}]}`.
Роли: `assistant`, `config-guardian`, `security-reviewer`, `onboarding`,
`api-helper`, `scaling-advisor`, `roles-admin`.

### 20.2. Ответ ассистента

`POST /v1/admin/assistant` (x-api-key)

```bash
curl -X POST http://localhost:8787/v1/admin/assistant \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","content":"Что будет, если выключить запись?"}],
       "role":"config-guardian",
       "context":{"screen":"settings","tenantId":"demo"}}'
```

```json
{
  "reply": "Если выключить запись, участники не смогут начать запись…",
  "role": "config-guardian",
  "model": "deepseek-chat",
  "fallback": false,
  "suggestions": ["Можно ли отключить запись?", "…"]
}
```

Серверная логика: системный промпт роли + краткий контекст экрана/тенанта
(конфиг — сжато), история ≤12 сообщений/≤8000 символов. Если задан
`AGIL_DEEPSEEK_API_KEY` — запрос в `${AGIL_DEEPSEEK_BASE_URL}/chat/completions`
(OpenAI-compatible, `model`, temperature 0.3, max_tokens 900, timeout 20 s);
ключ никогда не возвращается и не логируется. При ошибке/отсутствии ключа —
детерминированный локальный ответ, `fallback: true`, модель `local-rules`,
HTTP всегда 200. Аудит: `admin.action` c `details.kind = "admin.assistant.used"`
(role/fallback/model/messageLength, без текста сообщений). Лимит: 10 запросов/мин
на тенант.

### 20.3. Guard критических изменений

`POST /v1/admin/guard/assess` (x-api-key)

```bash
curl -X POST http://localhost:8787/v1/admin/guard/assess \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"kind":"config","next":{"features":{"recording":false}},"intent":"выключить запись"}'
```

`kind`: `config|roles|access|scale|providers|webhook|room|integrations`.
`current` по умолчанию — живой конфиг тенанта (для `roles` — массив из
`GET /v1/admin/roles`); `next` валидируется (для `config` — deep-partial
`tenantConfigSchema`, иначе объект/массив). Ответ: `{risk, requiresConfirmation,
summary, message, findings[], recommendations[]}`. Аудит: `admin.action` c
`details.kind = "admin.guard.assessed"` (changeKind/risk/findings).

### 20.4. Изменения безопасности (QA S-01…S-10)

| ID | Поведение |
|---|---|
| S-02 | Вебхуки подписываются `hex HMAC-SHA256(secret, `${x-agil-timestamp}.${rawBody}`)`; заголовки `x-agil-timestamp`/`x-agil-signature`, окно ±300 s; совместимо с `verifyWebhookSignature` из `@agil/security` |
| S-01 | `x-agil-country` не принимается по умолчанию; только при `AGIL_GEO_HEADER_ALLOW=1` (демо/edge). В проде страна — ipapi/edge |
| S-03 | Join-токен комнаты A не даёт роль в комнате B: чужие claims игнорируются, вход как `guest` |
| S-06 | `PUT /v1/admin/access-policy` принимает `clearUserOverrides:["userId"]`; `policy.userOverrides:{}` заменяет карту (отзыв прав) |
| S-08 | JSON-тело больше лимита → `413 payload_too_large`; файлы — `413 file_too_large` |
| S-10 | Admin CORS не отдаёт `*`: только origin из `integrations.allowedOrigins`, иначе ACAO отсутствует |


---

## 21. Документация (Docs CMS): редактирование, загрузка, версии

Документация в панели открывается как страница, а не сырой `.md`. Встроенные
документы из `docs/` (репозиторий) доступны только для чтения; правки и загрузки
создают **custom-копию** в `AGIL_DATA_DIR/docs/`, история — в
`AGIL_DATA_DIR/docs/.history/<name>/<timestamp>.json`, выбранный основной
документ — `AGIL_DATA_DIR/docs/.default.json`. Public `/docs/:file` отдаёт
custom-копию, если она есть (иначе bundled), `Content-Type: text/markdown`;
листинга каталога и доступа к служебным файлам нет.

```bash
KEY='-H "x-api-key: dev-key"'

# список: bundled + custom, defaultName, editable
curl -s http://localhost:8787/v1/admin/docs -H 'x-api-key: dev-key' | jq

# прочитать
curl -s http://localhost:8787/v1/admin/docs/api-guide.md -H 'x-api-key: dev-key' | jq -r .content

# сохранить правку (≤200KB; старая версия уходит в историю)
curl -X PUT http://localhost:8787/v1/admin/docs/api-guide.md \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"content":"# AGIL Calls API\n\nОбновлённое описание.","title":"AGIL Calls API"}'

# загрузить .md/.txt: raw body + x-file-name или JSON {name,content}
curl -X POST http://localhost:8787/v1/admin/docs/upload \
  -H 'x-api-key: dev-key' -H 'x-file-name: onboarding.md' -H 'content-type: text/markdown' \
  --data-binary @onboarding.md

# версии и восстановление
curl -s http://localhost:8787/v1/admin/docs/api-guide.md/versions -H 'x-api-key: dev-key' | jq
curl -s http://localhost:8787/v1/admin/docs/api-guide.md/versions/<id> -H 'x-api-key: dev-key' | jq -r .content
curl -X POST http://localhost:8787/v1/admin/docs/api-guide.md/versions/<id>/restore -H 'x-api-key: dev-key'

# выбрать основной документ
curl -X PUT http://localhost:8787/v1/admin/docs/default \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"name":"api-guide.md"}'

# сбросить custom к bundled (или удалить загруженный документ)
curl -X DELETE http://localhost:8787/v1/admin/docs/api-guide.md -H 'x-api-key: dev-key'
```

Правила: имя — `[a-z0-9][a-z0-9._-]*.(md|txt)` (traversal/абсолютные пути/вложенные
каталоги запрещены, путь дополнительно проверяется `resolve` внутри каталога);
размер ≤200KB (`413 payload_too_large`); каждый PUT/upload/restore архивирует
предыдущую эффективную версию; `DELETE` сбрасывает custom к bundled, для
загруженного без bundled — удаляет. Аудит: `admin.action` kind
`docs.updated|docs.uploaded|docs.reset|docs.restored|docs.default.changed` —
без содержимого документов. Explorer `/docs` показывает список документов и
выбранный основной (без `.history` и служебных файлов).

---

## 22. SaaS: организации (мультитенантность)

Один сервер/API обслуживает несколько организаций; у каждой свой конфиг
(брендинг, тема, фичи, безопасность, провайдеры). **Платформенный ключ**
`x-api-key: $AGIL_PLATFORM_KEY` управляет организациями; остальные ключи —
обычные tenant-ключи и видят только свой тенант. Environment-ключи
`AGIL_API_KEYS` продолжают работать (обратная совместимость); динамические ключи
хранятся **хэшем** в `AGIL_DATA_DIR/api-keys.json`.

### Платформенный список

```bash
curl -s https://calls.guardify.ru/v1/admin/tenants \
  -H "x-api-key: $AGIL_PLATFORM_KEY" | jq
# → { "platform": true, "tenants": [{ "tenantId":"demo", "name":"AGIL Calls",
#      "organizationName":"", "productName":"AGIL Calls", "logoUrl":"",
#      "scheme":"auto", "rooms":0, "participants":0, "recordings":0,
#      "apiKeyConfigured":true, "createdAt":1760000000000 }] }
```

### Создать организацию (ключ показывается один раз)

```bash
curl -X POST https://calls.guardify.ru/v1/admin/tenants \
  -H "x-api-key: $AGIL_PLATFORM_KEY" -H 'content-type: application/json' \
  -d '{
    "tenantId":"acme",
    "name":"ACME Meet",
    "organizationName":"ACME LLC",
    "branding":{"productName":"ACME Meet","logoUrl":"https://acme.ru/logo.png"},
    "theme":{"scheme":"dark"},
    "preset":"corporate"
  }'
# → { "tenant": {...}, "apiKey":"agil_acme_…", "embedUrl":"https://.../embed.html?tenant=acme" }
```

`preset`: `default` | `corporate` (waiting room, строгий lockout, без файлов/записи)
| `minimal` (только базовый звонок/чат). `templateTenantId` копирует конфиг
существующего тенанта. `tenantId` — `[a-zA-Z0-9._-]{1,64}`, дубль → `409`.

### Ключи и жизненный цикл

```bash
# ротация: старый динамический ключ этого тенанта инвалидируется
curl -X POST https://calls.guardify.ru/v1/admin/tenants/acme/keys \
  -H "x-api-key: $AGIL_PLATFORM_KEY"
# → { "tenantId":"acme", "apiKey":"agil_acme_…" }   # сохранить, показывается один раз

# summary/изменение брендинга (платформа или сам тенант своим ключом)
curl -s https://calls.guardify.ru/v1/admin/tenants/acme -H "x-api-key: agil_acme_…"
curl -X PATCH https://calls.guardify.ru/v1/admin/tenants/acme \
  -H 'x-api-key: agil_acme_…' -H 'content-type: application/json' \
  -d '{"branding":{"productName":"ACME Meet v2"}}'

# удаление (только платформа; demo и env-ключевые тенанты защищены)
curl -X DELETE https://calls.guardify.ru/v1/admin/tenants/acme \
  -H "x-api-key: $AGIL_PLATFORM_KEY"
```

Изоляция: ключ тенанта A на админ/публичных ручках тенанта B → `403`
(и `404` для чужих комнат), платформенный ключ не является tenant-ключом
(на tenant-ручках → `403`). Удаление архивирует конфиг в
`AGIL_DATA_DIR/tenants/.archive/`, отзывает динамический ключ и закрывает
in-memory комнаты; env-ключи API отозвать не может (такие тенанты не удаляются).
Аудит: `tenant.created|tenant.updated|tenant.key.rotated|tenant.deleted`.

---

## 23. Лобби (waiting room с admit/reject)

При `security.waitingRoom=true` гость после пароля попадает в очередь, а не
получает токен: `POST /v1/rooms/:id/join` → **202**
`{pending:true, requestId, roomId, status:"pending", expiresAt, pollAfterMs:2000}`.
Хост/модератор с валидным room-токеном или tenant-admin-ключом проходит сразу;
`waitingRoom=false` — прежнее поведение.

```bash
# гость
curl -X POST http://localhost:8787/v1/rooms/<room>/join \
  -H 'content-type: application/json' -d '{"name":"Гость"}'
# → 202 {"pending":true,"requestId":"...","status":"pending",...}

# хост/модератор: очередь (Bearer host-токен или x-api-key)
curl -s http://localhost:8787/v1/rooms/<room>/lobby -H 'authorization: Bearer <HOST_TOKEN>'
# → {"roomId":"...","requests":[{"id","name","country","createdAt","expiresAt","status":"pending"}]}

# впустить / отклонить / убрать из очереди
curl -X POST http://localhost:8787/v1/rooms/<room>/lobby/<requestId>/admit \
  -H 'authorization: Bearer <HOST_TOKEN>'
curl -X POST http://localhost:8787/v1/rooms/<room>/lobby/<requestId>/reject \
  -H 'authorization: Bearer <HOST_TOKEN>' -H 'content-type: application/json' -d '{"reason":"busy"}'
curl -X DELETE http://localhost:8787/v1/rooms/<room>/lobby/<requestId> -H 'authorization: Bearer <HOST_TOKEN>'

# гость опрашивает свою заявку; токен отдаётся ОДИН раз после admit
curl -s http://localhost:8787/v1/rooms/<room>/lobby/<requestId>
# → {"status":"admitted","token":"...","joinUrl":"...","embedUrl":"..."}  (повторно token отсутствует)
```

WS для host/moderator: `lobby.queue` при welcome, `lobby.pending`,
`lobby.admitted`, `lobby.rejected`. Лимит/TTL: `AGIL_LOBBY_MAX` (50) и
`AGIL_LOBBY_TTL_SEC` (600); при переполнении — `409 lobby_full`, истёкшая
заявка в poll — `status:"expired"`. Аудит: `lobby.pending`, `lobby.admitted`,
`lobby.rejected`, `lobby.expired`.

## 24. Календарь (ICS)

`GET /v1/rooms/:id/calendar.ics[?start=ISO&durationMin=60&download=1]` —
публичный (capability = roomId) `text/calendar; charset=utf-8`: VCALENDAR/VEVENT
с UTC `DTSTART/DTEND`, `SUMMARY` (имя комнаты), `DESCRIPTION/LOCATION/URL`
(join-ссылка), `UID=<roomId>@<host>`, `STATUS:CONFIRMED`. `download=1` —
`content-disposition: attachment`. Неизвестная комната → 404, неверный `start` → 400.

## 25. Кастомный домен (white-label, каркас)

`GET/PUT/DELETE /v1/admin/domain` (tenant admin):

```bash
curl -X PUT http://localhost:8787/v1/admin/domain \
  -H 'x-api-key: dev-key' -H 'content-type: application/json' \
  -d '{"domain":"meet.acme.com"}'
# → {"domain":"meet.acme.com","verified":false,
#    "dnsInstructions":{"type":"CNAME","name":"meet.acme.com","value":"calls.guardify.ru","ttl":300},
#    "docsHint":"..."}
```

Домен хранится в `branding.customDomain`; валидация формата (lowercase,
без схемы/пути/порта, метки 1..63, ≤253, без IP/localhost/wildcards), пустая
строка/DELETE — сброс. `verified` пока всегда `false` (DNS/ACME-верификация —
roadmap, см. docs/saas.md). Аудит `domain.updated`.

---

## 26. Опросы и Q&A

Опросы живут в комнате (in-memory, до 50 на комнату) и синхронизируются по WS
событием `poll.updated {roomId, poll, reason:"created"|"voted"|"closed"}`.
Создавать/закрывать может host/moderator (room-scoped Bearer host/moderator или
tenant admin key), голосовать — участник с join-токеном.

```bash
# создать (host/moderator)
curl -X POST http://localhost:8787/v1/rooms/<room>/polls \
  -H 'authorization: Bearer <HOST_TOKEN>' -H 'content-type: application/json' \
  -d '{"question":"Куда идём на обед?","options":[{"text":"Пицца"},{"text":"Суши"}],
       "multiple":false,"anonymous":true,"kind":"poll"}'
# → RoomPoll {id, options:[{id,text,votes,share}], status:"open", totalVotes:0, ...}

# список (любой участник; selected/voted персонализируются по токену)
curl -s http://localhost:8787/v1/rooms/<room>/polls \
  -H 'authorization: Bearer <GUEST_TOKEN>'

# голос (один на участника; optionIds 1..10; для multiple — несколько)
curl -X POST http://localhost:8787/v1/rooms/<room>/polls/<pollId>/vote \
  -H 'authorization: Bearer <GUEST_TOKEN>' -H 'content-type: application/json' \
  -d '{"optionIds":["<optionId>"]}'
# → {poll, accepted:true}  |  повтор: {accepted:false, reason:"already_voted"}

# закрыть (host/moderator)
curl -X POST http://localhost:8787/v1/rooms/<room>/polls/<pollId>/close \
  -H 'authorization: Bearer <HOST_TOKEN>'
# → RoomPoll status:"closed", closedAt
```

Ограничения: question 3..500, вариантов 2..10 (текст 1..200), нельзя два варианта
в single-choice (`accepted:false reason:"single_choice_only"`), невалидный
optionId → `accepted:false reason:"invalid_option"`, голос после закрытия →
`409 poll_closed`, неизвестный poll → `404 poll_not_found`, лимит комнаты →
`409 poll_limit`, гость создаёт/закрывает → `403`. `share` — доля от числа
голосующих (0..1), `totalVotes` — число проголосовавших. Аудит: `poll.created`,
`poll.voted` (participantId, без имени), `poll.closed`.

---

## 27. Breakout rooms

Host/moderator делит участников на подкомнаты: создаются child-комнаты в том же
тенанте, каждому участнику выдаётся room-токен на его подкомнату и приходит WS
`breakout.assigned`; все видят `breakout.started {plan}`. Активен только один
план; лимит 20 подкомнат; child-комнаты скрыты из общего списка комнат админки.

```bash
# старт (host/moderator room Bearer или tenant admin key)
curl -X POST http://localhost:8787/v1/rooms/<room>/breakouts/start \
  -H 'authorization: Bearer <HOST_TOKEN>' -H 'content-type: application/json' \
  -d '{"mode":"random","count":2,"durationMin":15,"allowReturn":true}'
# manual: {"mode":"manual","rooms":[{"name":"Дизайн","participantIds":["p1","p2"]},
#                                  {"name":"Разработка","participantIds":["p3"]}]}
# → BreakoutPlan {id,status:"active",rooms:[{id,roomId,name,index,participantIds,status}],
#                 assignments:{participantId:breakoutRoomId}, endsAt?}

# текущий план (host/moderator/admin)
curl -s http://localhost:8787/v1/rooms/<room>/breakouts -H 'authorization: Bearer <HOST_TOKEN>'

# вернуться в основной звонок (участник; 403 если allowReturn=false)
curl -X POST http://localhost:8787/v1/rooms/<room>/breakouts/return \
  -H 'authorization: Bearer <MAIN_TOKEN>'
# → {"returnToRoomId":"<main-room>"}

# закрыть (host/moderator/admin): child-комнаты закрываются, план closed
curl -X POST http://localhost:8787/v1/rooms/<room>/breakouts/close \
  -H 'authorization: Bearer <HOST_TOKEN>'
```

Ограничения/ошибки: уже активен → `409 breakout_active`; <2 участников →
`409 not_enough_participants`; count/rooms вне 2..20 → `400`; manual с чужим/
дублирующим participantId → `400`; нет плана → `404`; старт/закрытие/список
гостем → `403`. `durationMin` (1..240) задаёт `endsAt`; по истечении план
закрывается лениво при следующем GET/start и таймером `setTimeout` (если процесс
жив), с broadcast `breakout.closed`. WS: `breakout.started {plan}`,
`breakout.assigned {assignment}` (каждому участнику, включая host),
`breakout.closed {plan}`. Токены основной комнаты остаются валидны; child-комнаты
имеют собственный capacity и не попадают в список комнат админки. Аудит:
`breakout.started`, `breakout.closed` (и `admin.action` kind `breakout.returned`).

---

## 28. SFU media (SFU-плоскость)

Когда эффективный режим медиа — `sfu`/`broadcast` (см. `config.scale.mediaMode`
/ webinar) **и** SFU настроен, сервер выдаёт клиенту короткоживущий HS256 JWT
(LiveKit-совместимый) в `JoinRoomResponse.media` и `welcome.media`:

```json
{
  "mode": "sfu",
  "url": "wss://sfu.calls.guardify.ru",
  "token": "eyJhbGciOiJIUzI1NiIs…",
  "room": "demo__<roomId>",
  "canPublish": true,
  "expiresAt": 1794600000000
}
```

Claims: `iss` = API key SFU, `sub` = participantId, `name`, `nbf/exp`
(TTL = min(1h, TTL join-токена)), `video:{room, roomJoin:true, canPublish,
canSubscribe:true}`. Подпись HS256 секретом SFU (`node:crypto`), токены не
логируются. Комната SFU = `<tenantId>__<roomId>` (небезопасные разделители
заменяются). `canPublish`: host/moderator всегда; guest — по умолчанию да
(MVP mesh-паритет), но в webinar/broadcast viewer (`scale.webinar.enabled &&
hostOnlyPublish` или `mediaMode=broadcast` для guest) — `false`
(receive-only). `canSubscribe` всегда true.

Настройка (env приоритетнее tenant-конфига):
`AGIL_SFU_URL`, `AGIL_SFU_API_KEY`, `AGIL_SFU_API_SECRET`,
`AGIL_SFU_ENABLED` (default auto = все три заданы). `GET /v1/admin/scale`
показывает реальный `sfuConfigured`. Если SFU не настроен/выключен — `media`
отсутствует, клиент остаётся на mesh (обратная совместимость); при mesh-режиме
media не выдаётся. UI (SfuEngine) подключается к `url` с `token`.
