uVersion
Русский
Скачать →

Wiki

REST API

Справочник HTTP-эндпоинтов сервера uVersion: аутентификация, репозитории, файлы, блокировки, комментарии, список наблюдения, доска производства, сборки, администрирование.

Сервер uVersion предоставляет JSON HTTP API. Вызовы аутентифицируются JWT (JSON Web Token), то есть сессионным токеном, который сервер выдаёт вам при входе и который вы затем возвращаете при каждом запросе. Эта страница документирует эндпоинты, используемые десктопным клиентом, плагинами редактора и CLI uversion. Вы можете вызывать их напрямую, чтобы интегрировать uVersion в собственный внутренний инструментарий (самодельная панель, скрипты аудита, вебхуки и т. д.).

Она не охватывает весь API: существует несколько семейств маршрутов, которые здесь не описаны. Список находится в разделе Незатронутые области.

Соглашения

Базовый URL

Каждый задокументированный путь задаётся относительно URL вашего экземпляра. В примерах используется https://uversion.mygamestudio.com. Замените его на свой.

Обязательные заголовки

ЗаголовокЗначение
AuthorizationBearer <jwt> на каждом маршруте /api/*, кроме /api/auth/* и /api/server-info/health)
Content-Typeapplication/json для POST/PUT с телом JSON. application/octet-stream для бинарных загрузок (файлы сборок).
Acceptрекомендуется application/json (сервер по умолчанию возвращает JSON)

Два формата ответа, и нужно знать, какой вы читаете

У сервера не один формат ответа, а два, и путать их оказывается самой дорогой ошибкой для того, кто начинает интеграцию.

1. Аутентифицированные маршруты (все /api/*, кроме /api/auth/*) отвечают конвертом:

{
  "success": true,
  "data": { /* payload */ }
}

В случае ошибки те же маршруты отвечают:

{
  "success": false,
  "error": "No write permission on this repository"
}

Поле success присутствует всегда. data присутствует при успехе, error при неудаче, никогда оба вместе.

2. Маршруты аутентификации (/api/auth/login, /refresh, /validate, /logout, /register, /change-password) не используют этот конверт. Они возвращают голый объект, без success и data:

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "user": { "id": 12, "username": "alice", ... },
  "must_change_password": false
}

А их ошибки представляют собой объект с единственным полем, без success:

{ "error": "Invalid credentials" }

Практическое следствие: на /api/auth/login токен читается из .token, а не из .data.token. Скрипт, запрашивающий .data.token, получает null без какой-либо видимой ошибки.

Наконец, GET /api/files/{repo_id}/content не возвращает ни того, ни другого: это сырое двоичное содержимое файла, как есть.

Постраничная разбивка

Общего соглашения о постраничной разбивке нет: у каждого маршрута своё, либо его нет вовсе. Не предполагайте ни limit, ни total, ни has_more.

МаршрутПринимаемые параметрыФорма ответа
GET /api/files/{repo_id}/history limit (по умолчанию 50), offset (по умолчанию 0), path Плоский массив коммитов. Нет total, нет has_more: вы достигли конца, когда массив содержит меньше элементов, чем limit.
GET /api/repositories только include_inactive (и он учитывается лишь для суперадминистратора) Плоский массив. Ни limit, ни offset не читаются.
GET /api/locks/{repo_id}/status Нет Плоский массив всех блокировок репозитория.
GET /api/files/{repo_id}/snapshot commit_hash, обязательно Плоский массив файлов.
GET /api/admin/audit page и per_page, не limit/offset, плюс фильтры (user_id, action, entity_type, from, to) Только для суперадминистратора. Хорошо иллюстрирует отсутствие общего соглашения: это единственный маршрут, который разбивает по номеру страницы.

Для любого не перечисленного здесь маршрута считайте, что он возвращает весь свой результат за один вызов.

Ограничение частоты

Общего ограничения частоты нет: проект Unreal насчитывает тысячи файлов, и пакетные операции (получение, снятие блокировок) постоянно срабатывали бы на троттлинг. Поэтому вы можете вызывать API в большом объёме.

Ограничен лишь один маршрут, /api/auth/login: 5 неудачных попыток на имя пользователя каждые 15 минут. Успешные входы не учитываются.

Отзыв токенов

Каждый сессионный токен несёт поле tv (token version), которое отражает столбец users.token_version в базе. Сервер сравнивает оба при каждом запросе. Вызов POST /api/auth/logout, сброс пароля администратором или деактивация учётной записи увеличивают это значение, что делает мгновенно недействительными все существующие токены этого пользователя, на всех его машинах.

