/api/app/v1/packsDevice or CLI tokenThe publishing tool’s endpoint. Three actions on one route.
Behind a switch that ships off
Publishing is gated on the uploads.packsEnabled setting and answers 503
until an operator turns it on. The site does write the bytes (a worker job
builds the signed pack from the release’s files), but a claim takes a version
number permanently, so an operator enables it only once the games bucket and the
signing key are configured and a trial publish passes. Until then the
Game uploads page keeps listing what is already there.
503 rather than 404 on purpose: the endpoint exists, and a publishing tool
should say “not yet” rather than “no such command”.
Three actions, because a publish is not one request
claim takes the version, then the tool uploads, then complete or fail
closes it out. They are one transaction spread over however long an upload
takes, and the middle step is the slow one — exploding an archive, hashing every
file, signing a manifest. The version number has to be ours before that
starts.
- claim
Takes
version,fileCount,byteSize, and eitherrepoNameor anassetIdto publish under that asset’s name. Returns the row id and the resolvedcontentId. The row is created pending and holds the version from this moment.- complete
Takes the row
id, the finalfileCountandbyteSize, an optionalmanifestSha, and an optionalcontainsbill of materials (at most 200 entries). Marks the pack ready.- fail
Takes the row
idand anerrorstring. Closes the claim out as failed — and the version stays spent.
A tool that dies mid-upload leaves a pending row
That is the correct outcome, not a leak. The alternative is handing that version number to the next attempt while the first is still uploading to it — two publishes doing the same work, one of them throwing it away, and the loser has already spent the storage.
What a content id is
<owner>/<repo>, and it is a path component in the player’s client: a pack
mounts at res://<mount_root>/<content_id>/<version>/, and its manifest is
fetched from {base}/{content_id}/manifest.json.
Each segment is at most 64 characters of lowercase alphanumerics, - and _ —
and must equal its own slug, which rules out _foo, foo_, a__b and
__x__. Those pass a plain charset test and are then refused by every client
that tries to mount them.
Why the separator is a slash and never a flattened name
_ and - are both legal inside an owner and inside a name, so alice_bob is
ambiguous: it is alice_bob + x and also alice + bob_x. That is not
untidy, it is a security bug —
dot-cloud scopes a publisher’s signing key with
a glob over the id, so a key scoped alice_* would be entitled to sign for the
member called alice_bob. A / cannot occur inside either half, so alice/*
is exact.
The owner segment tmc is reserved for first-party content, and staff are the
only callers who may publish into it.
The gates, in the order they are asked
Shape, then ownership, then collision, then quota. Telling somebody they are out of space for a version number that was already taken sends them to delete something they did not need to.
| Denial | What it means |
|---|---|
BAD_ID |
The id or the version cannot survive being a path component. |
NOT_YOUR_NAMESPACE |
That <owner>/<repo> belongs to somebody else. |
VERSION_TAKEN |
That exact version has already been published. |
RETIRED |
That id was renamed away from; the denial names the new one. |
MOVING |
A rename of that pack is still running; try again when it ends. |
TOO_MANY_FILES |
Over uploads.maxFilesPerVersion for your role. |
TOO_MANY_VERSIONS |
Over uploads.maxVersions for that content id. |
OUT_OF_SPACE |
Over uploads.maxBytesPerUser for your role. |
A version is never republished
Not “should not” — cannot. Godot has no API to unmount a resource pack, so every version gets its own path and nothing is ever replaced. Reusing a number would mean two different sets of bytes answering to one mount path on machines that have already cached the first.
Two details worth knowing about the quotas. maxVersions counts rows in every
state but deleted, including failed ones — a failed row is still holding its version
number, and not counting it would let somebody mint versions by failing.
maxBytesPerUser counts ready rows only: a pending row has stored nothing
yet and a failed one never will, so charging either would bill for bytes nobody
is holding.
The same limits are asked again on complete, because the numbers on a
claim are a promise and the numbers on a complete are what actually landed.
Managing your packs
Account, then Game uploads lists one card per pack (content id): its total size, and the newest three versions, with “View all” for the rest. The list is paged, newest activity first. The same list appears on an asset’s Releases tab and in a game’s Content panel, scoped to that item.
- Delete
One version, or every version of a pack. Only the owner of the namespace and staff may delete: it is their version numbers that are spent, and permission to edit the asset is not permission to delete.
- Rename
Changes the pack’s id and rebuilds every version under the new one. Whoever can edit the asset may rename its pack; a pack with no asset (published from the CLI) can be renamed only by its owner. What changes depends on where the name comes from: the asset’s repo name, a per-archive name override for a map pack, or, for a CLI pack, only the id itself.
A deleted version keeps its number
Deleting removes the files, not the version: the row stays as a tombstone, so id@version is never handed to other bytes. Publishing that number again is refused with VERSION_TAKEN. Deleted versions stop counting toward your storage, maxVersions and the pack count, and a release sync will not republish one behind your back.
The delete itself is queued: the request only marks the rows and a worker removes the files afterwards, then repoints the pack’s latest.json (or removes it when no version is left). Delete and rename are refused while a version is still pending or a rename of the same asset’s packs is running, because either would write files the other has just removed.
A rename does not delete the old id’s files, and the old id is retired: the CLI publishing to it is refused with RETIRED naming the new one. Servers keep installing the old id’s newest version until they are pointed at the new name.
Compression
Files in a pack are content-addressed and cached for a year, and the compressible ones (models, JSON, scenes, scripts and resources) are stored gzip-encoded with Content-Encoding: gzip after delivery. You do nothing for this and the client sees the same bytes. Browser game builds are handled the same way with brotli.
Credentials
A CLI credential, minted by the same device grant the desktop app uses: the
member approves a named credential in a real browser and revokes it from the
same Account page. allowCli is opt-in across the whole app API, and this is
the route that opts in.
An ordinary device credential is accepted too — that is how the site’s own upload form reaches the same code. There is deliberately no second publish path: the namespace check and every quota live in one place, and a second entry point that skipped them would make both advisory.
Publishing under an asset
Pass assetId instead of repoName and the pack publishes under that asset’s
name — but the asset has to be yours. Without that check an asset id would
be enough to publish into somebody else’s namespace. If the asset has no repo
name yet, the first publish claims one derived from it and writes it down;
claiming it is what stops two of your assets interleaving their versions under
one mount path.
