uVersion
English
Download →

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

HeaderValue
AuthorizationBearer <jwt> on every /api/* route except /api/auth/* and /api/server-info (and /health)
Content-Typeapplication/json for POST/PUT with a JSON body. application/octet-stream for binary uploads (build files).
Acceptapplication/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 user
  • 429: 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, or token_version mismatch

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 repo
  • 404: 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 name
  • 403: missing create_repos capability
  • 409: 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 exist
  • 403: missing checkin capability, or no write permission on one of the files
  • 409: 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 username
  • since (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 (the lock_holder and lock_acquired_at fields are populated)
  • PERMISSION_DENIED: no write permission on this path
  • INVALID_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}.

RouteDescription
GET /api/tasks/{repo_id}/boardFull board: columns + cards
POST /api/tasks/{repo_id}/tasksCreates a card
PUT/DELETE /api/tasks/{repo_id}/tasks/{task_id}Updates / deletes a card
PUT /api/tasks/{repo_id}/tasks/{task_id}/assigneesAssigns users to a card
GET /api/tasks/{repo_id}/assignable-usersList of assignable users
POST /api/tasks/{repo_id}/columnsConfigures 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 hash
  • 413: 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).

RouteCapabilityDescription
GET/POST /api/admin/usersmanage_usersLists / creates users
PUT/DELETE /api/admin/users/{id}manage_usersUpdates / deletes
POST /api/admin/users/{id}/reset-passwordmanage_usersResets the password, bumps token_version
GET/POST /api/admin/groupsmanage_usersGroup management
GET/POST /api/admin/permissions/{repo_id}manage_permissionsGlob permission rules per path
GET/POST /api/admin/repositories/{repo_id}/rulesmanage_rulesPre-checkin validation rules
GET/POST /api/admin/repositories/{repo_id}/webhooksmanage_rulesDiscord / Slack / Teams / custom
GET /api/admin/auditview_all_activityFiltered and paginated audit log
GET /api/admin/statsview_all_activityStorage metrics, dedup ratio, growth
GET /api/admin/repositories/{repo_id}/gc/previewadminGC preview without executing
POST /api/admin/repositories/{repo_id}/gcadminRuns garbage collection
POST /api/admin/locks/{id}/force-releaseforce_unlockForce-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").

HTTPMeaningWhen
400Bad requestInvalid parameter, malformed JSON, body too large (before the hard 413 limit)
401Not authenticatedToken missing, expired, invalid signature, or token_version mismatch
403Permission deniedMissing capability, or no permission on the path / repo
404Resource not foundRepo / file / commit / user does not exist or is inaccessible
409ConflictLock already held, concurrent commit, unique constraint violated
413Payload too largeBody beyond the limit (1 GB on /files, 8 GB on /builds/upload)
429Too many requestsOnly on /auth/login (per-user rate limiting)
500Internal server errorDB error, IO error. Logged server-side; include the timestamp if you report the bug.
503Service unavailableDatabase unreachable (health check) or migration in progress