Curl

Чтобы вызывать API из терминала. Обратите внимание на .token: ответ /api/auth/login является голым объектом, нет никакого .data, через который нужно пройти.

TOKEN=$(curl -s -X POST https://uversion.mygamestudio.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret"}' | jq -r '.token')

# А вот аутентифицированные маршруты как раз используют конверт:
curl -s -H "Authorization: Bearer $TOKEN" \
  https://uversion.mygamestudio.com/api/repositories | jq '.data'

Аутентификация

Напоминание: ни один маршрут в этом разделе не использует конверт {"success", "data"}. Они возвращают голый объект, и их ошибки имеют вид {"error": "..."}.

POST /api/auth/login

Аутентифицирует пользователя и возвращает сессионный токен, действительный 30 дней.

Body:

{
  "username": "alice",
  "password": "secret"
}

Response 200:

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "user": {
    "id": 12,
    "username": "alice",
    "email": "alice@mygamestudio.com",
    "role": "lead"
  },
  "must_change_password": false
}

Нет поля expires_at: срок действия читается из самого токена или выводится из конфигурации сервера (по умолчанию 30 дней).

Не игнорируйте must_change_password. Это поле равно true, когда учётная запись всё ещё работает на временном пароле: том, что установщик сгенерировал для начальной учётной записи администратора, или том, что администратор только что задал при сбросе. Клиент, который его не смотрит, оставляет пользователя на этом временном пароле бессрочно. Ожидаемое поведение состоит в том, чтобы немедленно перенаправить на POST /api/auth/change-password до любого другого действия.

Ошибки:

  • 401: неверные учётные данные или деактивированная учётная запись
  • 429: достигнуто 5 неудачных попыток для этого имени пользователя в окне 15 минут

Сервер всегда выполняет проверку пароля Argon2id, в том числе против фиктивного хеша, когда учётной записи не существует. Поэтому ответ занимает одинаковое время в обоих случаях, что не даёт угадать существование учётной записи, замеряя время запросов.

POST /api/auth/refresh

Обновляет сессионный токен без повторного ввода пароля.

Заголовки: Authorization: Bearer <текущий_токен>. Без тела.

Response 200: точно та же форма, что у /login (token, user, must_change_password), с новым токеном.

Ошибки:

  • 401: недействительный или просроченный токен, деактивированная учётная запись, или устаревший token_version

POST /api/auth/logout

Делает недействительными все токены текущего пользователя, на всех его машинах, увеличивая users.token_version. Ему придётся войти заново повсюду: десктопный клиент, плагин редактора и CLI включительно.

Response 200: { "logged_out": true }.

POST /api/auth/validate

Проверяет, что токен ещё действителен, и возвращает пользователя, которому он соответствует. Проверка касается также is_active и token_version, поэтому отозванный токен отклоняется, даже если он ещё не истёк.

Внимание к телу: это не объект, это голая строка JSON, то есть токен, окружённый двойными кавычками.

curl -X POST https://uversion.mygamestudio.com/api/auth/validate \
  -H "Content-Type: application/json" \
  -d '"eyJhbGciOiJIUzI1NiIs..."'

Response 200: голый объект пользователя.

{
  "id": 12,
  "username": "alice",
  "email": "alice@mygamestudio.com",
  "role": "lead"
}

Нет ни valid, ни expires_at, ни refreshed_token: действительность читается из HTTP-кода (200 или 401), а обновление идёт через /api/auth/refresh, никогда через этот маршрут.

POST /api/auth/register

Этот маршрут по умолчанию отказывает. Открытая регистрация отключена, если только оператор явно не включил её в конфигурации сервера; иначе ответ будет 403 с {"error": "Open registration is disabled; contact your administrator"}.

В обычной работе учётные записи создаются через администрирование: POST /api/admin/users, или вкладка «Пользователи» панели администрирования. Не стройте интеграцию, которая зависит от /register.

POST /api/auth/change-password

Меняет пароль текущего пользователя. Увеличивает token_version, что делает недействительными все прежние токены, включая тот, что только что послужил для вызова.

Репозитории

GET /api/repositories

Перечисляет репозитории, доступные текущему пользователю, отфильтрованные по таблице прав. Пользователь, у которого нет ни одного правила прав на репозиторий, его не видит. Роли admin и lead видят все репозитории.

Query-параметры: один-единственный, include_inactive (булев, по умолчанию false), и он учитывается лишь для суперадминистратора. Нет ни limit, ни offset: маршрут возвращает весь список.

