Wiki
REST API
Complete reference of the uVersion server HTTP endpoints: auth, repos, files, locks, comments, watchlist, modification requests, builds, admin.
The uVersion server exposes a JWT-authenticated JSON HTTP API. This page documents every
endpoint used by the desktop client, the editor plugins and the uversion CLI.
You can call them directly to integrate uVersion into your internal tooling
(custom dashboard, audit scripts, webhooks, etc.).
Conventions
Base URL
Every documented path is relative to the URL of your instance. The examples use
https://uversion.mygamestudio.com. Replace it with your own.
Required headers
| Header | Value |
|---|---|
Authorization | Bearer <jwt> on every /api/* route except /api/auth/* and /api/server-info (and /health) |
Content-Type | application/json for POST/PUT with a JSON body. application/octet-stream for binary uploads (build files). |
Accept | application/json recommended (the server returns JSON by default) |
Uniform response format
Every route responds with the following envelope:
{
"success": true,
"data": { /* payload */ }
}
On error:
{
"success": false,
"error": "Permission denied: capability 'manage_users' required"
}
The success field is always present and indicates whether the request succeeded.
The data field is present on success. The error field is present on failure.
Never both together.
Pagination
Routes that return potentially long lists accept limit and offset
as query params (?limit=20&offset=40). The response includes total and has_more
to make iteration easier. Max limit: 100.
Rate limiting
Global rate limiting has been removed (see CLAUDE.md, Security section). Only /api/auth/login
is rate-limited: 5 failed attempts per username every 15 minutes. Valid attempts
do not count.
Token versioning
Every JWT carries a tv field (token version) that matches users.token_version in the DB.
The auth middleware checks this value on every request. When the user calls POST /api/auth/logout,
when an admin resets their password, or when an admin deactivates their account, the token_version is bumped,
instantly invalidating all existing tokens of that user on every machine.
Curl
To call the API from a terminal:
TOKEN=$(curl -s -X POST https://uversion.mygamestudio.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret"}' | jq -r '.data.token')
curl -H "Authorization: Bearer $TOKEN" \
https://uversion.mygamestudio.com/api/repositories
Authentication
POST /api/auth/login
Authenticates a user and returns a 30-day JWT.
Body:
{
"username": "alice",
"password": "secret"
}
Response 200:
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"expires_at": "2026-06-13T14:30:00Z",
"user": {
"id": 12,
"username": "alice",
"email": "alice@mygamestudio.com",
"role": "lead",
"is_active": true
}
}
}
Errors:
401: invalid credentials or inactive user429: 5 failed attempts reached for this username within the 15-minute window
The server always runs argon2id::verify_password (against a dummy hash when the user does not exist)
to neutralize timing attacks. The response takes the same time whether the user exists or not.
POST /api/auth/refresh
Renews the JWT without going through the password again.
Headers: Authorization: Bearer <current_token>. No body.
Response 200: identical to /login with the expiry pushed 30 days out and a fresh token.
Errors:
401: current token invalid, expired, ortoken_versionmismatch
POST /api/auth/logout
Invalidates all tokens of the current user (on every machine) by bumping
users.token_version. The user will have to log in again everywhere.
Response 200: { "success": true }.
POST /api/auth/validate
Checks that the token is still valid. For tokens less than 7 days from expiry, the server includes
a refreshed_token in the response (auto-extend). The client must persist it in place
of the old one.
Response 200:
{
"success": true,
"data": {
"valid": true,
"user_id": 12,
"expires_at": "2026-06-13T14:30:00Z",
"refreshed_token": "eyJhbGciOiJIUzI1NiIs..."
}
}
POST /api/auth/register
Creates a user account (public route, no authentication).
POST /api/auth/change-password
Changes the current user's password. Bumps token_version, which invalidates all old tokens.
Repositories
GET /api/repositories
Lists the repositories accessible to the current user (filtered through the permissions table). A user with no permission pattern on a repo does not see it.
Query params: limit, offset.
Response 200:
{
"success": true,
"data": [
{
"id": 1,
"name": "hero-rpg",
"owner": "acme",
"description": "Main RPG project",
"current_revision": "7f3a9b1c2d...",
"file_count": 8432,
"size_bytes": 14211938405,
"created_at": "2026-01-15T10:00:00Z"
},
{
"id": 2,
"name": "shared-assets",
"owner": "acme",
"description": "Shared asset library",
"current_revision": "3a4b5c6d...",
"file_count": 412,
"size_bytes": 980000000,
"created_at": "2026-02-01T09:00:00Z"
}
]
}
GET /api/repositories/{repo_id}
Detail of a repository, including the aggregated stats.
Response 200:
{
"success": true,
"data": {
"id": 1,
"name": "hero-rpg",
"owner": "acme",
"description": "Main RPG project",
"current_revision": "7f3a9b1c2d...",
"file_count": 8432,
"size_bytes": 14211938405,
"dedup_ratio": 4.2,
"created_at": "2026-01-15T10:00:00Z",
"last_commit_at": "2026-05-15T08:30:00Z"
}
}
Errors:
403: no access to this repo404: repo does not exist
POST /api/repositories
Creates a new repository. Capability create_repos required (admin by default).
Body:
{
"name": "new-project",
"description": "Optional description"
}
Validation:
- name: 1 to 64 characters, alphanumeric + hyphens + underscores. Unique per owner.
- description: 0 to 1024 characters. Optional.
Errors:
400: invalid or too long name403: missingcreate_reposcapability409: a repo with this name already exists for this owner
Files
POST /api/files/{repo_id}/upload-chunks
Uploads a batch of binary chunks. Identical chunks (by SHA-256) are deduplicated automatically.
The server does not re-store a chunk that is already present. The response returns, for each chunk, its hash + size +
compressed size, to be used in the following POST /commit.
Body:
{
"chunks": [
{ "data": "<base64-encoded chunk bytes>" },
{ "data": "<base64-encoded chunk bytes>" }
]
}
Response 200:
{
"success": true,
"data": {
"chunks": [
{ "hash": "abc123...", "size": 1048576, "compressed_size": 423152 },
{ "hash": "def456...", "size": 2097152, "compressed_size": 891204 }
]
}
}
Limits: body max 1 GB (configurable via security.max_body_size_files). For larger files, split into several batches.
POST /api/files/{repo_id}/commit
Creates an atomic commit with a list of files and their chunks. Either every file passes, or none does.
Body:
{
"message": "Updated main level + hero pose pass",
"files": [
{
"path": "Content/Maps/MainLevel.umap",
"action": "modified",
"chunks": ["abc123...", "def456..."]
},
{
"path": "Content/Characters/NewVillain.uasset",
"action": "added",
"chunks": ["fed789..."]
},
{
"path": "Content/OldAsset.uasset",
"action": "deleted"
}
]
}
Possible actions: added, modified, deleted.
For added and modified, the chunks array holds the hashes returned
by /upload-chunks. For deleted, omit chunks.
Response 200:
{
"success": true,
"data": {
"commit_hash": "7f3a9b1c2d3e4f...",
"files_changed": 3,
"bytes_uploaded": 88080384,
"bytes_deduped": 4194304,
"revision": 47
}
}
Errors:
400: empty message, file with no action, referenced chunks that do not exist403: missingcheckincapability, or no write permission on one of the files409: lock already held by another user on a modified file, or a concurrent commit (race condition on the same file)
GET /api/files/{repo_id}/snapshot
Returns the full state of the repository at the current revision: every file, their revisions and their chunks. Used by the client for clone and forced-sync operations.
Query params:
revision(optional): snapshot at a past revision. Default: HEAD.
Response 200:
{
"success": true,
"data": {
"revision": 47,
"files": [
{
"path": "Content/Maps/MainLevel.umap",
"revision": 12,
"size_bytes": 84934656,
"chunks": ["abc123...", "def456..."]
}
]
}
}
GET /api/files/{repo_id}/content
Downloads the content of a file at a given revision (the server reassembles the chunks). Used by clone and sync.
Query params:
path: file path (relative to the repo root)revision(optional): revision number. Default: latest.
Response 200: the binary content of the file.
GET /api/files/{repo_id}/history
Paginated history of the repository's commits.
Query params:
limit(1 to 100, default 20)offset(default 0)path(optional): filter by file path (commits that modified this file)author(optional): filter by usernamesince(optional): ISO 8601 timestamp
Response 200:
{
"success": true,
"data": {
"commits": [
{
"hash": "7f3a9b1c...",
"author": "alice",
"author_id": 12,
"date": "2026-05-15T08:30:00Z",
"message": "Fixed lighting in main level",
"files_changed": 1,
"bytes_uploaded": 84934656
}
],
"total": 423,
"limit": 20,
"offset": 0,
"has_more": true
}
}
Incremental sync
Endpoints used by the desktop client to synchronize efficiently:
GET /api/files/{repo_id}/sync: files changed since a revision, for a delta sync.GET /api/files/{repo_id}/deletions: files deleted server-side, to propagate deletions locally.GET /api/files/{repo_id}/list: list of the repo's files.POST /api/files/{repo_id}/checkout: acquires the locks and prepares editing.
Locks
POST /api/locks/{repo_id}/acquire
Acquires locks on a list of paths. Acquisitions are independent: the acquired list
holds the successes, the failed list holds the failures with their reason.
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",
"last_heartbeat_at": "2026-05-15T14:30:00Z",
"expires_at": "2026-05-15T15:30:00Z"
}
],
"failed": [
{
"path": "Content/Characters/Hero.uasset",
"reason": "ALREADY_LOCKED",
"lock_holder": "bob",
"lock_acquired_at": "2026-05-15T13:00:00Z"
}
]
}
}
Locks expire after 60 minutes without a heartbeat. The expires_at field reflects
this deadline. Call /heartbeat periodically to renew the lock, or /release
to release it.
Failed reasons:
ALREADY_LOCKED: another user holds the lock (thelock_holderandlock_acquired_atfields are populated)PERMISSION_DENIED: no write permission on this pathINVALID_PATH: malformed path (absolute, contains.., etc.)
POST /api/locks/{repo_id}/release
Releases locks that you own. Body:
{
"paths": ["Content/Maps/MainLevel.umap"],
"force": false
}
force: true requires the force_unlock capability. The action is audited.
POST /api/locks/{repo_id}/heartbeat
Updates last_heartbeat_at on your locks. Recommended every 5 minutes for long-running CI jobs
that want admins to see the activity.
GET /api/locks/{repo_id}/status
Lists every active lock in the repository, joined on users and files to return the names directly.
Query params: user (filter by username), limit, offset.
Response 200:
{
"success": true,
"data": {
"locks": [
{
"file_path": "Content/Maps/MainLevel.umap",
"user_id": 12,
"username": "alice",
"acquired_at": "2026-05-15T14:30:00Z",
"last_heartbeat_at": "2026-05-15T16:00:00Z"
}
],
"total": 1
}
}
Comments & Reviews
GET /api/comments/{repo_id}/comments?commit_hash=<hash>
Lists a commit's comments, threaded (parent → children).
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
Creates a comment. file_path is optional (commit comment vs. file comment). parent_id to reply to an existing thread.
Body:
{
"commit_hash": "7f3a9b1c...",
"file_path": "Content/Maps/MainLevel.umap",
"body": "LGTM, ship it",
"parent_id": null
}
POST /api/comments/{repo_id}/reviews
Submits a review on a commit. Capability approve_changes required.
Body:
{
"commit_hash": "7f3a9b1c...",
"status": "approved",
"comment": "Lighting looks great, approving"
}
Accepted status: approved, changes_requested, pending.
Watchlist
GET /api/watchlist/{repo_id}/watchlist
Lists the current user's watch patterns on this repository.
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
Adds a watch pattern. Body:
{
"pattern": "Content/Characters/Hero/**",
"notify_on": ["commit", "lock"]
}
Possible events: commit (a commit modified a matching file),
lock (a lock was acquired), review (a review was posted).
DELETE /api/watchlist/{repo_id}/watchlist/{watch_id}
Removes a watch pattern. Returns { "success": true }.
Production board (tasks)
The old modification-requests API has been removed: the production board absorbs it
(requests become cards, origin='request'). The endpoints are nested under
/api/tasks/{repo_id}.
| Route | Description |
|---|---|
GET /api/tasks/{repo_id}/board | Full board: columns + cards |
POST /api/tasks/{repo_id}/tasks | Creates a card |
PUT/DELETE /api/tasks/{repo_id}/tasks/{task_id} | Updates / deletes a card |
PUT /api/tasks/{repo_id}/tasks/{task_id}/assignees | Assigns users to a card |
GET /api/tasks/{repo_id}/assignable-users | List of assignable users |
POST /api/tasks/{repo_id}/columns | Configures the board columns |
Sub-routes also available: card comments, links to assets and commits, and attachments
(with a cover image). Configuring the columns requires the
manage_board capability (admin / lead).
Builds
POST /api/builds/{repo_id}/upload?hash=<sha256>
Uploads a build file to <storage_path>/builds/<hash>.
The server verifies the SHA-256 supplied in the query param against the received content. If the hash already exists
server-side, it returns 200 immediately without recopying (build-storage dedup).
Headers: Content-Type: application/octet-stream.
Body: raw binary (no JSON wrapping).
Limits: 8 GB per file.
Errors:
400: hash query param missing or malformed, or computed SHA-256 != supplied hash413: file > 8 GB
POST /api/builds/{repo_id}/publish
Registers a build's manifest after all of its files are uploaded. Capability publish_builds required.
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}
Lists the published builds. Capability download_builds required to see the download links.
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
Downloads a file from a build. Capability download_builds required. A build's manifest is available via GET /api/builds/{repo_id}/{build_id}/manifest.
Admin
Every /api/admin/* route requires the user to have at least one admin capability
(depending on the endpoint: manage_users, manage_permissions, manage_rules, etc.).
Brief documentation below. Most follow the standard REST pattern (GET/POST/PUT/DELETE).
| Route | Capability | Description |
|---|---|---|
GET/POST /api/admin/users | manage_users | Lists / creates users |
PUT/DELETE /api/admin/users/{id} | manage_users | Updates / deletes |
POST /api/admin/users/{id}/reset-password | manage_users | Resets the password, bumps token_version |
GET/POST /api/admin/groups | manage_users | Group management |
GET/POST /api/admin/permissions/{repo_id} | manage_permissions | Glob permission rules per path |
GET/POST /api/admin/repositories/{repo_id}/rules | manage_rules | Pre-checkin validation rules |
GET/POST /api/admin/repositories/{repo_id}/webhooks | manage_rules | Discord / Slack / Teams / custom |
GET /api/admin/audit | view_all_activity | Filtered and paginated audit log |
GET /api/admin/stats | view_all_activity | Storage metrics, dedup ratio, growth |
GET /api/admin/repositories/{repo_id}/gc/preview | admin | GC preview without executing |
POST /api/admin/repositories/{repo_id}/gc | admin | Runs garbage collection |
POST /api/admin/locks/{id}/force-release | force_unlock | Force-unlocks a third-party lock, audited |
Health
GET /health
Unauthenticated endpoint returning 200 OK if the server can reach the database.
Used for load-balancer or monitoring health checks (Prometheus blackbox, Datadog synthetic, etc.).
Response 200: OK (text/plain).
Response 503: if the database is unreachable.
GET /api/server-info
Public unauthenticated endpoint returning the server metadata (version, etc.).
Like /health, it is not protected by the auth middleware.
Error format
On error, the response is { "success": false, "error": "..." } with an appropriate HTTP status.
The error field is a human-readable message. For clients that need stable machine-readable
codes, parse the prefix (e.g. "Permission denied: ..." always starts with
"Permission denied").
| HTTP | Meaning | When |
|---|---|---|
| 400 | Bad request | Invalid parameter, malformed JSON, body too large (before the hard 413 limit) |
| 401 | Not authenticated | Token missing, expired, invalid signature, or token_version mismatch |
| 403 | Permission denied | Missing capability, or no permission on the path / repo |
| 404 | Resource not found | Repo / file / commit / user does not exist or is inaccessible |
| 409 | Conflict | Lock already held, concurrent commit, unique constraint violated |
| 413 | Payload too large | Body beyond the limit (1 GB on /files, 8 GB on /builds/upload) |
| 429 | Too many requests | Only on /auth/login (per-user rate limiting) |
| 500 | Internal server error | DB error, IO error. Logged server-side; include the timestamp if you report the bug. |
| 503 | Service unavailable | Database unreachable (health check) or migration in progress |