From f2a58f3a9143dec5959d1cba251e8f32b483a63e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 00:33:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(host/library):=20external=20provider=20API?= =?UTF-8?q?=20=E2=80=94=20declarative=20reconcile=20(M4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External game-library providers become first-class (RFC §8): a plugin computes its desired title list and PUTs it — the host owns the diff. - CustomEntry gains `provider` + `external_id` (API-set only; never on manual entries). GameEntry surfaces `provider` for console attribution and the new `GET /library?provider=` filter. - PUT /api/v1/library/provider/{p}: atomic declarative reconcile keyed on the provider's `external_id` — host ids stay stable across syncs, orphans drop, manual entries and other providers are never touched, an empty array clears the set. Validated: provider id [a-z0-9._-] (`manual` reserved), unique non-empty external_ids. - DELETE /api/v1/library/provider/{p}: clean uninstall, returns the removed count. - Ownership is unambiguous both ways: manual CRUD now returns 409 for a provider-owned entry (MutateOutcome::ProviderOwned) instead of letting an edit be silently clobbered at the next sync. - library.changed now carries the mutating source (`manual` or the provider id) — hooks and the SDK filter on it. - Spec + SDK schemas regenerated; sdk/examples/provider-sync.ts is the provider-plugin skeleton. 347 host tests green (pure reconcile: stable ids, orphan drop, idempotence, bystanders untouched; name/payload validation; route 400s) + 11 SDK tests. Live-verified end to end THROUGH the SDK against a real host: sync → filtered list → manual-delete 409 → re-sync with stable id + orphan drop → uninstall (removed=2), with three library.changed(source=romm) events observed on the live stream. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/openapi.json | 225 +++++++++++++- crates/punktfunk-host/src/library.rs | 5 + crates/punktfunk-host/src/library/custom.rs | 317 ++++++++++++++++++-- crates/punktfunk-host/src/library/epic.rs | 1 + crates/punktfunk-host/src/library/gog.rs | 1 + crates/punktfunk-host/src/library/heroic.rs | 1 + crates/punktfunk-host/src/library/lutris.rs | 1 + crates/punktfunk-host/src/library/steam.rs | 1 + crates/punktfunk-host/src/library/xbox.rs | 1 + crates/punktfunk-host/src/mgmt.rs | 4 + crates/punktfunk-host/src/mgmt/library.rs | 129 +++++++- crates/punktfunk-host/src/mgmt/tests.rs | 53 ++++ docs-site/content/docs/automation.md | 2 +- sdk/examples/provider-sync.ts | 26 ++ sdk/src/gen/schemas.ts | 83 ++++- 15 files changed, 815 insertions(+), 35 deletions(-) create mode 100644 sdk/examples/provider-sync.ts diff --git a/api/openapi.json b/api/openapi.json index 63d458a1..232b5eca 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -913,8 +913,19 @@ "library" ], "summary": "List the game library", - "description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles).", + "description": "Every installed-store title (Steam, read from the host's local files — no Steam API key)\nmerged with the user's custom entries, sorted by title. Artwork fields are URLs the client\nfetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the\nentries a given external provider owns.", "operationId": "getLibrary", + "parameters": [ + { + "name": "provider", + "in": "query", + "description": "Only entries owned by this external provider", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Unified library across all stores", @@ -1197,6 +1208,146 @@ } } }, + "/api/v1/library/provider/{provider}": { + "put": { + "tags": [ + "library" + ], + "summary": "Replace a provider's library entries (declarative reconcile)", + "description": "Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the\nprovider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each\nsurviving title's host id stable across reconciles, drops orphans, and never touches manual\nentries or other providers'. An empty array removes everything the provider owns. Emits\n`library.changed` with the provider as `source`.", + "operationId": "reconcileProviderEntries", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "The provider id ([a-z0-9._-], `manual` reserved)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderEntryInput" + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The provider's resulting entries (host ids assigned/kept)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CustomEntry" + } + } + } + } + }, + "400": { + "description": "Invalid provider id or payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "library" + ], + "summary": "Remove a provider's library entries", + "description": "Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin\n(RFC §8). Emits `library.changed` when anything was removed.", + "operationId": "deleteProviderEntries", + "parameters": [ + { + "name": "provider", + "in": "path", + "description": "The provider id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "How many entries were removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderRemoved" + } + } + } + }, + "400": { + "description": "Invalid provider id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the catalog", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/local/summary": { "get": { "tags": [ @@ -2602,6 +2753,13 @@ "art": { "$ref": "#/components/schemas/Artwork" }, + "external_id": { + "type": [ + "string", + "null" + ], + "description": "The provider's own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is." + }, "id": { "type": "string", "description": "Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path)." @@ -2623,6 +2781,13 @@ }, "description": "Per-title prep/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`])." }, + "provider": { + "type": [ + "string", + "null" + ], + "description": "The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous)." + }, "title": { "type": "string" } @@ -3198,6 +3363,13 @@ } ] }, + "provider": { + "type": [ + "string", + "null" + ], + "description": "The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET /library?provider=` filters on it." + }, "store": { "type": "string", "description": "Which store surfaced it: `\"steam\"` or `\"custom\"`.", @@ -4053,6 +4225,57 @@ } } }, + "ProviderEntryInput": { + "type": "object", + "description": "One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider's required stable key.", + "required": [ + "external_id", + "title" + ], + "properties": { + "art": { + "$ref": "#/components/schemas/Artwork" + }, + "external_id": { + "type": "string", + "description": "The provider's stable id for this title (the reconcile diff key)." + }, + "launch": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LaunchSpec" + } + ] + }, + "prep": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PrepCmd" + }, + "description": "Per-title prep/undo steps — commands run as the host user; operator-privileged config." + }, + "title": { + "type": "string" + } + } + }, + "ProviderRemoved": { + "type": "object", + "description": "The count envelope a provider uninstall returns.", + "required": [ + "removed" + ], + "properties": { + "removed": { + "type": "integer", + "description": "How many entries the provider owned (and were removed).", + "minimum": 0 + } + } + }, "ReleaseDisplayRequest": { "type": "object", "description": "Request body for `releaseDisplay`.", diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 70c91925..b9638035 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -95,6 +95,11 @@ pub struct GameEntry { /// How the host would launch it, when known. #[serde(skip_serializing_if = "Option::is_none")] pub launch: Option, + /// The external provider owning this entry (custom-store entries synced by a provider + /// plugin, RFC §8) — `None` for installed-store titles and manual custom entries. The + /// console uses it for attribution; `GET /library?provider=` filters on it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, } /// A store that contributes titles to the library. The trait is the extension point for future diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 954748f3..444fde04 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -18,6 +18,16 @@ pub struct CustomEntry { /// `undo` at session end in reverse order (see [`crate::hooks::run_prep`]). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub prep: Vec, + /// The external provider owning this entry (RFC §8), set ONLY by the provider reconcile + /// API — `None` = a manual entry, which no provider operation ever touches, and which the + /// manual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned + /// entries, so ownership is never ambiguous). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// The provider's own stable key for this title — the reconcile diff key, so the + /// host-assigned `id` stays stable across reconciles. Present iff `provider` is. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_id: Option, } /// Request body to create or replace a custom entry (no `id` — the host owns it). @@ -33,6 +43,22 @@ pub struct CustomInput { pub prep: Vec, } +/// One title in a provider's declarative reconcile payload (RFC §8): [`CustomInput`] plus the +/// provider's required stable key. +#[derive(Clone, Debug, Deserialize, ToSchema)] +pub struct ProviderEntryInput { + /// The provider's stable id for this title (the reconcile diff key). + pub external_id: String, + pub title: String, + #[serde(default)] + pub art: Artwork, + #[serde(default)] + pub launch: Option, + /// Per-title prep/undo steps — commands run as the host user; operator-privileged config. + #[serde(default)] + pub prep: Vec, +} + impl From for GameEntry { fn from(c: CustomEntry) -> Self { GameEntry { @@ -41,6 +67,7 @@ impl From for GameEntry { title: c.title, art: c.art, launch: c.launch, + provider: c.provider, } } } @@ -88,7 +115,17 @@ fn new_id(title: &str) -> String { hex::encode(&Sha256::digest(format!("{title}:{nanos}").as_bytes())[..6]) } -/// Create a custom entry, returning it with its assigned id. +/// Outcome of a manual mutation against an id — distinguishes "no such entry" from "exists, +/// but a provider owns it" (the mgmt layer maps the latter to 409, not 404). +pub enum MutateOutcome { + Done(T), + NotFound, + /// The entry belongs to this provider — mutate it through the provider reconcile API + /// (or remove the whole provider set); manual edits would be clobbered at the next sync. + ProviderOwned(String), +} + +/// Create a custom (manual) entry, returning it with its assigned id. pub fn add_custom(input: CustomInput) -> Result { let mut entries = load_custom(); let entry = CustomEntry { @@ -97,40 +134,156 @@ pub fn add_custom(input: CustomInput) -> Result { art: input.art, launch: input.launch, prep: input.prep, + provider: None, + external_id: None, }; entries.push(entry.clone()); save_custom(&entries)?; - emit_changed(); + emit_changed("manual"); Ok(entry) } -/// Replace a custom entry's fields (id preserved). `None` ⇒ no entry with that id. -pub fn update_custom(id: &str, input: CustomInput) -> Result> { +/// Replace a manual entry's fields (id preserved). Provider-owned entries are refused — +/// their state belongs to the provider's reconcile (RFC §8 ownership rule). +pub fn update_custom(id: &str, input: CustomInput) -> Result> { let mut entries = load_custom(); let Some(slot) = entries.iter_mut().find(|e| e.id == id) else { - return Ok(None); + return Ok(MutateOutcome::NotFound); }; + if let Some(provider) = &slot.provider { + return Ok(MutateOutcome::ProviderOwned(provider.clone())); + } slot.title = input.title; slot.art = input.art; slot.launch = input.launch; slot.prep = input.prep; let updated = slot.clone(); save_custom(&entries)?; - emit_changed(); - Ok(Some(updated)) + emit_changed("manual"); + Ok(MutateOutcome::Done(updated)) } -/// Delete a custom entry. `false` ⇒ no entry with that id. -pub fn delete_custom(id: &str) -> Result { +/// Delete a manual entry. Provider-owned entries are refused (see [`update_custom`]). +pub fn delete_custom(id: &str) -> Result> { + let mut entries = load_custom(); + let Some(entry) = entries.iter().find(|e| e.id == id) else { + return Ok(MutateOutcome::NotFound); + }; + if let Some(provider) = &entry.provider { + return Ok(MutateOutcome::ProviderOwned(provider.clone())); + } + entries.retain(|e| e.id != id); + save_custom(&entries)?; + emit_changed("manual"); + Ok(MutateOutcome::Done(())) +} + +// ------------------------------------------------------------------ providers (RFC §8) + +/// Provider ids are path segments, event sources, and console labels: keep them tame. +/// `manual` is reserved (it is the no-provider sentinel in `library.changed`). +pub fn validate_provider_name(provider: &str) -> Result<(), String> { + if provider == "manual" { + return Err("provider id `manual` is reserved".into()); + } + let ok = !provider.is_empty() + && provider.len() <= 64 + && provider + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.')) + && provider.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()); + if ok { + Ok(()) + } else { + Err("provider id must be 1–64 chars of [a-z0-9._-], starting alphanumeric".into()) + } +} + +/// Validate a reconcile payload: non-empty titles and unique, non-empty external ids (the +/// diff key — a duplicate would make ownership of the surviving entry ambiguous). +pub fn validate_provider_payload(inputs: &[ProviderEntryInput]) -> Result<(), String> { + let mut seen = std::collections::HashSet::new(); + for (i, e) in inputs.iter().enumerate() { + if e.external_id.trim().is_empty() { + return Err(format!("entries[{i}]: `external_id` must not be empty")); + } + if e.title.trim().is_empty() { + return Err(format!("entries[{i}]: `title` must not be empty")); + } + if !seen.insert(e.external_id.as_str()) { + return Err(format!( + "entries[{i}]: duplicate `external_id` \"{}\"", + e.external_id + )); + } + } + Ok(()) +} + +/// The pure reconcile (unit-tested without the filesystem): replace `provider`'s entry set +/// with `inputs` inside `entries` — keeping each surviving title's host id stable (keyed on +/// `external_id`), dropping the provider's orphans, and never touching manual entries or +/// other providers'. Returns the provider's resulting entries, payload order. +fn reconcile_entries( + entries: &mut Vec, + provider: &str, + inputs: Vec, +) -> Vec { + // The provider's current entries, keyed by its own stable id. + let mut existing: std::collections::HashMap = entries + .iter() + .filter(|e| e.provider.as_deref() == Some(provider)) + .filter_map(|e| e.external_id.clone().map(|x| (x, e.clone()))) + .collect(); + // Everything the provider does NOT own survives untouched. + entries.retain(|e| e.provider.as_deref() != Some(provider)); + let mut result = Vec::with_capacity(inputs.len()); + for input in inputs { + let id = existing + .remove(&input.external_id) + .map(|prev| prev.id) // same title as last sync → keep its host id + .unwrap_or_else(|| new_id(&format!("{provider}:{}", input.external_id))); + result.push(CustomEntry { + id, + title: input.title, + art: input.art, + launch: input.launch, + prep: input.prep, + provider: Some(provider.to_string()), + external_id: Some(input.external_id), + }); + } + // `existing`'s leftovers are the orphans — deliberately dropped (declarative reconcile). + entries.extend(result.iter().cloned()); + result +} + +/// Atomically replace `provider`'s entry set (RFC §8: `PUT /library/provider/{provider}`). +/// The caller validates the name and payload first. Emits `library.changed` with the provider +/// as the source. +pub fn reconcile_provider( + provider: &str, + inputs: Vec, +) -> Result> { + let mut entries = load_custom(); + let result = reconcile_entries(&mut entries, provider, inputs); + save_custom(&entries)?; + emit_changed(provider); + Ok(result) +} + +/// Remove every entry of `provider` (RFC §8: `DELETE /library/provider/{provider}` — the +/// clean-uninstall path). Returns how many were removed; no event when nothing was. +pub fn delete_provider(provider: &str) -> Result { let mut entries = load_custom(); let before = entries.len(); - entries.retain(|e| e.id != id); - if entries.len() == before { - return Ok(false); + entries.retain(|e| e.provider.as_deref() != Some(provider)); + let removed = before - entries.len(); + if removed > 0 { + save_custom(&entries)?; + emit_changed(provider); } - save_custom(&entries)?; - emit_changed(); - Ok(true) + Ok(removed) } /// The prep/undo steps for a library id — `custom:` entries only (the other stores have no @@ -146,11 +299,11 @@ pub fn prep_for(library_id: &str) -> Vec { .unwrap_or_default() } -/// The custom-entry mutations are the only library writes today, all operator-driven — hence -/// `source: "manual"` (RFC §4; a provider id once the provider API of RFC §8 lands). -fn emit_changed() { +/// Every library mutation announces itself (RFC §4): `source` is `"manual"` for the operator +/// CRUD, the provider id for a reconcile/uninstall — hooks and the SDK filter on it. +fn emit_changed(source: &str) { crate::events::emit(crate::events::EventKind::LibraryChanged { - source: "manual".to_string(), + source: source.to_string(), }); } @@ -166,17 +319,131 @@ pub(crate) fn valid_steam_appid(value: &str) -> bool { mod tests { use super::*; - #[test] - fn custom_entry_maps_to_game_entry() { - let g: GameEntry = CustomEntry { - id: "abc123".into(), - title: "My ROM".into(), + fn manual(id: &str, title: &str) -> CustomEntry { + CustomEntry { + id: id.into(), + title: title.into(), + art: Artwork::default(), + launch: None, + prep: Vec::new(), + provider: None, + external_id: None, + } + } + + fn input(external_id: &str, title: &str) -> ProviderEntryInput { + ProviderEntryInput { + external_id: external_id.into(), + title: title.into(), art: Artwork::default(), launch: None, prep: Vec::new(), } - .into(); + } + + #[test] + fn custom_entry_maps_to_game_entry_with_provider() { + let g: GameEntry = manual("abc123", "My ROM").into(); assert_eq!(g.id, "custom:abc123"); assert_eq!(g.store, "custom"); + assert_eq!(g.provider, None); + + let mut e = manual("def456", "Synced"); + e.provider = Some("romm".into()); + e.external_id = Some("rom-1".into()); + let g: GameEntry = e.into(); + assert_eq!(g.provider.as_deref(), Some("romm")); + } + + /// The RFC §8 contract in one walk: add keeps ids stable across re-syncs, updates flow, + /// orphans drop, and neither manual entries nor other providers are ever touched. + #[test] + fn reconcile_is_declarative_with_stable_ids() { + let mut entries = vec![manual("man1", "Hand-added")]; + // Another provider's entry must survive every romm reconcile. + let mut other = manual("oth1", "Other title"); + other.provider = Some("itch".into()); + other.external_id = Some("x1".into()); + entries.push(other); + + // First sync: two titles appear. + let r1 = reconcile_entries( + &mut entries, + "romm", + vec![input("rom-a", "Game A"), input("rom-b", "Game B")], + ); + assert_eq!(r1.len(), 2); + assert!(r1.iter().all(|e| e.provider.as_deref() == Some("romm"))); + let id_a = r1[0].id.clone(); + assert_eq!(entries.len(), 4); + + // Second sync: A renamed, B gone, C new — A's host id must be STABLE. + let r2 = reconcile_entries( + &mut entries, + "romm", + vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")], + ); + assert_eq!(r2.len(), 2); + assert_eq!(r2[0].id, id_a, "same external_id keeps its host id"); + assert_eq!(r2[0].title, "Game A (v2)"); + assert_ne!(r2[1].id, id_a); + assert!( + !entries + .iter() + .any(|e| e.external_id.as_deref() == Some("rom-b")), + "orphan dropped" + ); + + // Idempotence: an identical re-PUT changes nothing. + let snapshot: Vec = entries.iter().map(|e| e.id.clone()).collect(); + let r3 = reconcile_entries( + &mut entries, + "romm", + vec![input("rom-a", "Game A (v2)"), input("rom-c", "Game C")], + ); + assert_eq!( + r3.iter().map(|e| &e.id).collect::>(), + r2.iter().map(|e| &e.id).collect::>() + ); + assert_eq!( + entries.iter().map(|e| e.id.clone()).collect::>(), + snapshot + ); + + // The bystanders never moved. + assert!(entries + .iter() + .any(|e| e.id == "man1" && e.provider.is_none())); + assert!(entries + .iter() + .any(|e| e.id == "oth1" && e.provider.as_deref() == Some("itch"))); + + // Empty payload = remove everything the provider owns (same as DELETE). + let r4 = reconcile_entries(&mut entries, "romm", Vec::new()); + assert!(r4.is_empty()); + assert_eq!( + entries.len(), + 2, + "only the manual + other-provider entries remain" + ); + } + + #[test] + fn provider_name_and_payload_validation() { + assert!(validate_provider_name("romm").is_ok()); + assert!(validate_provider_name("my-provider.v2").is_ok()); + assert!(validate_provider_name("manual").is_err(), "reserved"); + assert!(validate_provider_name("").is_err()); + assert!(validate_provider_name("Bad/Name").is_err()); + assert!(validate_provider_name("-lead").is_err()); + assert!(validate_provider_name(&"x".repeat(65)).is_err()); + + assert!(validate_provider_payload(&[input("a", "A")]).is_ok()); + assert!(validate_provider_payload(&[input("", "A")]).is_err()); + assert!(validate_provider_payload(&[input("a", " ")]).is_err()); + assert!( + validate_provider_payload(&[input("a", "A"), input("a", "B")]).is_err(), + "duplicate external_id" + ); } } diff --git a/crates/punktfunk-host/src/library/epic.rs b/crates/punktfunk-host/src/library/epic.rs index 14e0c520..4e4db50b 100644 --- a/crates/punktfunk-host/src/library/epic.rs +++ b/crates/punktfunk-host/src/library/epic.rs @@ -89,6 +89,7 @@ fn epic_entry( app_name.clone() }; Some(GameEntry { + provider: None, id: format!("epic:{app_name}"), store: "epic".into(), title, diff --git a/crates/punktfunk-host/src/library/gog.rs b/crates/punktfunk-host/src/library/gog.rs index 1596552b..a848747b 100644 --- a/crates/punktfunk-host/src/library/gog.rs +++ b/crates/punktfunk-host/src/library/gog.rs @@ -53,6 +53,7 @@ fn gog_games() -> Vec { // whatever it has cached (title-only until warmed). let art = cached_art(&id).unwrap_or_default(); out.push(GameEntry { + provider: None, id, store: "gog".into(), title, diff --git a/crates/punktfunk-host/src/library/heroic.rs b/crates/punktfunk-host/src/library/heroic.rs index f99eca8e..bb9b9a54 100644 --- a/crates/punktfunk-host/src/library/heroic.rs +++ b/crates/punktfunk-host/src/library/heroic.rs @@ -106,6 +106,7 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result rusqlite::Result> { let mut games = Vec::new(); for (id, slug, name) in rows.flatten() { games.push(GameEntry { + provider: None, id: format!("lutris:{id}"), store: "lutris".into(), title: name, diff --git a/crates/punktfunk-host/src/library/steam.rs b/crates/punktfunk-host/src/library/steam.rs index 6a27cc6f..0a2e6313 100644 --- a/crates/punktfunk-host/src/library/steam.rs +++ b/crates/punktfunk-host/src/library/steam.rs @@ -25,6 +25,7 @@ impl LibraryProvider for SteamProvider { .into_iter() .filter(|(appid, name)| !is_steam_tool(*appid, name)) .map(|(appid, title)| GameEntry { + provider: None, id: format!("steam:{appid}"), store: "steam".into(), title, diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs index 05a02fb7..37817d83 100644 --- a/crates/punktfunk-host/src/library/xbox.rs +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -61,6 +61,7 @@ fn xbox_games() -> Vec { // background warmer; read whatever it has cached (title-only until warmed / if no StoreId). let art = cached_art(&id).unwrap_or_default(); games.push(GameEntry { + provider: None, id, store: "xbox".into(), title, diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index f1128ebc..d3f87bf8 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -207,6 +207,10 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { library::update_custom_game, library::delete_custom_game )) + .routes(routes!( + library::reconcile_provider_entries, + library::delete_provider_entries + )) .routes(routes!(library::get_library_art)) .routes(routes!(stats::stats_capture_start)) .routes(routes!(stats::stats_capture_stop)) diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index b00622aa..3aa6a545 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -4,23 +4,39 @@ use super::shared::*; use axum::http::header; +#[derive(Deserialize)] +pub(crate) struct LibraryQuery { + /// Only entries owned by this external provider (RFC §8). + provider: Option, +} + /// List the game library /// /// Every installed-store title (Steam, read from the host's local files — no Steam API key) /// merged with the user's custom entries, sorted by title. Artwork fields are URLs the client -/// fetches directly (the public Steam CDN for Steam titles). +/// fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the +/// entries a given external provider owns. #[utoipa::path( get, path = "/library", tag = "library", operation_id = "getLibrary", + params( + ("provider" = Option, Query, description = "Only entries owned by this external provider"), + ), responses( (status = OK, description = "Unified library across all stores", body = [crate::library::GameEntry]), (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), ) )] -pub(crate) async fn get_library() -> Json> { - Json(crate::library::all_games()) +pub(crate) async fn get_library( + Query(q): Query, +) -> Json> { + let mut games = crate::library::all_games(); + if let Some(provider) = q.provider.filter(|p| !p.is_empty()) { + games.retain(|g| g.provider.as_deref() == Some(provider.as_str())); + } + Json(games) } /// Add a custom library entry @@ -75,9 +91,16 @@ pub(crate) async fn update_custom_game( if input.title.trim().is_empty() { return api_error(StatusCode::BAD_REQUEST, "title must not be empty"); } + use crate::library::MutateOutcome; match crate::library::update_custom(&id, input) { - Ok(Some(entry)) => Json(entry).into_response(), - Ok(None) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"), + Ok(MutateOutcome::Done(entry)) => Json(entry).into_response(), + Ok(MutateOutcome::NotFound) => { + api_error(StatusCode::NOT_FOUND, "no custom entry with that id") + } + Ok(MutateOutcome::ProviderOwned(p)) => api_error( + StatusCode::CONFLICT, + &format!("entry is owned by provider `{p}` — update it through its reconcile"), + ), Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } @@ -97,9 +120,101 @@ pub(crate) async fn update_custom_game( ) )] pub(crate) async fn delete_custom_game(Path(id): Path) -> Response { + use crate::library::MutateOutcome; match crate::library::delete_custom(&id) { - Ok(true) => StatusCode::NO_CONTENT.into_response(), - Ok(false) => api_error(StatusCode::NOT_FOUND, "no custom entry with that id"), + Ok(MutateOutcome::Done(())) => StatusCode::NO_CONTENT.into_response(), + Ok(MutateOutcome::NotFound) => { + api_error(StatusCode::NOT_FOUND, "no custom entry with that id") + } + Ok(MutateOutcome::ProviderOwned(p)) => api_error( + StatusCode::CONFLICT, + &format!( + "entry is owned by provider `{p}` — remove it there, or DELETE the provider set" + ), + ), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +/// The count envelope a provider uninstall returns. +#[derive(Serialize, ToSchema)] +pub(crate) struct ProviderRemoved { + /// How many entries the provider owned (and were removed). + removed: usize, +} + +/// Replace a provider's library entries (declarative reconcile) +/// +/// Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the +/// provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each +/// surviving title's host id stable across reconciles, drops orphans, and never touches manual +/// entries or other providers'. An empty array removes everything the provider owns. Emits +/// `library.changed` with the provider as `source`. +#[utoipa::path( + put, + path = "/library/provider/{provider}", + tag = "library", + operation_id = "reconcileProviderEntries", + params(("provider" = String, Path, description = "The provider id ([a-z0-9._-], `manual` reserved)")), + request_body = Vec, + responses( + (status = OK, description = "The provider's resulting entries (host ids assigned/kept)", body = [crate::library::CustomEntry]), + (status = BAD_REQUEST, description = "Invalid provider id or payload", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), + ) +)] +pub(crate) async fn reconcile_provider_entries( + Path(provider): Path, + ApiJson(inputs): ApiJson>, +) -> Response { + if let Err(e) = crate::library::validate_provider_name(&provider) { + return api_error(StatusCode::BAD_REQUEST, &e); + } + if let Err(e) = crate::library::validate_provider_payload(&inputs) { + return api_error(StatusCode::BAD_REQUEST, &e); + } + match crate::library::reconcile_provider(&provider, inputs) { + Ok(entries) => { + tracing::info!( + provider, + count = entries.len(), + "library provider reconciled" + ); + Json(entries).into_response() + } + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +/// Remove a provider's library entries +/// +/// Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin +/// (RFC §8). Emits `library.changed` when anything was removed. +#[utoipa::path( + delete, + path = "/library/provider/{provider}", + tag = "library", + operation_id = "deleteProviderEntries", + params(("provider" = String, Path, description = "The provider id")), + responses( + (status = OK, description = "How many entries were removed", body = ProviderRemoved), + (status = BAD_REQUEST, description = "Invalid provider id", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the catalog", body = ApiError), + ) +)] +pub(crate) async fn delete_provider_entries(Path(provider): Path) -> Response { + if let Err(e) = crate::library::validate_provider_name(&provider) { + return api_error(StatusCode::BAD_REQUEST, &e); + } + match crate::library::delete_provider(&provider) { + Ok(removed) => { + if removed > 0 { + tracing::info!(provider, removed, "library provider entries removed"); + } + Json(ProviderRemoved { removed }).into_response() + } Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), } } diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 2a63f763..c25d3d8e 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -1206,3 +1206,56 @@ async fn hooks_get_shape_and_put_validation() { let resp = app.clone().oneshot(req).await.expect("infallible"); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } + +// ------------------------------------------------------------------ library providers + +/// Provider reconcile validation (the write path itself is unit-tested in `library::custom` +/// against pure functions — a successful PUT here would touch the developer's real catalog). +#[tokio::test] +async fn provider_reconcile_validation() { + let app = test_app(test_state(), None); + let put = |provider: &str, body: serde_json::Value| { + axum::http::Request::put(format!("/api/v1/library/provider/{provider}")) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap() + }; + + // Reserved / malformed provider ids. + let (s, json) = send(&app, put("manual", serde_json::json!([]))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + assert!(json["error"].as_str().unwrap().contains("reserved")); + let (s, _) = send(&app, put("Bad%2FName", serde_json::json!([]))).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + + // Payload rules: empty external_id, duplicate external_id. + let (s, _) = send( + &app, + put( + "romm", + serde_json::json!([{"external_id": "", "title": "X"}]), + ), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let (s, json) = send( + &app, + put( + "romm", + serde_json::json!([ + {"external_id": "a", "title": "A"}, + {"external_id": "a", "title": "B"} + ]), + ), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST); + assert!(json["error"].as_str().unwrap().contains("duplicate")); + + // DELETE validates the name too. + let del = axum::http::Request::delete("/api/v1/library/provider/manual") + .body(Body::empty()) + .unwrap(); + let (s, _) = send(&app, del).await; + assert_eq!(s, StatusCode::BAD_REQUEST); +} diff --git a/docs-site/content/docs/automation.md b/docs-site/content/docs/automation.md index 91c0a69a..8183e71d 100644 --- a/docs-site/content/docs/automation.md +++ b/docs-site/content/docs/automation.md @@ -28,7 +28,7 @@ and nothing you configure here runs anywhere near the streaming path. | `pairing.pending` | an unpaired device knocks (once per device, not per retry) | device name, fingerprint, plane | | `pairing.completed` / `pairing.denied` | a pairing is approved+stored / denied | device name, fingerprint, plane | | `display.created` / `display.released` | a virtual display is minted / kept displays are released | backend + mode / count | -| `library.changed` | the game library is mutated | source (`manual`) | +| `library.changed` | the game library is mutated | source: `manual`, or the provider id that reconciled (`PUT /api/v1/library/provider/{p}`) | | `host.started` / `host.stopping` | the serve planes come up / wind down | version, whether GameStream is enabled | Every event is a small JSON document with a monotonic `seq`, a `ts_ms` timestamp, a `schema` diff --git a/sdk/examples/provider-sync.ts b/sdk/examples/provider-sync.ts new file mode 100644 index 00000000..0b943248 --- /dev/null +++ b/sdk/examples/provider-sync.ts @@ -0,0 +1,26 @@ +// The external game-library provider pattern (RFC §8): compute your desired title list and +// declaratively PUT it — the host diffs by your `external_id`, keeps host ids stable across +// syncs, drops orphans, and never touches manual entries. Uninstall = one DELETE. +import { connect } from "../src/index.js"; + +const PROVIDER = "romm"; // your punktfunk-plugin-* name + +const pf = await connect(); +pf.events.on("library.changed", (e) => { + if (e.source === PROVIDER) console.log("library synced"); +}); + +// Fetch your source of truth (a ROM manager, itch.io, a curated list…), then reconcile: +const desired = [ + { external_id: "rom-1", title: "Chrono Trigger", launch: { command: "retroarch ..." } }, + { external_id: "rom-2", title: "Super Metroid", launch: { command: "retroarch ..." } }, +]; +const entries = (await pf.request("PUT", `/library/provider/${PROVIDER}`, desired)) as { + id: string; + title: string; +}[]; +console.log(`synced ${entries.length} titles:`, entries.map((e) => `${e.title} (custom:${e.id})`)); + +// …run on a schedule, or keep watching your source. Clean uninstall: +// await pf.request("DELETE", `/library/provider/${PROVIDER}`); +pf.close(); diff --git a/sdk/src/gen/schemas.ts b/sdk/src/gen/schemas.ts index de3bf065..49bac604 100644 --- a/sdk/src/gen/schemas.ts +++ b/sdk/src/gen/schemas.ts @@ -963,9 +963,14 @@ export const GetHostInfoResponse = S.Struct({ /** * Every installed-store title (Steam, read from the host's local files — no Steam API key) * merged with the user's custom entries, sorted by title. Artwork fields are URLs the client - * fetches directly (the public Steam CDN for Steam titles). + * fetches directly (the public Steam CDN for Steam titles). `?provider=` narrows to the + * entries a given external provider owns. * @summary List the game library */ +export const GetLibraryQueryParams = S.Struct({ + "provider": S.optional(S.String).annotations({ description: 'Only entries owned by this external provider' }) +}) + export const GetLibraryResponseItem = S.Struct({ "art": S.Struct({ "header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }), @@ -978,6 +983,7 @@ export const GetLibraryResponseItem = S.Struct({ "kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }), "value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' }) }).annotations({ description: 'How the host would launch it, when known.' }))), + "provider": S.optional(S.NullOr(S.String)).annotations({ description: 'The external provider owning this entry (custom-store entries synced by a provider\nplugin, RFC §8) — `None` for installed-store titles and manual custom entries. The\nconsole uses it for attribution; `GET \/library?provider=` filters on it.' }), "store": S.String.annotations({ description: 'Which store surfaced it: `\"steam\"` or `\"custom\"`.' }), "title": S.String }).annotations({ description: 'One title in the unified library, regardless of which store it came from.' }) @@ -1055,6 +1061,7 @@ export const UpdateCustomGameResponse = S.Struct({ "logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }), "portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' }) })).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }), + "external_id": S.optional(S.NullOr(S.String)).annotations({ description: 'The provider\'s own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is.' }), "id": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }), "launch": S.optional(S.Union(S.Null, S.Struct({ "kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }), @@ -1064,6 +1071,7 @@ export const UpdateCustomGameResponse = S.Struct({ "do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }), "undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' }) }).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`]).' }), + "provider": S.optional(S.NullOr(S.String)).annotations({ description: 'The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous).' }), "title": S.String }).annotations({ description: 'A user-added title, persisted in `~\/.config\/punktfunk\/library.json`. Same shape the API\nreturns and the web console edits.' }) @@ -1076,6 +1084,79 @@ export const DeleteCustomGameParams = S.Struct({ }) +/** + * Atomically replaces the full entry set owned by `{provider}` (RFC §8): the payload is the + * provider's desired list, keyed by its own stable `external_id` — the host diffs, keeps each + * surviving title's host id stable across reconciles, drops orphans, and never touches manual + * entries or other providers'. An empty array removes everything the provider owns. Emits + * `library.changed` with the provider as `source`. + * @summary Replace a provider's library entries (declarative reconcile) + */ +export const ReconcileProviderEntriesParams = S.Struct({ + "provider": S.String.annotations({ description: 'The provider id ([a-z0-9._-], `manual` reserved)' }) +}) + +export const ReconcileProviderEntriesBodyItem = S.Struct({ + "art": S.optional(S.Struct({ + "header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }), + "hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }), + "logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }), + "portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' }) +})).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }), + "external_id": S.String.annotations({ description: 'The provider\'s stable id for this title (the reconcile diff key).' }), + "launch": S.optional(S.Union(S.Null, S.Struct({ + "kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }), + "value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' }) +}).annotations({ description: 'How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam:\/\/rungameid\/`;\n`command` → run `` nested in a gamescope session.' }))), + "prep": S.optional(S.Array(S.Struct({ + "do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }), + "undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' }) +}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps — commands run as the host user; operator-privileged config.' }), + "title": S.String +}).annotations({ description: 'One title in a provider\'s declarative reconcile payload (RFC §8): [`CustomInput`] plus the\nprovider\'s required stable key.' }) +export const ReconcileProviderEntriesBody = S.Array(ReconcileProviderEntriesBodyItem) + +export const ReconcileProviderEntriesResponseItem = S.Struct({ + "art": S.optional(S.Struct({ + "header": S.optional(S.NullOr(S.String)).annotations({ description: 'Horizontal header (Steam `header.jpg`) — the universal fallback.' }), + "hero": S.optional(S.NullOr(S.String)).annotations({ description: 'Wide background (Steam `library_hero.jpg`).' }), + "logo": S.optional(S.NullOr(S.String)).annotations({ description: 'Transparent title logo (Steam `logo.png`).' }), + "portrait": S.optional(S.NullOr(S.String)).annotations({ description: 'Vertical capsule \/ poster (Steam `library_600x900.jpg`). Best for a grid.' }) +})).annotations({ description: 'Cover art for a title. All fields are URLs (the Steam CDN for Steam titles, user-supplied for\ncustom). The client prefers `portrait` for a grid and falls back to `header` when a title has\nno 600×900 capsule (common for older Steam apps).' }), + "external_id": S.optional(S.NullOr(S.String)).annotations({ description: 'The provider\'s own stable key for this title — the reconcile diff key, so the\nhost-assigned `id` stays stable across reconciles. Present iff `provider` is.' }), + "id": S.String.annotations({ description: 'Host-assigned, stable for the life of the entry (the `{id}` in the CRUD path).' }), + "launch": S.optional(S.Union(S.Null, S.Struct({ + "kind": S.String.annotations({ description: '`\"steam_appid\"` or `\"command\"`.' }), + "value": S.String.annotations({ description: 'The appid (for `steam_appid`) or the shell command (for `command`).' }) +}).annotations({ description: 'How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam:\/\/rungameid\/`;\n`command` → run `` nested in a gamescope session.' }))), + "prep": S.optional(S.Array(S.Struct({ + "do": S.String.annotations({ description: 'Command run before launch. Same execution recipe and ownership checks as hook `run`\ncommands (event-less: stdin is empty JSON, env carries the `PF_APP_\*` context).' }), + "undo": S.optional(S.NullOr(S.String)).annotations({ description: 'Command run after the session ends. Skipped when its `do` failed (it never took effect).' }) +}).annotations({ description: 'One per-app preparation step (RFC §6 — deliberate Sunshine `prep-cmd` parity): `do` runs\n\*\*synchronously before the app launches\*\* (an HDR toggle or a MangoHud env change must land\nfirst), `undo` runs at session end — reverse order across steps, best-effort, on every exit\npath including a crash-unwind (RAII via [`PrepGuard`]).' }))).annotations({ description: 'Per-title prep\/undo steps (RFC §6): each `do` runs before this title launches, each\n`undo` at session end in reverse order (see [`crate::hooks::run_prep`]).' }), + "provider": S.optional(S.NullOr(S.String)).annotations({ description: 'The external provider owning this entry (RFC §8), set ONLY by the provider reconcile\nAPI — `None` = a manual entry, which no provider operation ever touches, and which the\nmanual CRUD alone may edit (the converse holds too: manual CRUD refuses provider-owned\nentries, so ownership is never ambiguous).' }), + "title": S.String +}).annotations({ description: 'A user-added title, persisted in `~\/.config\/punktfunk\/library.json`. Same shape the API\nreturns and the web console edits.' }) +export const ReconcileProviderEntriesResponse = S.Array(ReconcileProviderEntriesResponseItem) + + +/** + * Deletes every entry owned by `{provider}` — the clean-uninstall path for a provider plugin + * (RFC §8). Emits `library.changed` when anything was removed. + * @summary Remove a provider's library entries + */ +export const DeleteProviderEntriesParams = S.Struct({ + "provider": S.String.annotations({ description: 'The provider id' }) +}) + +export const deleteProviderEntriesResponseRemovedMin = 0; + + + +export const DeleteProviderEntriesResponse = S.Struct({ + "removed": S.Number.pipe(S.greaterThanOrEqualTo(deleteProviderEntriesResponseRemovedMin)).annotations({ description: 'How many entries the provider owned (and were removed).' }) +}).annotations({ description: 'The count envelope a provider uninstall returns.' }) + + /** * Non-sensitive status (counts and booleans only — no PIN values, no fingerprints, no device * names). Unauthenticated, but served to loopback peers only.