Response 200:

{
  "success": true,
  "data": [
    {
      "id": 1,
      "name": "hero-rpg",
      "description": "Main RPG project",
      "storage_path": "/var/lib/uversion/data/hero-rpg",
      "is_active": true,
      "created_at": "2026-01-15T10:00:00Z",
      "updated_at": "2026-05-15T08:30:00Z"
    },
    {
      "id": 2,
      "name": "shared-assets",
      "description": "Shared asset library",
      "storage_path": "/var/lib/uversion/data/shared-assets",
      "is_active": true,
      "created_at": "2026-02-01T09:00:00Z",
      "updated_at": "2026-05-02T11:12:00Z"
    }
  ]
}

Это единственные возвращаемые поля. В частности, нет ни owner, ни current_revision, ни file_count, ни size_bytes, ни last_commit_at: у репозитория нет владельца в смысле API, а объёмные данные получаются через маршруты статистики администрирования.

GET /api/repositories/{repo_id}

Детали репозитория. Та же форма объекта, что в списке, с теми же полями.

Ошибки:

  • 403: нет доступа к этому репозиторию
  • 404: репозиторий не существует

POST /api/repositories

Создаёт репозиторий. Только для суперадминистратора, то есть для роли admin и только для неё. Это не возможность: проверка касается напрямую роли, поэтому project_admin или lead получает 403. В продукте нет никакой возможности create_repos.

Body:

{
  "name": "new-project",
  "description": "Необязательное описание"
}

Проверка:

  • name: от 1 до 255 символов. Сервер не накладывает никакого ограничения на набор символов. Путь хранения на диске выводится из имени путём его нормализации.
  • description: необязательно.

Уникальность касается только имени, в масштабе сервера. Нет понятия владельца, а значит, и уникальности «по владельцу».

Ошибки:

  • 400: пустое имя или свыше 255 символов
  • 403: у вызывающего нет роли admin
  • 409: репозиторий уже носит это имя

Файлы

POST /api/files/{repo_id}/upload-chunks

Отправляет целый файл, а не список кусков. Имя маршрута вводит в заблуждение: файл на блоки (chunks) режет сервер, а не вы. Вызывающему никогда не нужно делать это разбиение самому.

Идентичные блоки, распознаваемые по их хешу SHA-256, дедуплицируются: блок, уже присутствующий на сервере, не сохраняется второй раз, из какого бы файла или репозитория он ни пришёл. Именно это делает так, что большой двоичный файл, изменённый по краю, почти ничего не стоит в дисковом пространстве.

Body: объект с единственным полем, содержащий полный файл в кодировке base64.

{
  "data": "<весь файл в кодировке base64>"
}

Любая другая форма, в частности массив chunks, отклоняется сервером.

Response 200:

{
  "success": true,
  "data": {
    "chunks": [
      { "hash": "abc123...", "offset": 0,       "size": 1048576, "compressed_size": 423152 },
      { "hash": "def456...", "offset": 1048576, "size": 2097152, "compressed_size": 891204 }
    ],
    "chunks_stored": 1,
    "chunks_deduplicated": 1
  }
}

Массив chunks следует переиспользовать как есть, включая полные объекты, в следующем за ним POST /commit. chunks_stored считает блоки, реально записанные на диск, а chunks_deduplicated считает те, что уже существовали.

Право: требуется запись в репозиторий, иначе 403.

Ограничения: тело запроса ограничено 1 ГБ (настраивается через security.max_body_size_files). Файл больше этого предела не может пройти по этому маршруту.

POST /api/files/{repo_id}/commit

Создаёт атомарный коммит из списка файлов и их блоков. Либо проходят все файлы, либо ни один.

Единственные три принимаемых значения для action: add, modify и delete

Не added, не modified, не deleted.

Это не деталь формы. Сервер буквально проверяет action == "delete" и обрабатывает всё остальное как добавление или изменение. Отправить "deleted", стало быть, не удаляет ровным счётом ничего: файл уходит в ветку записи, с пустым массивом chunks, и сервер регистрирует ревизию с пустым содержимым. Никакой ошибки не возбуждается. Файл остаётся на месте, его последняя версия перезаписывается пустотой, и потеря становится видна лишь при следующем sync кого-то другого.

Body:

