Override an app's artwork (fuzzy matching WILL be wrong sometimes).
const url = 'http://localhost:8080/v1/admin/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/artwork';const options = { method: 'PUT', headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'}, body: '{"provider_ref":"example","tile_url":"example","hero_url":"example","rematch":true,"force":true}'};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}use std::str::FromStr;use serde_json::json;use reqwest;
#[tokio::main]pub async fn main() { let url = "http://localhost:8080/v1/admin/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/artwork";
let payload = json!({ "provider_ref": "example", "tile_url": "example", "hero_url": "example", "rematch": true, "force": true });
let mut headers = reqwest::header::HeaderMap::new(); headers.insert("Authorization", "Bearer <token>".parse().unwrap()); headers.insert("Content-Type", "application/json".parse().unwrap());
let client = reqwest::Client::new(); let response = client.request(reqwest::Method::from_str("PUT").unwrap(), url) .headers(headers) .json(&payload) .send() .await;
let results = response.unwrap() .json::<serde_json::Value>() .await .unwrap();
dbg!(results);}curl --request PUT \ --url http://localhost:8080/v1/admin/apps/2489E9AD-2EE2-8E00-8EC9-32D5F69181C0/artwork \ --header 'Authorization: Bearer <token>' \ --header 'Content-Type: application/json' \ --data '{ "provider_ref": "example", "tile_url": "example", "hero_url": "example", "rematch": true, "force": true }'UI-P7. Exactly one intent per request. provider_ref accepts a candidate from POST …/artwork/search. tile_url/hero_url fetch operator-supplied art - the path for anything a games database does not have, including every desktop app. rematch re-runs automatic matching now instead of waiting for the background sweep; it REFUSES a locked record with 409 unless force is also sent (#385 - it previously cleared unconditionally, silently discarding the correction the flag exists to protect). An operator-supplied URL is ATTACKER-ADJACENT input (an operator pastes what they were sent) and goes through the same guards as a provider URL with no exception for admin privilege: http(s) only, resolved address must be publicly routable (loopback, RFC1918, link-local, CGNAT and cloud-metadata addresses are refused, including across redirects), at most 3 redirects, a byte cap, and the stored bytes must SNIFF as JPEG/PNG/WebP regardless of the Content-Type claimed. Any override sets locked=true, so the background sweep can never silently re-break the correction.
Authorizations
Section titled “Authorizations”Parameters
Section titled “Parameters”Path Parameters
Section titled “Path Parameters”Request Bodyrequired
Section titled “Request Bodyrequired”Exactly one intent per request; an empty body is 400 validation_failed.
object
Accept this candidate from a prior …/artwork/search.
Fetch operator-supplied art for the tile crop. Absent leaves the existing tile untouched.
Fetch operator-supplied art for the hero crop. Absent leaves the existing hero untouched.
Re-run automatic matching now instead of waiting for the background sweep. 409 conflict when the record is locked, unless force is also sent.
Qualifies rematch ONLY: replace a locked record. The other intents set locked themselves and were never blocked by it.
Examplegenerated
{ "provider_ref": "example", "tile_url": "example", "hero_url": "example", "rematch": true, "force": true}Responses
Section titled “Responses”OK.
object
UI-P7: an app’s artwork provenance and the two locally served crops.
object
‘provider’ = matched and fetched automatically. ‘manual’ = an admin picked, supplied or uploaded it. ‘none’ = we looked and there is nothing - a NEGATIVE CACHE and a first-class outcome, not an error: a desktop app is not in a games database and never will be, so recording it stops every sweep re-querying a third party for a row that can never match. All three render correctly; ‘none’ is the gradient tile.
Which provider produced the match (‘steamgriddb’); empty for a manual or unmatched app.
The provider’s opaque id for the matched title.
The provider-side title that was matched. Surfaced so an operator can SEE that “Portal” matched “Portal Knights” before deciding to override it.
Local /v1/artwork/… path for the 2:3 portrait tile crop, or null.
Local /v1/artwork/… path for the wide hero crop, or null.
Credit line to render beside the art, when the source asks for one.
True once an admin has overridden the match. The automatic sweep NEVER touches a locked record - fuzzy matching is wrong sometimes, and a correction must not be silently re-broken.
Whether a third-party artwork provider is configured on this deployment. False is the SHIPPED DEFAULT. The admin UI reads this to explain why the provider-backed controls are unavailable, rather than offering a button that silently does nothing. Resolved PER REQUEST from the encrypted secrets store, so a key set from the admin UI flips this without a control-plane restart.
Empty when no provider is configured.
Where the credential in effect came from: ‘database’ (an admin set it through /v1/admin/secrets), ‘environment’ (the legacy QUASAR_STEAMGRIDDB_API_KEY), ‘static’ (a provider supplied directly at construction - embedded/test wiring only, never the shipped server) or ‘none’. An operator upgrading a deployment that already had the env var must be able to SEE that it is still what is being used.
Why the provider is unavailable despite something being configured - e.g. a stored API key the master key cannot decrypt. Empty when there is nothing to explain. Never contains any part of a credential.
Example
{ "artwork": { "source": "provider" }, "provider_origin": "none"}Malformed or invalid request.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Missing/invalid/expired/revoked token.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Authenticated but insufficient role / not the owner (precedes resource lookup).
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}No such resource.
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}Conflict (duplicate, in-use, quota, non-swappable, home-in-use, …).
object
object
E.g. validation_failed, unauthorized, forbidden, not_found, conflict, session_quota_exceeded, home_in_use, home_not_provisioned, parent_app_disabled, profile_ineligible, profile_not_launchable_for_app, no_host_available, capacity_exhausted, restart_required, rate_limited, internal. Open string, not an enum: new codes are additive and an unknown one falls through to a client’s generic per-status branch.
Present on restart_required.
Steam library discovery Phase 3, ADDITIVE: present on home_in_use when the guard could name the CONFLICTING live session - the one already holding the home. It is here so the client can offer “go to your running session” with a link instead of a dead-end toast. OMITTED rather than empty when the conflict is known but the session is not, so a client branches on presence and never renders a link to nowhere. Load-bearing once derived tiles exist: the lock is held by the PARENT’s home, so a user who clicks a game tile can be refused because a DIFFERENT app (the Steam launcher, or another game from the same install) is running, and without the session id the refusal reads as a bug. See control-api.md §Derived tiles.
Steam library discovery Phase 3, ADDITIVE: present on the 409 conflict from DELETE /v1/apps/{id} when the app has derived tiles and ?delete_derived=true was not sent. A LIST, not a count - the point of the confirmation is that the admin sees what they are about to destroy. Capped; an empty array means the tiles could not be listed, never that there are none.
object
Examplegenerated
{ "error": { "code": "example", "message": "example", "live_sessions": 1, "session_id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "derived_tiles": [ { "id": "2489E9AD-2EE2-8E00-8EC9-32D5F69181C0", "name": "example" } ] }}