Wiki
REST API
uVersion 서버 HTTP 엔드포인트 레퍼런스: 인증, 리포지토리, 파일, 잠금, 댓글, 관심 목록, 프로덕션 보드, 빌드, 관리.
uVersion 서버는 JSON HTTP API를 노출합니다. 호출은
JWT(JSON Web Token), 즉 로그인 시 서버가 건네주고 그 뒤 각 요청에서
되돌려 보내는 세션 토큰으로 인증됩니다. 이 페이지는
데스크톱 클라이언트, 에디터 플러그인, 그리고 uversion CLI가 사용하는 엔드포인트를 문서화합니다.
이를 직접 호출하여 uVersion을 자체 내부 도구(자체 대시보드, 감사 스크립트, 웹훅 등)에
통합할 수 있습니다.
이 페이지는 API 전체를 다루지 않습니다. 여기서 설명하지 않은 여러 라우트 계열이 존재합니다. 목록은 다루지 않은 영역 섹션에 있습니다.
규약
기본 URL
문서화된 모든 경로는 당신 인스턴스의 URL에 상대적입니다. 예제는
https://uversion.mygamestudio.com을 사용합니다. 당신의 것으로 바꾸세요.
필수 헤더
| 헤더 | 값 |
|---|---|
Authorization | Bearer <jwt>. /api/auth/*와 /api/server-info(그리고 /health)를 제외한 모든 /api/* 라우트에서 |
Content-Type | JSON 본문을 가진 POST/PUT에는 application/json. 바이너리 업로드(빌드 파일)에는 application/octet-stream. |
Accept | application/json 권장(서버는 기본적으로 JSON을 반환합니다) |
응답 형식은 두 가지이며, 어느 것을 읽는지 알아야 합니다
서버의 응답 형식은 하나가 아니라 둘이며, 이를 혼동하는 것은 통합을 시작하는 사람에게 가장 값비싼 실수입니다.
1. 인증된 라우트(/api/auth/*를 제외한 모든 /api/*)는
봉투로 응답합니다:
{
"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 하나뿐입니다:
사용자 이름당 15분마다 5회의 실패한 시도. 성공한 로그인은
집계되지 않습니다.
토큰 취소
각 세션 토큰은 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: 이 사용자 이름에 대해 15분 창 내에서 5회의 실패한 시도에 도달
서버는 계정이 존재하지 않을 때 더미 해시에 대해서도, 항상 비밀번호의 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 역할은 모든 리포지토리를 봅니다.
쿼리 파라미터: 단 하나, 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
역할과 그것뿐입니다. 이것은 기능(capability)이 아닙니다: 검사는 역할에 직접 이루어지므로,
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 GB로 제한됩니다(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에서도.
필드는 반드시 존재해야 합니다: 그것을 생략하면 요청 전체의 역직렬화가 실패합니다.
삭제에는, 빈 배열을 보내세요.
배열에는 문자열이 아니라 객체가 들어 있습니다: /upload-chunks가
반환한 {hash, offset, size, compressed_size} 항목을 수정하지 말고 재사용하세요.
각 hash는 64자의 16진 다이제스트여야 하며, 그렇지 않으면 커밋 전체가
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개의 16진 문자),chunks필드 누락403: 경로 중 하나에 쓰기 권한 없음409: 수정된 파일에 다른 사람이 보유한 잠금, 또는 같은 파일에 대한 동시 커밋
GET /api/files/{repo_id}/snapshot
특정 커밋 시점의 리포지토리 상태를 반환합니다: 존재하는 파일의 목록을, 그 리비전 번호와 크기와 함께. 데스크톱 클라이언트가 클론과 강제 동기화를 위해 사용합니다.
쿼리 파라미터:
-
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가 사용합니다.
쿼리 파라미터:
path: 파일 경로(repo 루트에 상대적)revision(선택): 리비전 번호. 기본: 최신.
Response 200: 파일의 바이너리 내용.
GET /api/files/{repo_id}/history
리포지토리의 커밋 이력, 최신에서 가장 오래된 것으로.
쿼리 파라미터:
limit: 커밋의 수, 기본 50offset: 기본 0path(선택): 이 파일을 건드린 커밋만 유지합니다
읽히는 파라미터는 이 셋뿐입니다. 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의
재확인도 없습니다. 이력 전체를 훑으려면, limit보다 적은 요소를 받을 때까지
offset을 증가시키세요.
각 커밋은 건드린 파일의 목록을 직접 지닙니다. 리비전이 삭제인 항목은 그렇게 표시되며, 다운로드할 내용이 없습니다.
증분 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
리포지토리의 모든 잠금을, 파일 이름과 그것을 보유한 사람의 이름과 함께 나열합니다.
쿼리 파라미터: 없음. 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 }를 반환합니다.
프로덕션 보드(작업)
옛 modification-requests API는 제거되었습니다: 프로덕션 보드가 그것을 흡수합니다
(요청이 카드가 되며, 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을 받은 내용에 대해 검증합니다. 해시가 이미
서버 측에 존재하면, 다시 복사하지 않고 즉시 200을 반환합니다(빌드 스토리지 측 중복 제거).
헤더: Content-Type: application/octet-stream.
Body: 원시 바이너리(JSON 래핑 없음).
제한: 파일당 8 GB.
오류:
400: hash 쿼리 파라미터 누락 또는 잘못됨, 또는 계산된 SHA-256 != 제공된 hash413: 파일 > 8 GB
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
기능은 서류상으로만 존재합니다. 설치 시 데이터베이스에 분명히
생성되지만, 어떤 코드 줄도 그것을 조회하지 않습니다. 역할에 부여해도 전혀 달라지지 않습니다.
manage_users를 받았다는 이유로 admin이 아닌 계정이 사용자를
관리할 수 있다고 가정하는 통합을 작성하지 마세요: 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를 반환합니다.
로드 밸런서나 모니터링의 상태 확인에 사용됩니다(Prometheus blackbox, Datadog synthetic 등).
Response 200: OK(text/plain).
Response 503: 데이터베이스에 도달할 수 없는 경우.
GET /api/server-info
서버 메타데이터(버전 등)를 반환하는 공개, 인증되지 않은 엔드포인트.
/health와 마찬가지로, 인증 미들웨어로 보호되지 않습니다.
오류 형식
오류의 경우, 인증된 라우트는 { "success": false, "error": "..." }를,
/api/auth/* 라우트는 { "error": "..." }를 반환하며, 두 경우 모두
적절한 HTTP 코드를 동반합니다.
error 필드는 코드가 아니라 인간을 향한 메시지입니다.
안정적인 오류 코드의 카탈로그는 본문에도 헤더에도 존재하지 않습니다. 따라서 그 내용 위에
로직을 구축하지 말고, 그 접두사를 분석하지 마세요: 이 문장들은 버전마다 재표현되며,
조용히 깨지는 문자열 테스트는 아예 테스트가 없는 것보다 나쁩니다.
동작을 분기할 근거로 삼을 수 있는 것은 HTTP 코드뿐입니다. 아래 표가 그 읽는 법을 줍니다.
| HTTP | 의미 | 언제 |
|---|---|---|
| 400 | Bad request | 무효한 파라미터, 잘못된 JSON, 본문이 너무 큼(하드한 413 제한 이전) |
| 401 | Not authenticated | 토큰 부재, 만료, 무효한 서명, 또는 token_version 불일치 |
| 403 | Permission denied | 기능 부재, 또는 path / repo에 대한 권한 없음 |
| 404 | Resource not found | repo / 파일 / commit / user가 없거나 접근 불가 |
| 409 | Conflict | 잠금이 이미 보유됨, 동시 commit, unique 제약 위반 |
| 413 | Payload too large | 본문이 제한을 초과(/files에서 1 GB, /builds/upload에서 8 GB) |
| 429 | Too many requests | /auth/login에서만(사용자별 속도 제한) |
| 500 | Internal server error | DB 오류, IO 오류. 서버 측에 로깅됨, 버그를 보고하면 timestamp를 첨부하세요. |
| 503 | Service unavailable | 데이터베이스 도달 불가(health check) 또는 마이그레이션 진행 중 |