{
  "message": "Updated main level + hero pose pass",
  "commit_hash": "необязательно: чтобы объединить несколько партий под одним коммитом",
  "files": [
    {
      "path": "Content/Maps/MainLevel.umap",
      "action": "modify",
      "chunks": [
        { "hash": "abc123...", "offset": 0,       "size": 1048576, "compressed_size": 423152 },
        { "hash": "def456...", "offset": 1048576, "size": 2097152, "compressed_size": 891204 }
      ]
    },
    {
      "path": "Content/Characters/NewVillain.uasset",
      "action": "add",
      "chunks": [
        { "hash": "fed789...", "offset": 0, "size": 524288, "compressed_size": 201004 }
      ]
    },
    {
      "path": "Content/OldAsset.uasset",
      "action": "delete",
      "chunks": []
    }
  ]
}

chunks не является необязательным, в том числе на delete. Поле должно присутствовать: его пропуск проваливает десериализацию всего запроса. Для удаления отправьте пустой массив.

Массив содержит объекты, а не строки: переиспользуйте без изменений записи {hash, offset, size, compressed_size}, возвращённые /upload-chunks. Каждый hash должен быть шестнадцатеричным дайджестом из 64 символов, иначе весь коммит отклоняется с 400.

Поле commit_hash на корневом уровне необязательно. Оно служит, чтобы несколько последовательных вызовов несли один и тот же коммит, что десктопный клиент делает, когда режет большую загрузку на партии. При пропуске сервер вычисляет его сам.

Response 200:

{
  "success": true,
  "data": {
    "commit_hash": "7f3a9b1c2d3e4f...",
    "files_committed": 3,
    "revisions": [
      { "path": "Content/Maps/MainLevel.umap",             "revision_number": 12 },
      { "path": "Content/Characters/NewVillain.uasset",    "revision_number": 1  },
      { "path": "Content/OldAsset.uasset",                 "revision_number": 8  }
    ]
  }
}

Это единственные возвращаемые поля. Нет ни files_changed, ни bytes_uploaded, ни bytes_deduped, ни revision. Заметьте, что revision_number является счётчиком по файлу, а не номером версии репозитория: коммит идентифицирует commit_hash, и именно его нужно хранить, чтобы ссылаться на состояние.

Ошибки:

  • 400: некорректное тело, недействительный дайджест блока (ожидается: 64 шестнадцатеричных символа), отсутствующее поле chunks
  • 403: нет права записи на один из путей
  • 409: блокировка, удерживаемая кем-то другим, на изменённом файле, или конкурентный коммит на том же файле

GET /api/files/{repo_id}/snapshot

Возвращает состояние репозитория, каким оно было в момент данного коммита: список присутствующих файлов, с их номером ревизии и размером. Используется десктопным клиентом для клонирования и для принудительной синхронизации.

Query-параметры:

  • commit_hash: обязательно. Это хеш коммита, служащий точкой отсчёта. Без этого параметра запрос отклоняется, и не существует значения по умолчанию «последнее состояние».

Нет параметра revision. Снимок запрашивается по хешу коммита, никогда по номеру. Неизвестный коммит отвечает 404.

Response 200: плоский массив, без обёртывающего объекта.

{
  "success": true,
  "data": [
    {
      "path": "Content/Maps/MainLevel.umap",
      "revision_number": 12,
      "file_size": 84934656
    },
    {
      "path": "Content/Characters/Hero.uasset",
      "revision_number": 3,
      "file_size": 5242880
    }
  ]
}

Ответ не содержит списков блоков. Чтобы получить содержимое, пройдите через GET /api/files/{repo_id}/content, который пересобирает файл на стороне сервера.

GET /api/files/{repo_id}/content

Скачивает содержимое файла на данной ревизии (сервер пересобирает чанки). Используется клоном и sync.

Query-параметры:

  • path: путь файла (относительно корня repo)
  • revision (необязательно): номер ревизии. По умолчанию: последняя.

Response 200: двоичное содержимое файла.

GET /api/files/{repo_id}/history

История коммитов репозитория, от самого недавнего к самому старому.

Query-параметры:

  • limit: число коммитов, по умолчанию 50
  • offset: по умолчанию 0
  • path (необязательно): оставляет только коммиты, затронувшие этот файл

Это единственные три читаемых параметра. Нет ни author, ни since: неизвестный параметр молча игнорируется, что даёт правдоподобный, но неотфильтрованный ответ. Фильтруйте по автору или по дате на стороне вызывающего.

Response 200: плоский массив коммитов.

{
  "success": true,
  "data": [
    {
      "commit_hash": "7f3a9b1c...",
      "message": "Fixed lighting in main level",
      "author": "alice",
      "created_at": "2026-05-15T08:30:00Z",
      "files": [
        {
          "path": "Content/Maps/MainLevel.umap",
          "revision_number": 12,
          "file_size": 84934656
        }
      ]
    }
  ]
}

