feat(assistant): content_list gains scope — peers, purchased and IndeeHub films

Operator asked "what films are there to watch from my peers" and the model
answered, honestly, that it had no tool for it. It was right: content_list
mapped only to content.list-mine — this node's own shared files. Peer
catalogues and IndeeHub were unreachable from the assistant entirely.

content_list now takes scope: own | peers | purchased | films, dispatching to
content.list-mine / content.browse-all-peers / content.owned-list /
content.indeehub-projects. The model picks from a closed enum and never names
a method, so an invented scope falls back to "own" rather than reaching
anything it was not granted (T-13-34).

Two new RPCs behind it:

- content.browse-all-peers aggregates every federated peer in ONE call. The
  dashboard fans this out client-side, but asking a model to enumerate peers
  and loop is how it ends up claiming it has no tool. Rides FIPS —
  PeerRequest::new(fips_npub, onion, "/content") with a 6s FIPS fast-fail then
  Tor — so the onion is the peer's identity and FIPS is the transport.
  Sequential with a per-peer timeout, not an unbounded fan-out: 02-08 traced a
  real UI stall to browse-peer starving the connection pool. One peer being
  down is the normal case and contributes nothing rather than failing the call.
- content.indeehub-projects fetches IndeeHub's catalogue, public plus (via a
  node-signed NIP-98 login) the operator's private titles. Node-side because
  signing that in the browser would put identity material next to the model,
  which this phase rules out by name. Tolerant of IndeeHub's field spellings
  across versions, and absent/stopped/empty all yield an empty list rather
  than failing the caller.

action_key includes the scope, so listing peers cannot be replayed as listing
own files. 15/15 assistant::tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-07 03:15:10 -04:00
co-authored by Claude Opus 5
parent 05b459a65f
commit 58c759c149
6 changed files with 423 additions and 4 deletions
+92
View File
@@ -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<serde_json::Value> {
let projects = crate::content_indeehub::list_projects(&self.config.data_dir).await;
let items: Vec<serde_json::Value> = 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<serde_json::Value> {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let onions: Vec<String> = 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(
@@ -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,
@@ -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(),
}
}
+68 -4
View File
@@ -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<String>,
}
impl ToolDef {
@@ -204,8 +214,11 @@ impl ToolDef {
/// model can recover from (AI-SPEC §4b.1).
pub fn validate(&self, raw: &Value) -> Result<ToolArgs> {
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;
+257
View File
@@ -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 <b64>`.
//! 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<String>,
pub title: Option<String>,
#[serde(alias = "logline", alias = "description")]
pub synopsis: Option<String>,
#[serde(alias = "posterUrl", alias = "poster_url", alias = "coverUrl")]
pub poster: Option<String>,
#[serde(alias = "releaseYear", alias = "release_year")]
pub year: Option<serde_json::Value>,
}
/// 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<IndeehubProject> {
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<Vec<IndeehubProject>> {
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<Vec<IndeehubProject>> {
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<String> {
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<serde_json::Value> {
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<u32> {
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::<u32>().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());
}
}
+1
View File
@@ -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;