The backend for atelier — Discord login, team cloud, storage and server builds for the GTA-V addon-clothing tool.
Collaborative backend for the atelier desktop app: Discord login, device
tokens, user approval, admin management including a web admin dashboard
(/admin), CAS uploads, packs/revisions, locks, WebSocket collaboration, plus
server builds, publish/registry and creative import.
- Runtime: Bun (
Bun.serve, no framework) - Database: MongoDB (raw driver, no ORM), DB
atelier(configurable) - Port:
3095 - Error convention:
{ "error": "message" }
⚠️ Server-build limitation (YMT): The real binaryCPedVariationInfoYMTs (mp_m_freemode_01_<dlc>.ymt,mp_creaturemetadata_*.ymt) can only be produced by the desktop build of the atelier app (CodeWalker/.NET). Server builds contain everything except the YMTs, plus astream/ATELIER_README.txtnote;atelier-build.jsoncarries"ymt": "missing-server-build". Registry downloads are therefore suitable for preview/distribution — complete in-game packs come from desktop builds. A future ymt-service sidecar deployment can close the gap.
atelier-api/
├── src/
│ ├── index.ts Bun.serve + route registration + CORS
│ ├── env.ts Typed env validation (fail fast)
│ ├── router.ts Mini router (method + path + :params, 0 deps)
│ ├── mongodb.ts Lazy singleton client + ensureIndexes()
│ ├── http.ts json/err/redirect/cookie/loopback helpers
│ ├── auth/
│ │ ├── jwt.ts HS256 JWT sign/verify (node:crypto, 0 deps)
│ │ ├── device-auth.ts atelierDevices + refresh-token rotation
│ │ ├── discord.ts reusable Discord OAuth helpers (web admin login)
│ │ ├── admin-web.ts /admin browser session (cookie) + CSRF + admin gate
│ │ └── require.ts requireUser / requireAdmin / requireService
│ ├── models/
│ │ ├── atelierUser.ts atelierUsers (pending/approved/locked)
│ │ ├── authCode.ts atelierAuthCodes (one-time codes, TTL 60s)
│ │ ├── atelierAsset.ts atelierAssets (CAS metadata)
│ │ ├── atelierUpload.ts atelierUploads (resumable sessions)
│ │ ├── atelierPack.ts atelierPacks (+ publish state)
│ │ ├── atelierRevision.ts atelierRevisions (immutable snapshots)
│ │ ├── atelierLock.ts atelierLocks (advisory locks)
│ │ ├── atelierBuild.ts atelierBuilds (server-build cache)
│ │ └── activity.ts atelierActivity (audit log)
│ ├── storage/
│ │ ├── cas.ts Content-addressed storage (+ casImportFile)
│ │ └── stats.ts disk-usage stats for the admin overview
│ ├── logging/log.ts in-memory ring-buffer log (admin live logs + SSE)
│ ├── web/
│ │ ├── pages.ts public HTML (landing + OAuth error pages)
│ │ └── admin/pages.ts admin login + dashboard shell HTML
│ ├── cloth/fivem-export.ts FiveM resource builder (without YMTs, see below)
│ ├── builds/queue.ts In-process build queue (concurrency, artifacts)
│ ├── ws/collab.ts WebSocket rooms (presence, locks, build-status)
│ └── routes/
│ ├── auth.ts Discord OAuth start/callback (+ dev fake mode)
│ ├── devices.ts exchange/refresh/logout + device management
│ ├── me.ts GET /api/v1/me
│ ├── admin.ts user list, approve/lock/role
│ ├── uploads.ts chunk uploads into the CAS
│ ├── assets.ts asset check + download (ETag/Range)
│ ├── packs.ts packs/revisions/members + publish
│ ├── presence.ts presence REST
│ ├── locks.ts drawable locks
│ ├── builds.ts server builds (status + artifact ZIP)
│ ├── registry.ts registry for community websites (service lane)
│ ├── import-creative.ts one-shot import from creative
│ └── admin-web.ts /admin dashboard (HTML + /api/v1/admin/web/* + assets)
├── assets/admin/ dashboard styles + client script (app.css, app.js)
└── scripts/
├── smoke.ts E2E smoke test against a running server
└── sync-roundtrip.ts push/pull roundtrip (pack, chunk upload, revision, download)
| Collection | Contents | Indexes |
|---|---|---|
atelierUsers |
discordId, username, avatar, status, role, createdAt, approvedBy… | discordId unique |
atelierAuthCodes |
one-time codes (browser → app), TTL 60 s, single-use | expiresAt TTL, code unique |
atelierDevices |
deviceId, refreshTokenHash (sha256), tokenVersion, revokedAt … | deviceId unique, discordId, refreshTokenHash |
atelierActivity |
audit log { type, actorDiscordId, ts, data } |
ts |
atelierAssets |
CAS assets { sha256, size, kind, diskPath, refCount } |
sha256 unique |
atelierUploads |
resumable upload sessions (chunks, TTL 48 h) | uploadId unique, TTL |
atelierPacks |
packs incl. publish { visibility, targets, publishedRevision } |
packId unique, slug (active) unique |
atelierRevisions |
immutable drawable snapshots | { packId, revision } unique |
atelierLocks |
advisory locks per drawable (TTL) | { packId, drawableEntryId } unique, TTL |
atelierBuilds |
server builds (cache per revision, artifact path, report) | buildId unique, { packId, revision } unique |
desktop app atelier-api Discord
| | |
| GET /auth/discord/start?redirect_uri=http://127.0.0.1:<port>/cb
|--------------------------->| |
| |-- 302 (signed state, ------->|
| | nonce cookie) |
| | |
| |<-- 302 /auth/discord/callback|
| | ?code&state |
| |-- code -> token, /users/@me |
| | upsert atelierUsers |
| | (new => status pending) |
|<-- 302 {redirect_uri}?code=<one-time, 60s TTL> -----------|
| |
| POST /auth/device/exchange { code, redirect_uri, device }
|--------------------------->| burn the single-use code,
| | create the device
|<-- { accessToken (JWT 1h), refreshToken (90d, rotating), user }
| |
| ... accessToken expired ...
| POST /auth/device/refresh { refreshToken }
|--------------------------->| verify hash, re-read user,
| | ROTATION: old token invalid immediately
|<-- { accessToken, refreshToken (NEW), user }
- Access token: JWT HS256, 1 h, claims
discordId/username/avatar/deviceId/tokenVersion/role/status. - Refresh token: 48 random bytes hex, stored only as a sha256 hash, 90 days, rotated on every refresh.
- tokenVersion: bumped on revoke/logout/lock → all of the device's issued JWTs invalid immediately.
- Pending gate: every
/api/v1/*endpoint except/api/v1/meand the auth/device routes returns403 { "error": "pending_approval" }for non-approved users (locked:403 { "error": "locked" }). - Admin override: Discord IDs from
ATELIER_ADMIN_DISCORD_IDSare forced tostatus=approved+role=adminon every login/refresh/request.
When ATELIER_DEV_FAKE_AUTH=1 and the Discord credentials are CHANGEME/empty
(and NODE_ENV != production), /auth/discord/start skips Discord entirely: the
fake user (ATELIER_DEV_FAKE_DISCORD_ID, username DevUser) is created directly
and redirected back to the app with a one-time code. Only in fake mode are the
query overrides &dev_id=<discordId> and &dev_username= allowed (for multi-user
testing, see scripts/smoke.ts).
Bun loads .env and .env.local automatically. Template: .env.example.
| Variable | Required | Default | Description |
|---|---|---|---|
PORT |
no | 3095 |
HTTP port |
HOST |
no | 127.0.0.1 |
bind address (deployment: 0.0.0.0) |
MONGODB_URI |
yes | – | MongoDB connection string (Atlas/local) |
MONGODB_DB_NAME |
no | atelier |
database name (configurable) |
MONGODB_DNS_SERVERS |
no | – | DNS override (e.g. 8.8.8.8) for querySrv ECONNREFUSED on Bun/Windows |
ATELIER_PUBLIC_ORIGIN |
no | http://127.0.0.1:3095 |
public base URL (Discord redirect) |
ATELIER_DISCORD_CLIENT_ID |
no* | CHANGEME |
Discord app client ID |
ATELIER_DISCORD_CLIENT_SECRET |
no* | CHANGEME |
Discord app client secret |
ATELIER_ADMIN_DISCORD_IDS |
no | empty | comma-separated IDs, always approved+admin |
ATELIER_JWT_SECRET |
yes | – | HS256 secret (min. 32 chars) |
ATELIER_SERVICE_TOKEN |
yes | – | header x-fg-service-token for service-to-service |
ATELIER_STORAGE_ROOT |
no | ./data |
file storage (cas/, tmp/, builds/) |
ATELIER_BUILD_CONCURRENCY |
no | 2 |
concurrent server builds |
ATELIER_CREATIVE_CLOTH_ROOT |
no | empty | creative CLOTH_UPLOAD_ROOT for the creative import (empty = endpoint 503) |
ATELIER_DEV_FAKE_AUTH |
no | 0 |
1 = fake login (dev only, see above) |
ATELIER_DEV_FAKE_DISCORD_ID |
no | – | Discord ID of the fake user |
* Required for real Discord login; not needed in fake mode.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
– | { ok, service, version } |
| GET | /api/v1/auth/discord/start?redirect_uri= |
– | 302 to Discord (or fake login) |
| GET | /api/v1/auth/discord/callback |
– | OAuth callback, 302 to the app with ?code= |
| POST | /api/v1/auth/device/exchange |
– | { code, redirect_uri, device } → tokens |
| POST | /api/v1/auth/device/refresh |
– | { refreshToken } → new tokens (rotation) |
| POST | /api/v1/auth/device/logout |
Bearer | sign out the current device |
| GET | /api/v1/me |
Bearer (even pending) | { user, device } |
| GET | /api/v1/devices |
Bearer (approved) | own devices |
| DELETE | /api/v1/devices/:deviceId |
Bearer (approved) | revoke own device |
| GET | /api/v1/admin/users?status= |
Admin | user list |
| POST | /api/v1/admin/users/:discordId/approve |
Admin | approve |
| POST | /api/v1/admin/users/:discordId/lock |
Admin | lock + revoke all devices |
| POST | /api/v1/admin/users/:discordId/role |
Admin | { role: "admin"|"member" } |
| GET | /api/v1/internal/ping |
x-fg-service-token |
service-to-service probe |
| POST | /api/v1/packs/:packId/builds |
Editor+ | { revision: n|"head" } → 202 (build running) or 200 (cache) |
| GET | /api/v1/builds/:buildId |
Member+ | build status { queued|running|done|error } |
| GET | /api/v1/builds/:buildId/artifact |
Member+ | artifact ZIP (FiveM resource, without YMTs, see above) |
| POST | /api/v1/packs/:packId/publish |
Owner | { visibility, targets, revision } → registry listing |
| GET | /api/v1/registry/packs?target=&q=&page=&pageSize= |
x-fg-service-token |
published packs (community) |
| GET | /api/v1/registry/packs/:idOrSlug |
x-fg-service-token |
pack + published revision manifest |
| GET | /api/v1/registry/packs/:idOrSlug/download |
x-fg-service-token |
build ZIP (202 { build } while building) |
| POST | /api/v1/import/creative/:creativeProjectId |
Admin | one-shot import of a creative cloth pack → pack + revision 1 |
The
/adminweb dashboard and its/api/v1/admin/web/*JSON API use a separate cookie session — see Admin dashboard.
- Builds are cached per
{ packId, revision }(revisions are immutable): the firstPOST /builds→202+ queue (ATELIER_BUILD_CONCURRENCY), finished builds →200with a cache hit. Artifacts:<ATELIER_STORAGE_ROOT>/builds/<packId>/<revision>.zip. - Status transitions are broadcast as
{ type: "build-status", buildId, status }into the pack's WebSocket room; completions land asbuild.completedin the activity log. - Split semantics (1:1 mirror of the sidecar
BuildPlanner): per gender the ADDON drawables are split, in revision order, into flatsplitAtchunks (default 128, the YMT limit); part k = chunk k of both genders, and with >1 part EVERY part gets the suffix_partNon both the resource folder AND the dlcName.NNN= index within the(part, gender, slot)bucket (restarts at 000 per part). Replace drawables go without a DLC prefix into part 1 (NNN=replaceTargetId), never into a YMT/shop meta. Props keep theirp_slot prefix in the stream name. Shop metas: one gender →shop_ped_apparel.meta, both →shop_ped_apparel_m.meta+shop_ped_apparel_f.meta. Stream names, shop metas andfxmanifest.luaare byte-identical to the desktop build (verified via an integration diff) — only the YMTs (missing server-side),atelier-build.jsonandATELIER_README.txtdiffer. - Creative import: componentId→slot follows creative's own semantics
(
7=accs,8=teef— swapped in creative vs. the canonical order,5=handalso for "task" files), so imported packs keep exactly the slot the creative UI showed. Gender: male, unlessscope.pedGender == "female". Missing files →skipped[].
bun install
cp .env.example .env.local # fill in the values
bun run dev # with --watch
bun run start # without watch
bun run lint # tsc --noEmit
bun run smoke # E2E test (server must be running, fake mode active)
bun run sync-roundtrip # push/pull roundtrip like the app does it# Health
curl http://127.0.0.1:3095/health
# 1) Start login (fake mode: immediate 302 with a code; otherwise 302 to Discord)
curl -i "http://127.0.0.1:3095/api/v1/auth/discord/start?redirect_uri=http://127.0.0.1:53682/callback"
# -> Location: http://127.0.0.1:53682/callback?code=<32hex>
# 2) Exchange the code for tokens
curl -X POST http://127.0.0.1:3095/api/v1/auth/device/exchange \
-H 'content-type: application/json' \
-d '{"code":"<32hex>","redirect_uri":"http://127.0.0.1:53682/callback","device":{"name":"My PC","platform":"windows","appVersion":"0.1.0"}}'
# 3) Authenticated requests
curl http://127.0.0.1:3095/api/v1/me -H "authorization: Bearer <accessToken>"
curl http://127.0.0.1:3095/api/v1/devices -H "authorization: Bearer <accessToken>"
# 4) Refresh the access token (the refresh token ROTATES!)
curl -X POST http://127.0.0.1:3095/api/v1/auth/device/refresh \
-H 'content-type: application/json' \
-d '{"refreshToken":"<96hex>"}'
# 5) Admin: approve a pending user
curl http://127.0.0.1:3095/api/v1/admin/users?status=pending -H "authorization: Bearer <adminToken>"
curl -X POST http://127.0.0.1:3095/api/v1/admin/users/<discordId>/approve -H "authorization: Bearer <adminToken>"
# 6) Service-to-service
curl http://127.0.0.1:3095/api/v1/internal/ping -H "x-fg-service-token: <ATELIER_SERVICE_TOKEN>"- https://discord.com/developers/applications → New Application → name e.g.
atelier. - Open OAuth2 on the left.
- Copy the Client ID →
ATELIER_DISCORD_CLIENT_ID. - Reset Secret → copy the Client Secret →
ATELIER_DISCORD_CLIENT_SECRET. - Under Redirects add exactly (BOTH):
{ATELIER_PUBLIC_ORIGIN}/api/v1/auth/discord/callback— desktop app login{ATELIER_PUBLIC_ORIGIN}/admin/callback— web admin dashboard (locally that'shttp://127.0.0.1:3095/api/v1/auth/discord/callbackandhttp://127.0.0.1:3095/admin/callback).
- Scope
identifyis enough — it is requested automatically by the service. - Set
ATELIER_DEV_FAKE_AUTH=0(once real creds exist, fake mode disables itself anyway).
A browser dashboard at {ATELIER_PUBLIC_ORIGIN}/admin — login only for
Discord IDs in ATELIER_ADMIN_DISCORD_IDS (a separate Discord web login, decoupled
from the desktop loopback flow; signed HttpOnly session cookie, 12 h, admin check
on every request). It offers:
- Overview — storage size (CAS/builds/tmp) + metrics (assets, packs, revisions, builds, users).
- Logs — live server logs (SSE) + activity audit (
atelierActivity). - Packs & builds — create/rebuild a server build per revision, download finished packages as ZIP.
- fxmanifest & build config — a per-pack resource-name and
fxmanifest.luatemplate override (placeholders{{files}}/{{data_files}}); affects server builds only and takes effect on the next build. Without an override the manifest stays byte-identical to the desktop build. - Users — approve / lock.
Requirement: real Discord creds + the /admin/callback redirect URI (see above).
Locally with fake auth, /admin/login logs in directly as
ATELIER_DEV_FAKE_DISCORD_ID (which must be in ATELIER_ADMIN_DISCORD_IDS).
Browser pages (cookie session, gated on ATELIER_ADMIN_DISCORD_IDS):
| Method | Path | Description |
|---|---|---|
| GET | /admin |
login page, or the dashboard when signed in |
| GET | /admin/login |
→ Discord OAuth (or the dev fake login) |
| GET | /admin/callback |
Discord callback; sets the session cookie |
| GET | /admin/logout |
clears the session |
| GET | /admin/app.css, /admin/app.js |
static dashboard assets |
JSON API — all under /api/v1/admin/web/, cookie-authed; mutations also require a
same-origin request:
| Method | Path | Description |
|---|---|---|
| GET | …/overview |
version, uptime, storage stats, counts |
| GET | …/activity?limit= |
activity audit log (atelierActivity) |
| GET | …/logs |
server-log ring-buffer snapshot |
| GET | …/logs/stream |
live server logs (SSE) |
| GET | …/packs |
pack list |
| GET | …/packs/:packId |
pack detail (revisions, builds, build config) |
| POST | …/packs/:packId/builds |
{ revision, force? } → trigger/rebuild a server build |
| PUT | …/packs/:packId/build-config |
{ resourceName, fxmanifestTemplate } → fxmanifest override |
| GET | …/builds |
all server builds |
| GET | …/builds/:buildId/download |
artifact ZIP |
| GET / POST | …/users · …/users/:discordId/approve · …/users/:discordId/lock |
user management |
-
Docker:
docker build -t atelier-api .— image onoven/bun:1, CAS storage as a volume under/data(ATELIER_STORAGE_ROOT), health check onGET /health, runs as an unprivilegedbunuser. Behind a reverse proxy setATELIER_TRUST_PROXY=1. Example:docker build -t atelier-api . docker run -d --name atelier-api \ -p 3095:3095 \ -v atelier-data:/data \ --env-file .env.docker \ atelier-api -
CI (
.github/workflows/ci.yml, PRs + master + tags): typecheck, then the full smoke suite (120 checks) + sync roundtrip (15 checks) against a live-started server with dev fake auth and amongo:7service container, plusdocker buildas a pure Dockerfile gate. No image is pushed to a registry on purpose — the deployment builds the image directly on the target host from the repo.
atelier-api is released under the PolyForm Noncommercial License 1.0.0: using, modifying and sharing for noncommercial purposes is allowed — selling and commercial use are not permitted (please keep the copyright notice from the license intact). Part of atelier. Dependencies (Bun, the MongoDB driver, JSZip) are under their respective licenses.
In the spirit of grzyClothTool (grzybeek), with CodeWalker (dexyfex) for the build pipeline. Built by the feelgood team.