Нет ни total, ни has_more, ни повтора limit и offset. Чтобы обойти всю историю, увеличивайте offset, пока не получите меньше элементов, чем limit.

Каждый коммит несёт напрямую список затронутых файлов. Запись, ревизия которой является удалением, помечается как таковая и не имеет содержимого для скачивания.

Инкрементальный sync

Эндпоинты, используемые десктопным клиентом для эффективной синхронизации:

  • GET /api/files/{repo_id}/sync: файлы, изменённые с некоторой ревизии, для дельта-sync.
  • GET /api/files/{repo_id}/deletions: файлы, удалённые на стороне сервера, чтобы распространить удаления локально.
  • GET /api/files/{repo_id}/list: список файлов repo.
  • POST /api/files/{repo_id}/checkout: получает блокировки и готовит редактирование.

Блокировки

Блокировки никогда не истекают

Блокировка держится, пока не будет явно снята: через checkin, через revert, или через принудительное снятие администратором. Никакого автоматического истечения нет, ни через час, ни через месяц.

Поле expires_at существует единственно потому, что соответствующий столбец в базе не принимает пустого значения. Сервер пишет туда сторожевое значение в сто лет: блокировка, взятая сегодня, показывает срок где-то около 2126 года. Ничего не стройте на этом поле и не показывайте эту дату пользователю.

Стало быть, heartbeat ничего не продлевает. Это сигнал наблюдения, единственная цель которого: показывать администраторам, какие блокировки ещё активно используются.

POST /api/locks/{repo_id}/acquire

Получает блокировки на список путей. Получения независимы: список acquired содержит успехи, список failed содержит неудачи с их причиной.

Body:

{
  "paths": [
    "Content/Maps/MainLevel.umap",
    "Content/Characters/Hero.uasset"
  ]
}

Response 200:

{
  "success": true,
  "data": {
    "acquired": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "file_id": 12345,
        "file_path": "Content/Maps/MainLevel.umap",
        "user_id": 12,
        "username": "alice",
        "acquired_at": "2026-05-15T14:30:00Z",
        "expires_at": "2126-05-15T14:30:00Z"
      }
    ],
    "failed": [
      {
        "path": "Content/Characters/Hero.uasset",
        "reason": "File is locked",
        "locked_by": "bob"
      }
    ]
  }
}

Срок 2126 года в этом примере не является опечаткой: это сторожевое значение, описанное выше. Блокировка постоянна.

Причины неудачи. Поле reason представляет собой английскую фразу, предназначенную для отображения, а не стабильный код. Не пишите логику, которая сравнивает эту строку, и не «парсите» её префикс: она может быть переформулирована от версии к версии. Значения, производимые в настоящее время:

reasonЗначениеlocked_by
File is lockedКто-то другой уже держит блокировкуИмя человека
No write permissionНет права записи на этом путиnull
Failed to create lockУстановка блокировки провалилась в базеnull
Database errorОшибка базы данных на этом путиnull

Недействительный путь (абсолютный, содержащий .., пустой, свыше 4096 символов или несущий нулевой байт) не производит записи в failed: он проваливает весь запрос.

POST /api/locks/{repo_id}/release

Снимает блокировки, которые вы держите. Body:

{
  "paths": ["Content/Maps/MainLevel.umap"]
}

На этом маршруте нет поля force. Добавлять его бесполезно: сервер игнорирует поля, которых не знает, запрос удаётся, и чужая блокировка остаётся на месте. Интегратор, рассчитывающий на это, полагает, что освободил файл, а на деле нет.

Чтобы снять чужую блокировку, единственным маршрутом является POST /api/admin/locks/{lock_id}/force-release. Он зарезервирован за администрированием соответствующего репозитория, и операция вносится в журнал аудита. Он принимает идентификатор блокировки, который вы получаете через GET /api/locks/{repo_id}/status.

POST /api/locks/{repo_id}/heartbeat

Обновляет метку времени активности ваших блокировок. Это ничего не продлевает, поскольку ничто не истекает: это сигнал наблюдения, позволяющий администратору отличить ещё используемую блокировку от забытой.

Body: массив JSON путей, напрямую, без обёртывающего объекта.

["Content/Maps/MainLevel.umap", "Content/Characters/Hero.uasset"]

Недействительные пути игнорируются по отдельности, вместо того чтобы провалить всю партию, чтобы устаревший остаток отслеживания не блокировал остальные.

