Four endpoints, and they answer four different questions that are easy to confuse:
| Question | |
|---|---|
/version |
Is there a newer app? — for a banner. |
/update/… |
Give me the newer app. — for the updater. |
/apps/:id/build |
Where is this game’s build, for this machine? |
/releases |
Here is a new app release. — for CI, not for clients. |
Is there a newer app?
/api/app/v1/versionTokenThe newest desktop app there is.
A version string and a download page. No artifact URL, no signature and no
checksum — this is the endpoint behind a banner, and the app compares its own
version against latest to decide whether to draw one.
Give me the newer app
/api/app/v1/update/{target}/{arch}/{current}The updater manifest. No authentication.
The app updates itself, and this is the route that lets it. The path segments
are the placeholders tauri-plugin-updater substitutes — {{target}},
{{arch}} and {{current_version}} — and the body is the manifest that plugin
parses.
Current builds ask the downloads bucket first
Release builds of the app are compiled with an updater endpoint pointing at a
signed latest.json (or, on the beta channel, latest-beta.json) in the
downloads bucket, which CI writes last, after every artifact it names. This
route is the fallback for a build with no endpoint compiled in (a build
whose bucket cannot be reached falls back to /version
for its banner, not to this route), and it answers the same Tauri shape, so
either source installs through the same signature check. What a reader sees of this
is described in Updates.
The shape is Tauri's, not ours
Inventing a shape here would mean writing the client half by hand, and the client half is the one whose bugs are silent: an updater that quietly stops checking looks exactly like an updater with nothing to report.
- 204 means you are current
Not a
200carrying the version you already have. The plugin treats anything that is not a204as a manifest it should act on, so answering an up-to-date client with a manifest naming an older build is how you downgrade somebody who is already ahead.- It is unauthenticated, on purpose
The answer is the same for everybody and is public the moment a release is published — and the normal state for an app that has just been installed is signed out. What bounds the route is not a token: nothing it names is installable without a signature, and the key that makes one is not in this database.
- There is no rate limit, also on purpose
Every other route in this family has one. The plugin checks once per launch, and a ceiling reached by an office behind a single address would answer a real client with a
429that it surfaces as “update check failed” — a failure whose remedy is invisible to the person it happens to. The route reads one indexed row and one cached setting.- Never cached
Cache-Control: no-store. The window between publishing a release and it being offered should be zero, and an edge holding a stale manifest is a release nobody receives with nothing to look at.
Where is this game’s build?
/api/app/v1/apps/{id}/build?platform=LINUX_X64Resolve one installable build, for the machine asking. Authentication is optional.
The install half of /play/launch,
and built the same way for the same reasons.
- platformenumrequired
The machine asking. There is deliberately no “all of them” — the whole reason this is a second request is that a device receives one download address, for itself.
- `null` is the whole refusal vocabulary
The app is hidden, the build is disabled, nothing is published for that platform, or the row is malformed. None of them changes what the device does next, and naming which one would tell a caller which column to probe.
- Every decision is made server-side
The app sends an id and its own platform. It never sends a URL and never chooses a file.
The catalogue at /apps says a build
exists; this says where it is. A device browsing the app list must not come
away holding download addresses for every game on every machine it is not.
`installable` narrows the catalogue
/apps?installable=true asks for only the games this app could actually put on
disk, so a launcher does not draw an install button it will then have to
retract.
Publishing a release
/api/app/v1/releasesShared secretPublish one desktop release — every platform at once. Called by CI, not by clients.
The step that used to be a person running a script per platform and then remembering to promote. The release workflow calls this once, after it has uploaded the artifacts to the downloads bucket and GitHub, with the bucket’s URLs, and the site’s own update route can offer the release from the moment it returns. The step is optional — a release the site was never told about still reaches every app that reads the bucket’s manifest.
- versionstringrequired
Dotted numeric, optionally with a
-preor+buildsuffix. The app compares this, and one it cannot order is one it never offers.- notesstring
Shown in the app before it installs. Plain text.
- artifactsarrayrequired
One to sixteen. Each names a target, an architecture, a URL and the signature the updater verifies.
- promotebooleandefault
false Also move the
app.version.latestsetting, which is what actually offers the release to anybody.falsepublishes the artifacts and leaves them unreachable — the right shape for a pre-release somebody wants staged.
Why a shared secret, and why it 404s
The caller is a CI job on the internet. It cannot be an /api/internal/ route —
nginx refuses those on both public server blocks, which is right for a
container-to-container hook and fatal for one called from GitHub — and it cannot
be a member session, because there is no member.
Unset means the route does not exist
404, not 401, and it fails closed. An unconfigured publishing hook costs
a manual release; an open one lets anybody point every installed copy of the app
at a binary of their choosing. A bad token gets the same 404: this route’s
existence is not something an unauthenticated caller needs confirmed.
The comparison against the secret is constant-time. === on a secret leaks its
length and its matching prefix through timing.
Every artifact moves at once
Every artifact and the app.version.latest setting are written in one
transaction. Publishing platform by platform and promoting afterwards has a
window in it where the setting names a version some machines have no artifact
for, and what those users get is being told there is an update and then handed a
404.
