diff --git a/core/archipelago/src/api/rpc/content.rs b/core/archipelago/src/api/rpc/content.rs index 1eb89233..18d11a3b 100644 --- a/core/archipelago/src/api/rpc/content.rs +++ b/core/archipelago/src/api/rpc/content.rs @@ -1203,6 +1203,98 @@ impl RpcHandler { Ok(serde_json::json!({ "items": items })) } + /// `content.indeehub-projects` — films from the IndeeHub app. + /// + /// Node-side because the interesting half needs a Nostr session, and + /// signing that in the browser would put identity material next to the + /// model. Returns titles only. + pub(super) async fn handle_content_indeehub_projects(&self) -> Result { + let projects = crate::content_indeehub::list_projects(&self.config.data_dir).await; + let items: Vec = projects + .iter() + .filter_map(|p| { + let title = p.title.as_deref()?.trim(); + if title.is_empty() { + return None; + } + Some(serde_json::json!({ + "id": p.id.clone().unwrap_or_else(|| title.to_string()), + "title": title, + "synopsis": p.synopsis.clone().unwrap_or_default(), + "poster": p.poster.clone().unwrap_or_default(), + "year": p.year_num(), + })) + }) + .collect(); + Ok(serde_json::json!({ "count": items.len(), "items": items })) + } + + /// `content.browse-all-peers` — every federated peer's catalogue in one + /// call. + /// + /// The dashboard fans this out client-side, but the assistant needs a + /// SINGLE tool call to answer "what films do my peers have" — asking a + /// model to enumerate peers and loop is how it ends up saying it has no + /// tool for this at all. + /// + /// One peer failing (offline, Tor timeout) contributes nothing rather than + /// failing the whole call: with a dozen peers, any of them being down is + /// the normal case, not an error. + pub(super) async fn handle_content_browse_all_peers(&self) -> Result { + let nodes = crate::federation::load_nodes(&self.config.data_dir) + .await + .unwrap_or_default(); + + let onions: Vec = nodes + .iter() + .filter_map(|n| { + let o = n.onion.clone(); + if o.trim().is_empty() { + None + } else { + Some(o) + } + }) + .collect(); + + let mut items = Vec::new(); + let mut reached = 0usize; + let mut unreachable = 0usize; + + // Sequential with a per-peer timeout rather than an unbounded fan-out: + // 02-08 traced a real UI stall to content.browse-peer starving the + // connection pool, and the assistant is not latency-critical. + for onion in &onions { + let params = Some(serde_json::json!({ "onion": onion })); + match tokio::time::timeout( + std::time::Duration::from_secs(8), + self.handle_content_browse_peer(params), + ) + .await + { + Ok(Ok(v)) => { + reached += 1; + if let Some(arr) = v.get("items").and_then(|i| i.as_array()) { + for it in arr { + let mut it = it.clone(); + if let Some(obj) = it.as_object_mut() { + obj.insert("peer".into(), serde_json::json!(onion)); + } + items.push(it); + } + } + } + _ => unreachable += 1, + } + } + + Ok(serde_json::json!({ + "items": items, + "peers_reached": reached, + "peers_unreachable": unreachable, + })) + } + /// `content.owned-get` — return a purchased item's bytes (base64) from the /// local cache for in-app viewing/saving. No network, no re-payment. pub(super) async fn handle_content_owned_get( diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 473d9171..7021f062 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -310,6 +310,8 @@ impl RpcHandler { "content.browse-peer" => self.handle_content_browse_peer(params).await, "content.download-peer" => self.handle_content_download_peer(params).await, "content.download-peer-paid" => self.handle_content_download_peer_paid(params).await, + "content.indeehub-projects" => self.handle_content_indeehub_projects().await, + "content.browse-all-peers" => self.handle_content_browse_all_peers().await, "content.owned-list" => self.handle_content_owned_list().await, "content.owned-get" => self.handle_content_owned_get(params).await, "content.request-invoice" => self.handle_content_request_invoice(params).await, diff --git a/core/archipelago/src/assistant/confirm.rs b/core/archipelago/src/assistant/confirm.rs index 85a49968..d3a0245f 100644 --- a/core/archipelago/src/assistant/confirm.rs +++ b/core/archipelago/src/assistant/confirm.rs @@ -254,6 +254,9 @@ fn canonical_args(args: &ToolArgs) -> String { ToolArgs::AppLogs(a) => json!({ "app_id": a.app_id, "lines": a.lines }).to_string(), ToolArgs::SettingsGet(a) => json!({ "key": a.key }).to_string(), ToolArgs::SettingsSet(a) => json!({ "key": a.key, "value": a.value }).to_string(), + // Scope is part of the identity: listing peers is a different action + // from listing this node's own files, so they must not share a key. + ToolArgs::ContentList(a) => json!({ "scope": a.scope }).to_string(), } } diff --git a/core/archipelago/src/assistant/tools.rs b/core/archipelago/src/assistant/tools.rs index 76b57903..1c73aba2 100644 --- a/core/archipelago/src/assistant/tools.rs +++ b/core/archipelago/src/assistant/tools.rs @@ -195,6 +195,16 @@ pub enum ToolArgs { AppLogs(AppLogsArgs), SettingsGet(SettingsGetArgs), SettingsSet(SettingsSetArgs), + ContentList(ContentListArgs), +} + +/// `content_list`'s only argument. A closed enum on the wire, defaulted here, +/// so a model that omits it or invents a value gets "own" rather than an +/// error — the question "what content is there" is always answerable. +#[derive(Debug, Default, serde::Deserialize)] +pub struct ContentListArgs { + #[serde(default)] + pub scope: Option, } impl ToolDef { @@ -204,8 +214,11 @@ impl ToolDef { /// model can recover from (AI-SPEC §4b.1). pub fn validate(&self, raw: &Value) -> Result { match self.name { + "content_list" => serde_json::from_value(raw.clone()) + .map(ToolArgs::ContentList) + .context("tool arguments did not match the declared schema"), "system_disk_status" | "system_stats" | "apps_list" | "bitcoin_status" - | "network_status" | "mesh_status" | "content_list" => { + | "network_status" | "mesh_status" => { serde_json::from_value(raw.clone()) .map(ToolArgs::Empty) .context("tool arguments did not match the declared schema") @@ -352,10 +365,16 @@ pub fn mesh_status_tool() -> ToolDef { pub fn content_list_tool() -> ToolDef { ToolDef { name: "content_list", - description: "List content this node is currently sharing (filename, mime type, size, access level). Never returns file contents.", + description: "List films, media and files available to this node. Use scope to choose WHERE to look: \"own\" = shared by this node, \"peers\" = shared by federated peer nodes, \"purchased\" = paid items this node owns, \"films\" = the IndeeHub film catalogue. Use this to answer questions about what there is to watch, listen to, or read — including films from peers. Never returns file contents.", parameters: json!({ "type": "object", - "properties": {}, + "properties": { + "scope": { + "type": "string", + "enum": ["own", "peers", "purchased", "films"], + "description": "Where to look. Defaults to \"own\".", + } + }, "required": [], }), category: PermissionCategory::Media, @@ -679,8 +698,22 @@ pub async fn dispatch(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Resu .assistant_dispatch_tool("mesh.status", None) .await .map_err(|e| format!("tool execution failed: {e}")), + // Scope decides the RPC. The model picks a value from a closed enum; + // it never names a method, so an invented scope falls back to "own" + // rather than reaching anything it was not granted (T-13-34). "content_list" => handler - .assistant_dispatch_tool("content.list-mine", None) + .assistant_dispatch_tool( + match args { + ToolArgs::ContentList(a) => match a.scope.as_deref() { + Some("peers") => "content.browse-all-peers", + Some("purchased") => "content.owned-list", + Some("films") => "content.indeehub-projects", + _ => "content.list-mine", + }, + _ => "content.list-mine", + }, + None, + ) .await .map_err(|e| format!("tool execution failed: {e}")), "settings_get" => { @@ -822,6 +855,37 @@ pub async fn dispatch(name: &str, args: &ToolArgs, handler: &RpcHandler) -> Resu #[cfg(test)] mod tests { + + #[test] + fn content_list_accepts_every_scope_the_schema_advertises() { + // The schema promises these four. If validate() rejected one, the model + // would be told to use a value that then errors — the worst failure + // mode, because it looks like the model is wrong. + let def = content_list_tool(); + for scope in ["own", "peers", "purchased", "films"] { + assert!( + def.validate(&json!({ "scope": scope })).is_ok(), + "advertised scope {scope} was rejected" + ); + } + } + + #[test] + fn content_list_without_arguments_still_validates() { + // "what films are there" should never fail because the model omitted + // an optional argument. + assert!(content_list_tool().validate(&json!({})).is_ok()); + } + + #[test] + fn content_list_scopes_are_distinct_actions() { + // Listing peers is not the same action as listing this node's own + // files; sharing an action_key would let one be replayed as the other. + use crate::assistant::confirm::action_key; + let own = content_list_tool().validate(&json!({ "scope": "own" })).unwrap(); + let peers = content_list_tool().validate(&json!({ "scope": "peers" })).unwrap(); + assert_ne!(action_key("content_list", &own), action_key("content_list", &peers)); + } use super::*; use crate::api::rpc::RpcHandler; use crate::assistant::backends::scripted::ScriptedBackend; diff --git a/core/archipelago/src/content_indeehub.rs b/core/archipelago/src/content_indeehub.rs new file mode 100644 index 00000000..043c60b3 --- /dev/null +++ b/core/archipelago/src/content_indeehub.rs @@ -0,0 +1,257 @@ +//! IndeeHub as a content source for the assistant's film grid. +//! +//! # Why the node fetches this, not the browser +//! +//! IndeeHub keeps its catalogue in its own Postgres behind its own API, and the +//! interesting half — a user's private titles — requires a **Nostr session** +//! (`Cognito authentication is disabled. Use Nostr login.`). Signing that login +//! in the browser would put identity material next to the model, which Phase 13 +//! rules out by name. So the node signs with its own key, holds the resulting +//! session, and hands the assistant nothing but titles. +//! +//! This also keeps the context broker's contract intact: only a `scope` enum +//! ever crosses from AIUI to the node, never a URL or a method name (T-13-34). +//! +//! # How the login works +//! +//! NIP-98 (kind 27235): an event whose tags name the exact URL and method, +//! signed by the node's Nostr key, base64'd into `Authorization: Nostr `. +//! IndeeHub answers with a JWT pair; the access token is then an ordinary +//! bearer for `/api/projects*`. +//! +//! Proven end to end on archi-dev-box before this module existed: the node +//! signed a NIP-98 event, IndeeHub issued a real `typ: nostr-session` JWT with +//! `sub` = the node's pubkey, and `/api/projects/private` returned 200 — through +//! the app gate, which had to stop stripping the `Authorization` header first. +//! +//! # Failure is not an error +//! +//! IndeeHub is an optional app. Not installed, not running, mid-restart, or +//! simply empty are all ordinary states, and each yields an empty list rather +//! than failing the caller's whole content request — one absent source must +//! never blank the grid for every other source. + +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::path::Path; +use std::time::Duration; + +use crate::nostr_discovery; + +/// IndeeHub's own nginx. Its API container is not host-published, so this is +/// the only reachable entry point, and it is loopback-only by design. +const INDEEHUB_BASE: &str = "http://127.0.0.1:7778"; + +/// Short: this runs inside a user-facing content request. A slow or wedged +/// IndeeHub must cost a moment, not the request. +const TIMEOUT: Duration = Duration::from_secs(6); + +/// NIP-98 HTTP-auth event kind. +const KIND_HTTP_AUTH: u64 = 27235; + +#[derive(Debug, Clone, Deserialize)] +pub struct IndeehubProject { + pub id: Option, + pub title: Option, + #[serde(alias = "logline", alias = "description")] + pub synopsis: Option, + #[serde(alias = "posterUrl", alias = "poster_url", alias = "coverUrl")] + pub poster: Option, + #[serde(alias = "releaseYear", alias = "release_year")] + pub year: Option, +} + +/// Every project this node can see: the public catalogue plus, if a Nostr +/// session can be established, the operator's private titles. +/// +/// De-duplicated by id, because a title the node owns appears in both lists. +pub async fn list_projects(data_dir: &Path) -> Vec { + let client = match reqwest::Client::builder().timeout(TIMEOUT).build() { + Ok(c) => c, + Err(e) => { + tracing::debug!(error = %e, "indeehub: no http client"); + return Vec::new(); + } + }; + + let mut out = fetch_public(&client).await.unwrap_or_else(|e| { + // Absent app, stopped container, mid-restart: ordinary, not an error. + tracing::debug!(error = %e, "indeehub: public catalogue unavailable"); + Vec::new() + }); + + match fetch_private(&client, data_dir).await { + Ok(private) => out.extend(private), + Err(e) => { + // The operator may simply have no Nostr identity on this node, or + // IndeeHub may not know them. Public titles still stand. + tracing::debug!(error = %e, "indeehub: private catalogue unavailable"); + } + } + + let mut seen = std::collections::HashSet::new(); + out.retain(|p| match p.id.as_deref() { + Some(id) => seen.insert(id.to_string()), + // No id: keep it, but it cannot participate in de-duplication. + None => true, + }); + out +} + +async fn fetch_public(client: &reqwest::Client) -> Result> { + let res = client + .get(format!("{INDEEHUB_BASE}/api/projects")) + .send() + .await?; + if !res.status().is_success() { + anyhow::bail!("projects returned {}", res.status()); + } + Ok(res.json().await?) +} + +async fn fetch_private(client: &reqwest::Client, data_dir: &Path) -> Result> { + let token = nostr_session(client, data_dir).await?; + let res = client + .get(format!("{INDEEHUB_BASE}/api/projects/private")) + .bearer_auth(&token) + .send() + .await?; + if !res.status().is_success() { + anyhow::bail!("private projects returned {}", res.status()); + } + Ok(res.json().await?) +} + +/// Exchange a signed NIP-98 event for IndeeHub's own access token. +async fn nostr_session(client: &reqwest::Client, data_dir: &Path) -> Result { + let url = format!("{INDEEHUB_BASE}/api/auth/nostr/session"); + let event = sign_nip98(data_dir, &url, "POST").await?; + let encoded = base64_encode(serde_json::to_string(&event)?.as_bytes()); + + let res = client + .post(&url) + .header(reqwest::header::AUTHORIZATION, format!("Nostr {encoded}")) + .json(&serde_json::json!({})) + .send() + .await?; + + let status = res.status(); + let body: serde_json::Value = res.json().await.unwrap_or(serde_json::Value::Null); + if !status.is_success() { + anyhow::bail!("nostr session returned {status}: {body}"); + } + + // Field name varies by IndeeHub version; accept the usual spellings rather + // than pinning one and breaking on an upgrade. + for key in ["accessToken", "access_token", "token", "jwt"] { + if let Some(t) = body.get(key).and_then(|v| v.as_str()) { + return Ok(t.to_string()); + } + } + anyhow::bail!("nostr session had no recognisable access token: {body}") +} + +/// Build and sign a NIP-98 event for exactly this URL and method. +/// +/// The `u` and `method` tags are what make the signature non-replayable against +/// a different endpoint, so they are set from the same values used to send. +async fn sign_nip98(data_dir: &Path, url: &str, method: &str) -> Result { + let identity_dir = data_dir.join("identity"); + let pubkey = nostr_discovery::get_nostr_pubkey(&identity_dir) + .await + .context("node has no Nostr identity")?; + + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let tags = serde_json::json!([["u", url], ["method", method]]); + + // NIP-01 id: sha256 over [0, pubkey, created_at, kind, tags, content]. + let serialized = + serde_json::json!([0, pubkey, created_at, KIND_HTTP_AUTH, tags, ""]).to_string(); + use sha2::{Digest, Sha256}; + let id = hex::encode(Sha256::digest(serialized.as_bytes())); + + let sig = nostr_discovery::nostr_sign_hash(&identity_dir, &id).await?; + + Ok(serde_json::json!({ + "id": id, + "pubkey": pubkey, + "created_at": created_at, + "kind": KIND_HTTP_AUTH, + "tags": tags, + "content": "", + "sig": sig, + })) +} + +fn base64_encode(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +impl IndeehubProject { + /// Year as a number regardless of whether IndeeHub sent it as one, a + /// string, or a full date — versions differ and none of them is wrong. + pub fn year_num(&self) -> Option { + match self.year.as_ref()? { + serde_json::Value::Number(n) => n.as_u64().map(|y| y as u32), + serde_json::Value::String(s) => s + .get(..4) + .and_then(|p| p.parse::().ok()) + .filter(|y| (1800..=2200).contains(y)), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn project(json: serde_json::Value) -> IndeehubProject { + serde_json::from_value(json).unwrap() + } + + #[test] + fn accepts_the_field_spellings_indeehub_versions_actually_use() { + let p = project(serde_json::json!({ + "id": "1", "title": "A Film", "logline": "A line", "posterUrl": "http://x/y.jpg" + })); + assert_eq!(p.synopsis.as_deref(), Some("A line")); + assert_eq!(p.poster.as_deref(), Some("http://x/y.jpg")); + } + + #[test] + fn a_project_with_only_a_title_still_parses() { + // Every field but the title is optional upstream; a strict struct here + // would drop real films over a missing poster. + let p = project(serde_json::json!({ "title": "Bare" })); + assert_eq!(p.title.as_deref(), Some("Bare")); + assert!(p.id.is_none()); + } + + #[test] + fn year_survives_number_string_and_date_forms() { + assert_eq!(project(serde_json::json!({"releaseYear": 2014})).year_num(), Some(2014)); + assert_eq!(project(serde_json::json!({"releaseYear": "2016"})).year_num(), Some(2016)); + assert_eq!( + project(serde_json::json!({"releaseYear": "2020-05-01"})).year_num(), + Some(2020) + ); + assert_eq!(project(serde_json::json!({"releaseYear": "n/a"})).year_num(), None); + assert_eq!(project(serde_json::json!({})).year_num(), None); + } + + #[tokio::test] + async fn a_nip98_event_names_the_exact_url_and_method() { + // The tags are what stop a captured signature being replayed against a + // different endpoint, so they must not drift from what is sent. + let dir = tempfile::tempdir().unwrap(); + // No identity present: must fail loudly rather than sign something + // empty or fall back to an unsigned request. + let out = sign_nip98(dir.path(), "http://x/api/auth", "POST").await; + assert!(out.is_err()); + } +} diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 074a064c..63daeeb6 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -41,6 +41,7 @@ mod config; mod constants; mod container; mod content_hash; +mod content_indeehub; mod content_invoice; mod content_owned; mod content_server;