GET /api/locks/{repo_id}/status

Перечисляет все блокировки репозитория, с именем файла и именем человека, который её держит.

Query-параметры: нет. Нет ни фильтра user, ни limit, ни offset. Маршрут возвращает всю совокупность блокировок репозитория, для фильтрации на стороне вызывающего.

Response 200: плоский массив, без обёртывающего объекта и без total.

{
  "success": true,
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "file_id": 12345,
      "file_path": "Content/Maps/MainLevel.umap",
      "user_id": 12,
      "username": "alice",
      "acquired_at": "2026-05-15T14:30:00Z",
      "expires_at": "2126-05-15T14:30:00Z"
    }
  ]
}

Поле id нужно передать в POST /api/admin/locks/{lock_id}/force-release.

Комментарии & ревью

GET /api/comments/{repo_id}/comments?commit_hash=<hash>

Перечисляет комментарии коммита, в виде тредов (родитель → дети).

Response 200:

{
  "success": true,
  "data": [
    {
      "id": 42,
      "commit_hash": "7f3a9b1c...",
      "file_path": "Content/Maps/MainLevel.umap",
      "author_id": 12,
      "author_username": "alice",
      "body": "LGTM, ship it",
      "parent_id": null,
      "created_at": "2026-05-15T15:00:00Z",
      "replies": [
        {
          "id": 43,
          "author_username": "bob",
          "body": "Thanks!",
          "parent_id": 42,
          "created_at": "2026-05-15T15:05:00Z"
        }
      ]
    }
  ]
}

POST /api/comments/{repo_id}/comments

Создаёт комментарий. file_path необязателен (комментарий к коммиту vs. к файлу). parent_id, чтобы ответить в существующий тред.

Body:

{
  "commit_hash": "7f3a9b1c...",
  "file_path": "Content/Maps/MainLevel.umap",
  "body": "LGTM, ship it",
  "parent_id": null
}

POST /api/comments/{repo_id}/reviews

Отправляет ревью на коммит. Требуется возможность approve_changes.

Body:

{
  "commit_hash": "7f3a9b1c...",
  "status": "approved",
  "comment": "Lighting looks great, approving"
}

Принимаемый status: approved, changes_requested, pending.

Список наблюдения

GET /api/watchlist/{repo_id}/watchlist

Перечисляет паттерны наблюдения текущего пользователя на этом репозитории.

Response 200:

{
  "success": true,
  "data": [
    {
      "id": 1,
      "user_id": 12,
      "pattern": "Content/Characters/Hero/**",
      "notify_on": ["commit", "lock"],
      "created_at": "2026-04-10T10:00:00Z"
    }
  ]
}

POST /api/watchlist/{repo_id}/watchlist

Добавляет паттерн наблюдения. Body:

{
  "pattern": "Content/Characters/Hero/**",
  "notify_on": ["commit", "lock"]
}

Возможные события: commit (коммит изменил совпадающий файл), lock (была получена блокировка), review (было опубликовано ревью).

DELETE /api/watchlist/{repo_id}/watchlist/{watch_id}

Убирает паттерн наблюдения. Возвращает { "success": true }.

Доска производства (tasks)

Старый API modification-requests был убран: доска производства его поглощает (запросы становятся карточками, origin='request'). Эндпоинты вложены под /api/tasks/{repo_id}.

МаршрутОписание
GET /api/tasks/{repo_id}/boardПолная доска: колонки + карточки
POST /api/tasks/{repo_id}/tasksСоздаёт карточку
PUT/DELETE /api/tasks/{repo_id}/tasks/{task_id}Обновляет / удаляет карточку
PUT /api/tasks/{repo_id}/tasks/{task_id}/assigneesНазначает пользователей на карточку
GET /api/tasks/{repo_id}/assignable-usersСписок назначаемых пользователей
POST /api/tasks/{repo_id}/columnsНастраивает колонки доски

Также доступные подмаршруты: комментарии к карточке, ссылки на ассеты и коммиты, и вложения (с обложкой). Настройка колонок требует возможности manage_board (admin / lead).

Сборки

POST /api/builds/{repo_id}/upload?hash=<sha256>

Загружает файл сборки в <storage_path>/builds/<hash>. Сервер сверяет SHA-256, переданный в query-параметре, с полученным содержимым. Если хеш уже существует на стороне сервера, немедленно возвращает 200 без повторного копирования (дедупликация на стороне хранилища сборок).

Заголовки: Content-Type: application/octet-stream.

Body: сырой двоичный (без обёртки JSON).

