{"openapi":"3.0.3","info":{"title":"Highnet Partner API","version":"1.0.0","contact":{"name":"Highnet"},"description":"# Highnet Partner API\n\nДокументация **только для партнёров** (ключи формата `hn_…`): создание пользователей, продление, VPN-профиль, публичная подписка, статистика.\n\nAdmin / agent / internal endpoint'ы — в [`/docs/internal`](/docs/internal).\n\n## Базовый URL\n\nВсе пути ниже — от корня API (например `https://your-host:3000`).\nЗа reverse proxy проксируйте **весь** upstream, не только `/user`.\n\n## Авторизация (Partner API key)\n\nФормат ключа: **`hn_` + 64 hex-символа**. Scope: **`can_user_api`** (не admin/agent/internal).\n\nПример ключа партнёра **example**:\n\n```\nhn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger нажмите **Authorize** и вставьте Partner key в поле **PartnerApiKey** (`X-API-Key`):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nТот же ключ можно передать как `Authorization: Bearer …` — это один secret, не второй ключ.\n\nКлюч привязан к партнёру: вы видите **только своих** пользователей (`created_by_key_id`).\n\n## Полный сценарий интеграции (пошагово)\n\n### Шаг 1 — Создать пользователя\n`POST /user/create` с `{ \"id\": \"client001\" }`\n\nСохраните из ответа:\n- `public_subscription.url` — ссылка для клиента\n- `public_subscription.token` — secret для `?t=` (повторно не отдаётся, кроме `subscription-token`)\n- `expires_at` — когда trial закончится\n\n### Шаг 2 — Отдать клиенту VPN\nОтдайте `public_subscription.url` или `karing_url` — клиент сам обновляет подписку.\n\n### Шаг 3 — Продление после оплаты\n`POST /user/extend` с `{ \"id\": \"client001\", \"duration\": 30 }` или `365`.\n\n### Шаг 4 — Если ссылка утекла\n`POST /user/{id}/subscription-token` — **старый token сразу недействителен**.\n\n### Шаг 5 — Ваша статистика\n`GET /partner/stats` — trials, extend, revenue.\n\n## Публичная подписка для клиента\n\n`GET /{userId}/sub?t=<token>` — отдаёт base64 VLESS-строки для Karing/Clash.\n\n- Token выдаётся при create или через `subscription-token`\n- UUID в подписке: per-user (`SUBSCRIPTION_UUID_MODE=user`) — у каждого клиента свой UUID\n- API key в клиентское приложение **не кладите**\n\n## VPN-сессии пользователей (Agent)\n\n`GET /user/{id}/sessions` — список VPN-устройств пользователя с ноды.\n\nДанные из `agent_device_sessions` (sing-box node-agent), не HTTP `/sub`.\n\n## Типовые ошибки\n\n| HTTP | error | Когда |\n|------|-------|-------|\n| 401 | unauthorized | нет или неверный API key |\n| 403 | forbidden / not_owner | пользователь создан другим ключом |\n| 404 | not_found | пользователь не существует |\n| 409 | id_exists | duplicate user id |\n\nВнутренняя документация (admin, agent, internal): `/docs/internal`."},"servers":[{"url":"/","description":"Текущий хост (compose :3000 или reverse proxy)"},{"url":"http://ns1.turbopatriot.ru:3000","description":"Production example"}],"tags":[{"name":"system-health","description":"**Проверка доступности API** перед интеграцией или в uptime-мониторинге.\n\nEndpoint проверяет PostgreSQL (обязательно) и Redis (если включён на сервере).\nТребует тот же Partner API key, что и остальные методы — это не anonymous health."},{"name":"users-lifecycle","description":"**Жизненный цикл пользователя VPN.**\n\n| Метод | Действие |\n|-------|----------|\n| `POST /user/create` | Создать trial + subscription token |\n| `POST /user/extend` | Продлить на 14, 30 или 365 дней |\n| `POST /user/{id}/subscription-token` | Перевыпустить secret для `/sub` |\n\n**Изоляция партнёров:** ключ A не может читать/продлевать пользователей ключа B."},{"name":"subscriptions-public","description":"**Публичная подписка для конечного клиента** (Karing, Clash, v2rayNG, NekoBox и т.д.).\n\nЭтот URL **отдаёте клиенту**. API key в клиентское приложение **не кладите**.\nДоступ защищён secret token в query `?t=`."},{"name":"subscriptions-api","description":"**Подписка через Partner API key** — server-to-server.\n\nВаш backend скачивает VLESS-строки и отдаёт клиенту сам.\nОтличие от публичного `/sub`: нужен `X-API-Key`, нет token в URL, **нет** enforcement лимита устройств по HTTP."},{"name":"user-agent-sessions","description":"**VPN-сессии пользователей (Agent API).**\n\nДанные с sing-box нод: таблица `agent_device_sessions`.\n\n| Endpoint | Назначение |\n|----------|------------|\n| `GET /user/{id}/sessions` | Список коннектов (по `device_key`) |\n| `GET /user/{id}/segments` | IP-сегменты для лимита (`limit_unit=ip`) |"},{"name":"partner-billing","description":"**Статистика биллинга** вашего Partner API key.\n\nСобытия: `trial_create`, `extend_14`, `extend_30`, `extend_365`.\nRevenue = количество × цена. Для 14 дней цена = 50% от `extend_30_price`."}],"security":[{"PartnerApiKey":[]}],"x-tagGroups":[{"name":"Начало работы","tags":["system-health","partner-billing"]},{"name":"Пользователи","tags":["users-lifecycle"]},{"name":"Подписка клиента","tags":["subscriptions-public","subscriptions-api"]},{"name":"VPN-сессии (Agent)","tags":["user-agent-sessions"]}],"paths":{"/health":{"get":{"tags":["system-health"],"summary":"Проверка работоспособности API и БД","description":"## Назначение\nПроверяет, что API процесс жив и может выполнить `SELECT 1` в PostgreSQL.\nОпционально пингует Redis (если `REDIS_DISABLED≠1`).\n\n## Когда вызывать\n- Перед первым деплоем интеграции\n- В uptime-мониторинге (раз в 1–5 минут)\n- После инцидента — быстрая проверка «API отвечает»\n\n### Авторизация (Partner API key)\n\nОдин ключ на все запросы. Формат: `hn_` + **64 hex-символа**.\nScope: **только** `can_user_api` (не admin / agent / internal).\n\nПример (партнёр **example**):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger: **Authorize → PartnerApiKey** — вставьте свой `hn_…` ключ.\n\nТот же secret можно передать как `Authorization: Bearer hn_…` (опционально, для curl/кода).\n\nВы видите **только пользователей, созданных вашим ключом** (`created_by_key_id`).\n\n### Пример запроса\n```bash\ncurl -sS -H \"X-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\" https://your-api.example/health\n```\n\n### Успешный ответ `200`\n```json\n{ \"ok\": true, \"redis_ok\": true }\n```\n\n| Поле | Тип | Описание |\n|------|-----|----------|\n| `ok` | boolean | `true` — PostgreSQL доступна |\n| `redis_ok` | boolean \\| null | `true/false` если Redis включён; `null` если Redis отключён |\n\n### Ошибки\n| HTTP | Тело | Причина |\n|------|------|---------|\n| 401 | `{ \"error\": \"unauthorized\" }` | Нет или неверный API key |\n| 503 | `{ \"ok\": false }` | PostgreSQL недоступна |","security":[{"PartnerApiKey":[]}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthOk"}}}},"401":{"description":"Missing or invalid API key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"DB unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthFail"}}}}}}},"/user/create":{"post":{"tags":["users-lifecycle"],"summary":"Создать нового пользователя (trial)","description":"## Назначение\nГлавная точка входа интеграции. Одним запросом:\n\n1. Создаёт запись пользователя в PostgreSQL\n2. Регистрирует DNS-домен `{id}.{base_domain}` (например `client001.turbopatriot.ru`)\n3. Генерирует **subscription token** для публичной ссылки `GET /{id}/sub?t=…`\n4. Создаёт **per-user UUID** для VLESS (если на сервере `SUBSCRIPTION_UUID_MODE=user`)\n5. Пишет billing-событие `trial_create` на ваш Partner key\n\nTrial-длительность задаётся на сервере (`DEFAULT_TTL_DAYS`, обычно 3 дня).\n\n### Авторизация (Partner API key)\n\nОдин ключ на все запросы. Формат: `hn_` + **64 hex-символа**.\nScope: **только** `can_user_api` (не admin / agent / internal).\n\nПример (партнёр **example**):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger: **Authorize → PartnerApiKey** — вставьте свой `hn_…` ключ.\n\nТот же secret можно передать как `Authorization: Bearer hn_…` (опционально, для curl/кода).\n\nВы видите **только пользователей, созданных вашим ключом** (`created_by_key_id`).\n\n### Правила ID пользователя\n\n| Правило | Пример OK | Пример BAD |\n|---------|-----------|------------|\n| Только `a-z` и `0-9` | `client001` | `Client001` |\n| До 64 символов | `user42` | строка >64 символов |\n| Без дефисов/underscore | `shopuser1` | `shop-user-1` |\n| Не зарезервированное слово | `myvpn01` | `health`, `admin`, `docs` |\n\n### Тело запроса\n| Поле | Тип | Обязательно | Описание |\n|------|-----|-------------|----------|\n| `id` | string | да* | ID пользователя |\n| `username` | string | да* | Синоним `id` — достаточно одного из двух |\n\n### Пример запроса\n```bash\ncurl -sS -X POST https://your-api.example/user/create \\\n  -H \"X-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"id\":\"client001\"}'\n```\n\n### Успешный ответ `201`\n```json\n{\n  \"id\": \"client001\",\n  \"expires_at\": 1735689600,\n  \"public_subscription\": {\n    \"path\": \"/client001/sub\",\n    \"url\": \"https://api.example/client001/sub?t=m4_xxxxxxxx\",\n    \"token\": \"m4_xxxxxxxx\",\n    \"karing_url\": \"karing://install-config?url=...\",\n    \"clash_url\": \"clash://install-config?url=...\"\n  }\n}\n```\n\n| Поле | Описание | Что делать |\n|------|----------|------------|\n| `id` | ID пользователя | Сохраните у себя как primary key |\n| `expires_at` | Unix timestamp окончания trial | Покажите клиенту дату окончания |\n| `public_subscription.url` | **Готовая ссылка для клиента** | Отдайте в бота/ЛК/QR |\n| `public_subscription.token` | Secret для `?t=` | **Сохраните в БД** — повторно через API не отдаётся автоматически |\n| `public_subscription.path` | Относительный путь `/client001/sub` | Для сборки URL на своём домене |\n| `karing_url` / `clash_url` | Deep link one-click install | Кнопка «Добавить в Karing/Clash» |\n\n### Типовой сценарий после create\n```\nPOST /user/create\n  → сохранить token + url\n  → отдать клиенту url или karing_url\n```\n\n### Ошибки\n| HTTP | error | detail | Когда |\n|------|-------|--------|-------|\n| 400 | `invalid_username` | `use only a-z0-9` | Некорректный ID |\n| 401 | `unauthorized` | — | Нет API key |\n| 409 | `id_exists` | — | ID уже занят глобально |\n| 500 | `internal` | — | Ошибка сервера |","security":[{"PartnerApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreateBody"},"examples":{"by_id":{"summary":"Создание по полю id","value":{"id":"client001"}},"by_username":{"summary":"Создание по полю username","value":{"username":"client001"}}}}}},"responses":{"201":{"description":"Пользователь создан. **Обязательно сохраните** `public_subscription.token` — повторно через API не отдаётся (кроме `POST /user/{id}/subscription-token`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserCreateResponse"},"example":{"id":"client001","expires_at":1735689600,"public_subscription":{"path":"/client001/sub","url":"https://example.com/client001/sub?t=m4_xxxxxxxx","token":"m4_xxxxxxxx","karing_url":"karing://install-config?url=...","clash_url":"clash://install-config?url=..."}}}}},"400":{"description":"Некорректный username — только a-z0-9, до 64 символов","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"invalid_username","detail":"use only a-z0-9"}}}},"401":{"description":"unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Пользователь с таким ID уже существует в системе","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"},"example":{"error":"id_exists"}}}},"500":{"description":"internal","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/user/extend":{"post":{"tags":["users-lifecycle"],"summary":"Продлить подписку на 14, 30 или 365 дней","description":"## Назначение\nДобавляет оплаченный период к пользователю и записывает billing-событие.\n\n### Логика расчёта `expires_at`\n\n```\nbase = max(сейчас, текущий expires_at)\nновый expires_at = base + duration дней (UTC)\n```\n\n**Примеры:**\n- Подписка до 2026-06-01, сегодня 2026-05-15, extend 30 → новый срок ~2026-07-01\n- Подписка истекла 2026-01-01, сегодня 2026-06-11, extend 30 → срок ~2026-07-11 (от «сейчас»)\n\nBilling: `extend_14`, `extend_30` или `extend_365` → попадает в `GET /partner/stats`.\nЦена 14 дней = 50% от `extend_30_price` (отдельная цена не задаётся).\n\n### Авторизация (Partner API key)\n\nОдин ключ на все запросы. Формат: `hn_` + **64 hex-символа**.\nScope: **только** `can_user_api` (не admin / agent / internal).\n\nПример (партнёр **example**):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger: **Authorize → PartnerApiKey** — вставьте свой `hn_…` ключ.\n\nТот же secret можно передать как `Authorization: Bearer hn_…` (опционально, для curl/кода).\n\nВы видите **только пользователей, созданных вашим ключом** (`created_by_key_id`).\n\n### Тело запроса\n| Поле | Тип | Обязательно | Описание |\n|------|-----|-------------|----------|\n| `id` / `username` | string | да | ID пользователя |\n| `duration` | integer | да* | 14, 30 или 365 |\n| `duration_days` | integer | да* | Синоним `duration` |\n\n### Пример\n```bash\ncurl -sS -X POST https://your-api.example/user/extend \\\n  -H \"X-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"id\":\"client001\",\"duration\":30}'\n```\n\n### Ответ `200`\n```json\n{\n  \"username\": \"client001\",\n  \"domain\": \"client001.turbopatriot.ru\",\n  \"expires_at\": 1740000000,\n  \"status\": \"active\"\n}\n```\n\n| Поле | Описание |\n|------|----------|\n| `username` | ID пользователя |\n| `domain` | Полное DNS-имя |\n| `expires_at` | Новый Unix timestamp |\n| `status` | `active` если срок в будущем, иначе `expired` |\n\n### Ошибки\n| HTTP | error | detail / allowed | Когда |\n|------|-------|------------------|-------|\n| 400 | `invalid_username` | — | Плохой ID |\n| 400 | `invalid_duration` | `allowed: [14, 30, 365]` | Недопустимый срок |\n| 401 | `unauthorized` | — | Нет key |\n| 403 | `forbidden` | `not_owner` | Чужой пользователь |\n| 404 | `not_found` | — | Нет такого ID |","security":[{"PartnerApiKey":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserExtendBody"},"examples":{"extend_14":{"summary":"Продление на 14 дней","value":{"id":"client001","duration":14}},"extend_30":{"summary":"Продление на 30 дней","value":{"id":"client001","duration":30}},"extend_365":{"summary":"Продление на 365 дней","value":{"id":"client001","duration_days":365}}}}}},"responses":{"200":{"description":"Extended","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserExtendResponse"}}}},"400":{"description":"invalid_username or duration","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"not_owner","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"internal","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/user/{id}/subscription-token":{"post":{"tags":["users-lifecycle"],"summary":"Перевыпустить secret token публичной подписки","description":"## Назначение\nГенерирует **новый** secret token для `GET /{userId}/sub?t=...`.\n\n### ⚠️ Важно\n**Старый token немедленно перестаёт работать.**\nВсе клиенты со старой ссылкой потеряют доступ к подписке, пока не получат новый URL.\n\n## Когда вызывать\n- Ссылка на подписку утекла / скомпрометирована\n- Потеряли token (он есть только в ответе create или subscription-token)\n- Нужно «отозвать» доступ без смены user id\n\n### Авторизация (Partner API key)\n\nОдин ключ на все запросы. Формат: `hn_` + **64 hex-символа**.\nScope: **только** `can_user_api` (не admin / agent / internal).\n\nПример (партнёр **example**):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger: **Authorize → PartnerApiKey** — вставьте свой `hn_…` ключ.\n\nТот же secret можно передать как `Authorization: Bearer hn_…` (опционально, для curl/кода).\n\nВы видите **только пользователей, созданных вашим ключом** (`created_by_key_id`).\n\n### Path parameter\n`id` — ID пользователя. **Тело запроса не нужно.**\n\n### Пример\n```bash\ncurl -sS -X POST -H \"X-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\" \\\n  https://your-api.example/user/client001/subscription-token\n```\n\n### Ответ `200`\n```json\n{\n  \"token\": \"m4_NEW_TOKEN...\",\n  \"path\": \"/client001/sub\",\n  \"url\": \"https://api.example/client001/sub?t=m4_NEW_TOKEN...\",\n  \"karing_url\": \"karing://...\",\n  \"clash_url\": \"clash://...\"\n}\n```\n\n| Поле | Описание |\n|------|----------|\n| `token` | Новый plain token для `?t=` |\n| `url` | Полный HTTPS URL для клиента |\n| `path` | Относительный путь |\n\n### Ошибки\n| HTTP | JSON | Когда |\n|------|------|-------|\n| 403 | `{ \"error\": \"forbidden\", \"detail\": \"not_owner\" }` | Пользователь создан другим Partner key |\n| HTTP | error | Когда |\n|------|-------|-------|\n| 404 | `not_found` | Нет пользователя |","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"ID пользователя","schema":{"$ref":"#/components/schemas/UserId"},"example":"client001"}],"responses":{"200":{"description":"Token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubscriptionTokenResponse"}}}},"403":{"description":"forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/user/{id}/sessions":{"get":{"tags":["user-agent-sessions"],"summary":"VPN-устройства пользователя (Agent)","description":"## Назначение\nСписок **реальных VPN-сессий** пользователя с sing-box нод (`agent_device_sessions`).\n\nNode-agent шлёт `POST /agent/sessions/report`; backend считает active/blocked по `device_key`.\n\n### Авторизация (Partner API key)\n\nОдин ключ на все запросы. Формат: `hn_` + **64 hex-символа**.\nScope: **только** `can_user_api` (не admin / agent / internal).\n\nПример (партнёр **example**):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger: **Authorize → PartnerApiKey** — вставьте свой `hn_…` ключ.\n\nТот же secret можно передать как `Authorization: Bearer hn_…` (опционально, для curl/кода).\n\nВы видите **только пользователей, созданных вашим ключом** (`created_by_key_id`).\n\n### Query parameters\n| Параметр | Default | Max | Описание |\n|----------|---------|-----|----------|\n| `limit` | 100 | 500 | Записей на страницу |\n| `offset` | 0 | — | Пагинация |\n| `status` | `all` | — | `all`, `live` (`active+blocked`), `active`, `blocked`, `expired` |\n\n### Поля ответа\n| Поле | Описание |\n|------|----------|\n| `user_id`, `uuid` | Пользователь и per-user VLESS UUID |\n| `active_count` | Активных устройств |\n| `blocked_count` | Заблокированных (лимит 5) |\n| `expired_count` | Исторических/протухших записей |\n| `latest_seen_at` | Последняя активность по пользователю |\n| `status_filter` | Применённый фильтр списка `items[]` |\n| `max_devices` | Лимит (обычно 5) |\n| `remaining_slots` | Свободных слотов для нового device |\n| `enforcement_mode` | `observe` / `enforce_new_only` |\n| `source` | всегда `agent` |\n| `items[]` | Сессии: `node_id`, `status`, `connected_at`, `metadata`, `device_key_hash`, … |\n\n### Статусы в `items[]`\n| status | Значение |\n|--------|----------|\n| `active` | Устройство подключено |\n| `blocked` | Отклонено (напр. `max_devices`) |\n| `expired` | Давно не видели на ноде |\n\nДля лимита по IP см. **`GET /user/{id}/segments`**.","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"ID пользователя","schema":{"$ref":"#/components/schemas/UserId"},"example":"client001"},{"name":"limit","in":"query","required":false,"description":"Записей на страницу (1–500, default 100)","schema":{"type":"integer","minimum":1,"maximum":500,"default":100}},{"name":"offset","in":"query","required":false,"description":"Смещение для пагинации","schema":{"type":"integer","minimum":0,"default":0}},{"name":"status","in":"query","required":false,"description":"Фильтр списка: all/live/active/blocked/expired","schema":{"type":"string","enum":["all","live","active","blocked","expired"],"default":"all"}}],"responses":{"200":{"description":"Sessions","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserAgentSessionsResponse"}}}},"403":{"description":"forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/user/{id}/segments":{"get":{"tags":["user-agent-sessions"],"summary":"IP-сегменты пользователя (Agent enforcement)","description":"## Назначение\nАгрегированные **masked IP-сегменты** (`/24` или `/64`) по активным VPN-сессиям.\n\nИспользуется для лимита при `AGENT_LIMIT_UNIT=ip`: в слот идут только **стабильные** сегменты.\n\n### Авторизация (Partner API key)\n\nОдин ключ на все запросы. Формат: `hn_` + **64 hex-символа**.\nScope: **только** `can_user_api` (не admin / agent / internal).\n\nПример (партнёр **example**):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger: **Authorize → PartnerApiKey** — вставьте свой `hn_…` ключ.\n\nТот же secret можно передать как `Authorization: Bearer hn_…` (опционально, для curl/кода).\n\nВы видите **только пользователей, созданных вашим ключом** (`created_by_key_id`).\n\n### Стабильный сегмент\nСегмент считается стабильным, если в окне `session_ttl_sec`:\n- ≥ `segment_min_hits` активных коннектов (default 4)\n- ≥ `segment_min_age_sec` между первым и последним seen (default 120)\n\n### Query parameters\n| Параметр | Default | Описание |\n|----------|---------|----------|\n| `stable` | false | `true`/`1` — только стабильные сегменты в `items[]` |\n\n### Поля ответа\n| Поле | Описание |\n|------|----------|\n| `stable_segment_count` | Сегментов, которые занимают слот лимита |\n| `pending_segment_count` | Видны в окне, но ещё не стабильны |\n| `active_connection_count` | Сырые active-строк (как `active_count` в `/sessions`) |\n| `remaining_slots` | `max_devices - stable_segment_count` при `limit_unit=ip` |\n| `items[]` | Сегменты: `segment`, `stable`, `hits`, `age_sec`, `src_ips`, `needs_*` |","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"ID пользователя","schema":{"$ref":"#/components/schemas/UserId"},"example":"client001"},{"name":"stable","in":"query","required":false,"description":"true/1 — только стабильные сегменты в items[]","schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"IP segments","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserIpSegmentsResponse"}}}},"403":{"description":"forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/user/subscription":{"get":{"tags":["subscriptions-api"],"summary":"Подписка server-to-server (с Partner API key)","description":"## Назначение\nТот же **формат контента**, что клиент получает по публичному `/sub`, но:\n\n| | `/user/subscription` | `GET /{userId}/sub` |\n|---|---------------------|---------------------|\n| Авторизация | Partner API key | Secret token `?t=` |\n| Кто вызывает | Ваш backend | Клиентское VPN-приложение |\n| Лимит устройств (HTTP) | **Нет** | **Да** (если enforcement включён) |\n| Per-user UUID в VLESS | Shared UUID (legacy) | Per-user UUID (если `SUBSCRIPTION_UUID_MODE=user`) |\n\nИспользуйте, если ваш сервер сам забирает VLESS и отдаёт клиенту.\nДля типовой интеграции проще отдать клиенту `public_subscription.url` из create.\n\n### Авторизация (Partner API key)\n\nОдин ключ на все запросы. Формат: `hn_` + **64 hex-символа**.\nScope: **только** `can_user_api` (не admin / agent / internal).\n\nПример (партнёр **example**):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger: **Authorize → PartnerApiKey** — вставьте свой `hn_…` ключ.\n\nТот же secret можно передать как `Authorization: Bearer hn_…` (опционально, для curl/кода).\n\nВы видите **только пользователей, созданных вашим ключом** (`created_by_key_id`).\n\n### Query parameters\n| Параметр | Обязательно | Описание |\n|----------|-------------|----------|\n| `user` | да* | ID пользователя |\n| `username` | да* | Синоним `user` |\n| `format` | нет | `base64` (default), `plain`, `json` |\n\n### Пример\n```bash\ncurl -sS -H \"X-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\" \\\n  \"https://your-api.example/user/subscription?user=client001&format=base64\"\n```\n\n### Форматы ответа\n| format | Content-Type | Что получите |\n|--------|--------------|--------------|\n| `base64` | text/plain | Base64-encoded VLESS URI (стандарт подписок) |\n| `plain` | text/plain | Текст + metadata, без base64 |\n| `json` | application/json | `{ username, expires_at, servers: [...] }` |\n\n**Декодирование base64:**\n```bash\ncurl ... | base64 -d\n# → vless://UUID@IP:PORT?encryption=none&flow=...\n```\n\nЕсли подписка **истекла** — вместо рабочих URI вернётся специальная expired-строка.\n\n### Rate limit\nЛимит запросов на ключ/IP (по умолчанию ~20/мин). При превышении: `429`.\n\n### Ошибки\n| HTTP | error | detail | Когда |\n|------|-------|--------|-------|\n| 400 | `invalid_user` | `set query user or username` | Нет query user |\n| 400 | `invalid_format` | — | format не base64/plain/json |\n| 403 | `forbidden` | `not_owner` | Чужой пользователь |\n| 404 | `not_found` | — | Нет пользователя |\n| 429 | `rate_limited` | — | Слишком частые запросы |","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"user","in":"query","required":true,"description":"ID пользователя (альтернатива: query `username`)","schema":{"$ref":"#/components/schemas/UserId"},"example":"client001"},{"name":"username","in":"query","required":false,"description":"Синоним `user`","schema":{"$ref":"#/components/schemas/UserId"}},{"name":"format","in":"query","required":false,"description":"Формат тела: base64 (для клиентов), plain (отладка), json (структура)","schema":{"type":"string","enum":["base64","plain","json"],"default":"base64"}}],"responses":{"200":{"description":"base64 (default), plain or json subscription body","content":{"text/plain":{"schema":{"type":"string"}},"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"403":{"description":"forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"rate_limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/{userId}/sub":{"get":{"tags":["subscriptions-public"],"summary":"Публичная подписка для клиента (Karing / Clash / v2rayNG)","description":"## Назначение\n**Главная ссылка для конечного пользователя.**\n\nVPN-клиент периодически (каждые N часов) запрашивает этот URL и обновляет список серверов.\nAPI key **не нужен** — только secret token.\n\n### Как это работает (пошагово)\n```\n1. Вы создаёте пользователя → получаете url с ?t=TOKEN\n2. Клиент добавляет url в Karing/Clash\n3. Приложение делает GET /client001/sub?t=TOKEN\n4. Сервер проверяет token, срок подписки, (опционально) лимит устройств\n5. Отдаёт base64 с vless:// строками\n6. Клиент декодирует и подключается\n```\n\n## Авторизация\n**Без API key.** Secret в query:\n\n| Query param | Поддержка |\n|-------------|-----------|\n| `t` | ✅ рекомендуется |\n| `token` | ✅ синоним |\n| `key` | ✅ синоним |\n\nToken выдаётся при `POST /user/create` или `POST /user/{id}/subscription-token`.\n\n### URL\n```\nGET https://<api-host>/<userId>/sub?t=<subscription_token>\n```\n\n**Пример:**\n`GET /client001/sub?t=m4_visEGWDKo0U7a71B_...`\n\n### Query parameters\n| Параметр | Обязательно | Описание |\n|----------|-------------|----------|\n| `t` | **да** | Subscription token |\n| `format` | нет | `base64` (default), `plain`, `json` |\n\n### Path parameter `userId`\n- Только `a-z0-9`, до 64 символов\n- **Запрещены** зарезервированные имена: `health`, `user`, `admin`, `partner`, `internal`, `docs`, `openapi`, `favicon`, `robots`, `sub`\n\n### Что внутри (format=base64)\n```\nbase64_decode(response) →\nvless://<per-user-uuid>@<node-ip>:52006?encryption=none&flow=xtls-rprx-vision&security=reality&...#Highnet\n```\n\nПри `SUBSCRIPTION_UUID_MODE=user` UUID **уникален для каждого пользователя**.\n\n### Поведение при истечении подписки\nЕсли `expires_at` в прошлом — вместо рабочих URI возвращается **expired-строка**.\nКлиент не сможет подключиться — нужно `POST /user/extend`.\n\n### Лимит устройств (HTTP enforcement)\nЕсли на сервере включён `USER_ACTIVITY_ENABLED=1` и режим `hard`:\n- При превышении лимита активных «устройств» (по эвристике HTTP) → special limit-exceeded строка\n- В JSON-формате: `limit_exceeded: true`, `limit_type: max_active_connections`\n- Это **не** agent enforcement на VPN-ноде — только содержимое подписки\n\n### Rate limit\nПубличный endpoint лимитируется (по умолчанию ~60 req/min на IP). `429` при превышении.\n\n### Безопасность token\n- Передавайте URL только по HTTPS\n- Token = пароль к подписке; при утечке → `subscription-token`\n\n### Ошибки\n| HTTP | error | detail | Когда |\n|------|-------|--------|-------|\n| 401 | `unauthorized` | `missing_token` | Нет `t`/`token`/`key` |\n| 403 | `forbidden` | `invalid_token` | Неверный token |\n| 403 | `forbidden` | `public_subscription_not_configured` | Token ещё не создавался |\n| 404 | `not_found` | — | Нет userId или зарезервированное имя |\n| 429 | — | — | Rate limit |","security":[],"parameters":[{"name":"userId","in":"path","required":true,"description":"ID пользователя (a-z0-9). Нельзя: health, user, admin, docs, partner, internal, openapi, favicon, robots, sub","schema":{"$ref":"#/components/schemas/UserId"},"example":"client001"},{"name":"t","in":"query","required":true,"description":"Subscription token (plain). Из ответа create или subscription-token. Синонимы: token, key","schema":{"type":"string"},"example":"m4_visEGWDKo0U7a71B_noSyiHGPLgj1X5IGnvZJPNQ"},{"name":"format","in":"query","required":false,"description":"base64 — для клиентов; plain — отладка; json — структурированный ответ","schema":{"type":"string","enum":["base64","plain","json"],"default":"base64"}}],"responses":{"200":{"description":"Subscription payload","content":{"text/plain":{"schema":{"type":"string"},"example":"dmxlc3M6Ly9iOGI2YjQyMS01ZTQ0LTQ0MjgtYmU4Ny00YTY2MzIxOTcyYTVAMTg1LjI0Ni4yMjIuMjExOjc0NDM/..."},"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"401":{"description":"invalid token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"forbidden","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"not_found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"rate_limited","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/partner/stats":{"get":{"tags":["partner-billing"],"summary":"Статистика биллинга вашего Partner key","description":"## Назначение\nСколько trial и продлений вы сделали + расчёт revenue по **вашим** ценам.\n\n### Авторизация (Partner API key)\n\nОдин ключ на все запросы. Формат: `hn_` + **64 hex-символа**.\nScope: **только** `can_user_api` (не admin / agent / internal).\n\nПример (партнёр **example**):\n\n```http\nX-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n```\n\nВ Swagger: **Authorize → PartnerApiKey** — вставьте свой `hn_…` ключ.\n\nТот же secret можно передать как `Authorization: Bearer hn_…` (опционально, для curl/кода).\n\nВы видите **только пользователей, созданных вашим ключом** (`created_by_key_id`).\n\n### Пример\n```bash\ncurl -sS -H \"X-API-Key: hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\" https://your-api.example/partner/stats\n```\n\n### Ответ `200`\n```json\n{\n  \"key_id\": \"11111111-1111-4111-8111-111111111111\",\n  \"events\": {\n    \"trial_create\": 120,\n    \"extend_14\": 20,\n    \"extend_30\": 45,\n    \"extend_365\": 10\n  },\n  \"revenue\": {\n    \"trials\": 0,\n    \"extensions_14d\": 1000,\n    \"extensions_30d\": 4500,\n    \"extensions_365d\": 30000,\n    \"total\": 35500\n  }\n}\n```\n\n### Как считается revenue\n```\ntrials          = trial_create × trial_unit_price\nextensions_14d  = extend_14 × extend_30_price × 0.5\nextensions_30d  = extend_30 × extend_30_price\nextensions_365d = extend_365 × extend_365_price\ntotal           = сумма четырёх\n```\n\nЦены (`trial_unit_price`, `extend_30_price`, `extend_365_price`) задаются администратором при создании Partner key.\n\n### Связь с другими endpoint'ами\n| Ваш вызов | Событие в stats |\n|-----------|-----------------|\n| `POST /user/create` | `trial_create` +1 |\n| `POST /user/extend` duration=14 | `extend_14` +1 |\n| `POST /user/extend` duration=30 | `extend_30` +1 |\n| `POST /user/extend` duration=365 | `extend_365` +1 |\n\n### Ограничения\n- Партнёр видит **только свой** key_id\n- Query `?key_id=` доступен только admin (`can_admin`) — партнёрам вернёт `403 admin_only_param`","security":[{"PartnerApiKey":[]}],"parameters":[{"name":"key_id","in":"query","schema":{"type":"string","format":"uuid"},"description":"Admin only"}],"responses":{"200":{"description":"Агрегированная статистика: events (счётчики) + revenue (суммы по ценам ключа)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BillingStats"},"example":{"key_id":"11111111-1111-4111-8111-111111111111","events":{"trial_create":120,"extend_14":20,"extend_30":45,"extend_365":10},"revenue":{"trials":0,"extensions_14d":1000,"extensions_30d":4500,"extensions_365d":30000,"total":35500}}}}},"403":{"description":"admin_only_param","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}},"components":{"securitySchemes":{"PartnerApiKey":{"type":"apiKey","in":"header","name":"X-API-Key","description":"**Единственный Partner API key** для всех запросов (scope `can_user_api`).\n\nФормат: `hn_` + 64 hex-символов.\n\nПример (партнёр example):\nhn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a\n\nАльтернатива в коде (не отдельное поле в Swagger):\nAuthorization: Bearer hn_a7c4e8910d3f62b558e79c1a4f6d0b3e9a2c7f5481b6d0e3a9f72c5b8d1e4f6a"}},"schemas":{"HealthOk":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"redis_ok":{"type":["boolean","null"]}}},"Error":{"type":"object","properties":{"error":{"type":"string","description":"Machine-readable code"},"detail":{"type":"string"},"allowed":{"type":"array","items":{"type":"integer"}}}},"HealthFail":{"type":"object","properties":{"ok":{"type":"boolean","example":false}}},"UserCreateBody":{"type":"object","required":["id"],"properties":{"id":{"$ref":"#/components/schemas/UserId"},"username":{"$ref":"#/components/schemas/UserId"}}},"UserCreateResponse":{"type":"object","description":"VPN payload + public_subscription + expires_at unix","additionalProperties":true,"properties":{"id":{"type":"string"},"domain":{"type":"string"},"expires_at":{"type":"integer"},"public_subscription":{"$ref":"#/components/schemas/PublicSubscriptionBlock"}}},"UserExtendBody":{"type":"object","required":["duration"],"properties":{"id":{"$ref":"#/components/schemas/UserId"},"username":{"$ref":"#/components/schemas/UserId"},"duration":{"type":"integer","enum":[14,30,365]},"duration_days":{"type":"integer","enum":[14,30,365]}}},"UserExtendResponse":{"type":"object","properties":{"username":{"type":"string"},"domain":{"type":"string"},"expires_at":{"type":"integer"},"status":{"type":"string","enum":["active","expired"]}}},"UserId":{"type":"string","pattern":"^[a-z0-9]+$","maxLength":64,"example":"gangbang"},"SubscriptionTokenResponse":{"type":"object","properties":{"token":{"type":"string"},"path":{"type":"string"},"url":{"type":["string","null"]},"karing_url":{"type":["string","null"]},"clash_url":{"type":["string","null"]}}},"UserAgentSessionsResponse":{"type":"object","properties":{"user_id":{"type":"string"},"uuid":{"type":["string","null"],"format":"uuid"},"items":{"type":"array","items":{"$ref":"#/components/schemas/UserAgentSessionItem"}},"total":{"type":"integer"},"total_count":{"type":"integer","description":"All rows for user, regardless of filter"},"limit":{"type":"integer"},"offset":{"type":"integer"},"status_filter":{"type":"string","enum":["all","live","active","blocked","expired"]},"active_count":{"type":"integer"},"blocked_count":{"type":"integer"},"expired_count":{"type":"integer"},"latest_seen_at":{"type":["string","null"],"format":"date-time"},"max_devices":{"type":"integer","example":5},"remaining_slots":{"type":"integer"},"enforcement_mode":{"type":"string","enum":["observe","enforce_new_only"]},"source":{"type":"string","enum":["agent"]}}},"UserIpSegmentsResponse":{"type":"object","properties":{"user_id":{"type":"string"},"uuid":{"type":["string","null"],"format":"uuid"},"limit_unit":{"type":"string","enum":["ip","device"]},"max_devices":{"type":"integer","example":5},"stable_segment_count":{"type":"integer"},"pending_segment_count":{"type":"integer","description":"Segments seen in window but not yet stable"},"active_connection_count":{"type":"integer","description":"Raw active agent_device_sessions rows in TTL window"},"remaining_slots":{"type":"integer"},"enforcement_mode":{"type":"string","enum":["observe","enforce_new_only"]},"segment_min_hits":{"type":"integer","example":4},"segment_min_age_sec":{"type":"integer","example":120},"session_ttl_sec":{"type":"integer","example":180},"stable_only":{"type":"boolean"},"items":{"type":"array","items":{"$ref":"#/components/schemas/UserIpSegmentItem"}},"source":{"type":"string","enum":["agent"]}}},"BillingStats":{"type":"object","properties":{"key_id":{"type":"string","format":"uuid"},"events":{"type":"object","properties":{"trial_create":{"type":"integer"},"extend_14":{"type":"integer"},"extend_30":{"type":"integer"},"extend_365":{"type":"integer"}}},"revenue":{"type":"object","properties":{"trials":{"type":"number"},"extensions_14d":{"type":"number"},"extensions_30d":{"type":"number"},"extensions_365d":{"type":"number"},"total":{"type":"number"}}}}},"PublicSubscriptionBlock":{"type":"object","properties":{"path":{"type":"string","example":"/gangbang/sub"},"url":{"type":["string","null"]},"token":{"type":"string"},"karing_url":{"type":["string","null"]},"clash_url":{"type":["string","null"]},"note":{"type":"string"},"configured":{"type":"boolean"}}},"UserAgentSessionItem":{"type":"object","description":"VPN device session from agent_device_sessions","properties":{"user_id":{"type":"string"},"user_uuid":{"type":"string","format":"uuid"},"node_id":{"type":"string"},"status":{"type":"string","enum":["active","blocked","expired"]},"first_seen_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time"},"connected_at":{"type":["string","null"],"format":"date-time"},"blocked_until":{"type":["string","null"],"format":"date-time"},"block_reason":{"type":["string","null"],"example":"max_devices"},"device_key_hash":{"type":"string","description":"Hashed stable device key (sha256 + pepper)"},"metadata":{"type":"object","additionalProperties":true}}},"UserIpSegmentItem":{"type":"object","description":"Masked client IP segment (/24 or /64) aggregated from active agent sessions","properties":{"segment":{"type":"string","example":"94.180.32.0/24"},"stable":{"type":"boolean","description":"Counts toward limit when limit_unit=ip"},"hits":{"type":"integer","description":"Active connection rows in session TTL window"},"age_sec":{"type":"integer","description":"Seconds between first and last seen in window"},"first_seen_at":{"type":"string","format":"date-time"},"last_seen_at":{"type":"string","format":"date-time"},"active_connections":{"type":"integer"},"src_ips":{"type":"array","items":{"type":"string"},"description":"Distinct raw src_ip values mapped to this segment"},"needs_hits":{"type":"integer","description":"Additional hits required before segment becomes stable"},"needs_age_sec":{"type":"integer","description":"Additional seconds required before segment becomes stable"}}}}}}