Files
archy/core/archipelago/src/content_indeehub.rs
T

277 lines
10 KiB
Rust
Raw Normal View History

2026-08-12 10:55:50 +00:00
//! 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");
// Prove the identity this node already has; never mint one here.
// `get_nostr_pubkey` goes through `load_or_create_nostr_keys`, so on a node
// without an identity it would GENERATE a keypair, sign with it, and write
// the secret to disk — an HTTP auth header quietly creating a new node
// identity, and authenticating to IndeeHub as a stranger with a key nobody
// has ever seen. The `.context("node has no Nostr identity")` below could
// never fire because of it.
if !nostr_discovery::nostr_identity_exists(&identity_dir).await {
anyhow::bail!("node has no Nostr 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());
}
}