Ограничения: 8 ГБ на файл.

Ошибки:

  • 400: query-параметр hash отсутствует или некорректен, или вычисленный SHA-256 != предоставленному hash
  • 413: файл > 8 ГБ

POST /api/builds/{repo_id}/publish

Регистрирует manifest сборки после загрузки всех её файлов. Требуется возможность publish_builds.

Body:

{
  "version": "0.1.5-nightly",
  "config": "Development",
  "platform": "Win64",
  "executable_path": "HeroRPG/Binaries/Win64/HeroRPG.exe",
  "release_notes": "Nightly build of main branch, 2026-05-15",
  "files": [
    { "path": "HeroRPG.exe",                "hash": "abc123...", "size_bytes": 12345678 },
    { "path": "HeroRPG/Content/Paks/pak0.pak", "hash": "def456...", "size_bytes": 2147483648 }
  ]
}

GET /api/builds/{repo_id}

Перечисляет опубликованные сборки этого проекта.

Доступ к сборкам даётся проект за проектом. Возможности download_builds недостаточно: будучи общесерверной, она лишь говорит, что учётная запись не является простым зрителем. Сверх того нужен либо доступ к репозиторию, либо явное разрешение на сборки этого проекта, предоставленное его администратором через /api/admin/repositories/{repo_id}/build-access. Администратор проекта по умолчанию имеет доступ к тем, что он администрирует, а суперадминистратор имеет доступ ко всем.

Response 200:

{
  "success": true,
  "data": [
    {
      "id": 5,
      "version": "0.1.5-nightly",
      "config": "Development",
      "platform": "Win64",
      "published_by": "ci-nightly",
      "published_at": "2026-05-15T03:00:00Z",
      "size_bytes": 2159829326,
      "file_count": 142,
      "release_notes": "Nightly build of main branch, 2026-05-15"
    }
  ]
}

GET /api/builds/{repo_id}/{build_id}/file

Скачивает файл сборки. Требуется возможность download_builds. Manifest сборки доступен через GET /api/builds/{repo_id}/{build_id}/manifest.

Администрирование

Что на самом деле защищает эти маршруты

Вопреки тому, чего можно было бы ожидать, маршруты /api/admin/* не защищены каждый одной возможностью. Они проходят через одну из четырёх проверок, которые почти все касаются роли. capability представляет собой именованное право, привязанное к роли; важный момент в том, что оно действует на всём сервере, ибо не несёт никакой ссылки на репозиторий. Поэтому оно никогда не может послужить, чтобы ограничить кого-то одним проектом.

ПроверкаПроходит дляДля чего служит
Суперадминистратор Роль admin, и только она Всё, что действует для всего сервера: учётные записи, группы, журнал аудита, глобальная статистика, лицензия, обновление сервера.
Администратор этого репозитория admin, или project_admin, администрирующий именно этот репозиторий Всё, что присуще одному проекту: права, правила проверки, вебхуки, доступ к сборкам, принудительное снятие блокировки, сборка мусора.
Общесерверная возможность Любая роль, обладающая именованной возможностью Несколько сквозных маршрутов. Внимание: возможность действует на всех репозиториях, такова её природа. project_admin, не обладающий ни одной, допускается вместо этого лишь на репозиториях, которые он администрирует.
Только допуск admin или project_admin Впускает, но ничего не разрешает. Маршрут, который её применяет, должен затем сам ограничить свои результаты репозиториями, администрируемыми вызывающим.

Иначе говоря: возможности manage_users и manage_permissions существуют лишь на бумаге. Они действительно создаются в базе в момент установки, но ни одна строка кода их не запрашивает. Предоставить их роли не меняет ровным счётом ничего. Не пишите интеграцию, предполагающую, что учётная запись не-admin сможет управлять пользователями, потому что ей дали manage_users: она получит 403.

МаршрутРеальная проверкаОписание
GET /api/admin/usersТолько допуск, затем фильтрацияproject_admin видит лишь учётные записи своего периметра
POST /api/admin/usersТолько допускСоздаёт учётную запись
PUT/DELETE /api/admin/users/{id}СуперадминистраторОбновляет или удаляет учётную запись
POST /api/admin/users/{id}/reset-passwordСуперадминистраторСбрасывает пароль и отзывает все токены учётной записи
GET/POST /api/admin/groupsСуперадминистраторГруппы глобальны, их нельзя делегировать по проекту
GET/POST /api/admin/permissions/{repo_id}Администратор этого репозиторияПравила прав по паттерну пути, предоставленные группе или пользователю
GET/POST /api/admin/repositories/{repo_id}/rulesАдминистратор этого репозиторияПравила проверки, применяемые перед отправкой
GET/POST /api/admin/repositories/{repo_id}/webhooksАдминистратор этого репозиторияDiscord, Slack, Teams, или обобщённый вебхук
GET /api/admin/locksТолько допуск, затем фильтрацияБлокировки, ограниченные администрируемыми репозиториями
POST /api/admin/locks/{lock_id}/force-releaseАдминистратор этого репозиторияСнимает чужую блокировку. Вносится в журнал аудита
GET /api/admin/auditСуперадминистраторЖурнал аудита, с фильтрацией и постраничной разбивкой
GET /api/admin/statsСуперадминистраторОбъём хранилища, коэффициент дедупликации, рост
GET /api/admin/licenceСуперадминистраторСостояние лицензии и потреблённые места
GET /api/admin/repositories/{repo_id}/gc/previewАдминистратор этого репозиторияМоделирует сборку мусора, ничего не удаляя
POST /api/admin/repositories/{repo_id}/gcАдминистратор этого репозиторияЗапускает сборку мусора

Полные подробности о ролях, их рангах и о том, что каждый может делать, находятся на странице, посвящённой ролям и правам.

Разделы API, не охваченные этой страницей

Следующие семейства маршрутов существуют, смонтированы и обслуживаются сервером, но здесь не описаны. Если ваша интеграция в них нуждается, самое надёжное сегодня: наблюдать за вызовами, которые делает десктопный клиент, или написать нам.

ПрефиксЧто он охватывает
/api/advisor/*Project Health: аудит проекта Unreal, его находки, оценка по столпам и сортировка элементов для игнорирования.
/api/distribution/*Распространение между проектами: связи между исходным и целевым репозиторием, публикация файлов из одного проекта в другой, история.
/api/binaries/*Предкомпилированные бинарники редактора, привязанные к коммиту, по модели UnrealGameSync.
/api/watchlist/*За пределами паттернов наблюдения, описанных выше: уведомления и входящие.
/api/profile/*Профиль текущего пользователя.
/api/admin/repositories/{repo_id}/adminsКто администрирует репозиторий: назначение и снятие администраторов проекта.
/api/admin/repositories/{repo_id}/build-accessКто может скачивать сборки этого проекта, предоставляется проект за проектом.
/api/admin/licenceСостояние лицензии и потреблённые места.
/api/admin/server/*Обновление сервера из панели администрирования.

Здоровье

GET /health

Неаутентифицированный эндпоинт, возвращающий 200 OK, если сервер может обратиться к базе. Используется для health-check балансировщика нагрузки или мониторинга (Prometheus blackbox, Datadog synthetic и т. д.).

Response 200: OK (text/plain).

Response 503: если база недостижима.

GET /api/server-info

Публичный неаутентифицированный эндпоинт, возвращающий метаданные сервера (версия и т. д.). Как и /health, он не защищён middleware аутентификации.

Формат ошибок

В случае ошибки аутентифицированный маршрут отвечает { "success": false, "error": "..." }, а маршрут /api/auth/* отвечает { "error": "..." }, в обоих случаях с подходящим HTTP-кодом.

Поле error представляет собой сообщение, предназначенное человеку, а не код. Не существует никакого каталога стабильных кодов ошибок, ни в теле, ни в заголовке. Поэтому не стройте логику на его содержимом и не разбирайте его префикс: эти фразы переформулируются от версии к версии, а строковый тест, ломающийся молча, хуже, чем полное отсутствие теста.

HTTP-код является единственным, на чём можно ветвить поведение. Таблица ниже даёт его прочтение.

HTTPЗначениеКогда
400Bad requestНедействительный параметр, некорректный JSON, слишком большое тело (до жёсткого предела 413)
401Not authenticatedТокен отсутствует, просрочен, недействительная подпись, или несовпадение token_version
403Permission deniedОтсутствует возможность, или нет права на path / repo
404Resource not foundRepo / файл / commit / user не существует или недоступен
409ConflictБлокировка уже удерживается, конкурентный commit, нарушено ограничение unique
413Payload too largeТело сверх лимита (1 ГБ на /files, 8 ГБ на /builds/upload)
429Too many requestsТолько на /auth/login (ограничение частоты по пользователю)
500Internal server errorОшибка DB, ошибка IO. Логируется на стороне сервера; приложите timestamp, если сообщаете о баге.
503Service unavailableБаза недостижима (health check) или выполняется миграция