diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index 486babe7..5179e000 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -623,6 +623,24 @@ impl ApiHandler { // (upstream Gitea has no ACAO header) or CSP (IP-port upstream // falls outside `connect-src`). Session-authenticated so only // the logged-in node owner can spin up fetches. + (Method::GET, "/api/node-app-catalog") => { + if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); } + let data_dir = self.config.data_dir.clone(); + let result = tokio::task::spawn_blocking(move || { + crate::container::node_catalog::verified_body(&data_dir) + }).await.unwrap_or_else(|error| Err(anyhow::anyhow!(error))); + let (status, body) = match result { + Ok(Some(body)) => (StatusCode::OK, body), + Ok(None) => (StatusCode::NOT_FOUND, "{}".to_owned()), + Err(error) => { + tracing::warn!("Node demo catalog rejected: {error}"); + (StatusCode::CONFLICT, "{\"error\":\"Node demo catalog is unavailable\"}".to_owned()) + }, + }; + Ok(Response::builder().status(status).header("Content-Type", "application/json") + .header("Cache-Control", "private, no-store").body(hyper::Body::from(body))?) + } + (Method::GET, "/api/app-catalog") => { if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); diff --git a/core/archipelago/src/container/app_catalog.rs b/core/archipelago/src/container/app_catalog.rs index 8d064750..cf1f5c11 100644 --- a/core/archipelago/src/container/app_catalog.rs +++ b/core/archipelago/src/container/app_catalog.rs @@ -178,7 +178,7 @@ fn find_cache_file() -> Option<(PathBuf, SystemTime)> { /// Load and cache the on-node catalog. Returns an empty catalog when absent — /// callers then fall back to `image-versions.sh`. -fn load_catalog() -> AppCatalog { +fn load_global_catalog() -> AppCatalog { let (path, mtime) = match find_cache_file() { Some(v) => v, None => return AppCatalog::default(), @@ -218,6 +218,18 @@ fn load_catalog() -> AppCatalog { catalog } +fn load_catalog() -> AppCatalog { + let mut catalog = load_global_catalog(); + if let Some((path, _)) = find_cache_file() { + if let Some(data_dir) = path.parent() { + for (id, entry) in super::node_catalog::entries(data_dir) { + catalog.apps.entry(id).or_insert(entry); + } + } + } + catalog +} + fn entry_for(app_id: &str) -> Option { load_catalog().apps.get(app_id).cloned() } diff --git a/core/archipelago/src/container/mod.rs b/core/archipelago/src/container/mod.rs index da3968fe..f75ee749 100644 --- a/core/archipelago/src/container/mod.rs +++ b/core/archipelago/src/container/mod.rs @@ -1,4 +1,5 @@ pub mod app_catalog; +pub mod node_catalog; pub mod app_gate_config; pub mod bitcoin_ui; pub mod boot_reconciler; diff --git a/core/archipelago/src/container/node_catalog.rs b/core/archipelago/src/container/node_catalog.rs new file mode 100644 index 00000000..186cc1d3 --- /dev/null +++ b/core/archipelago/src/container/node_catalog.rs @@ -0,0 +1,148 @@ +//! Optional, release-signed app catalog restricted to exactly one node DID. +//! It is never fetched from public mirrors or merged into the global signed +//! bytes. Existing application IDs cannot be overridden by a demo catalog. +use super::app_catalog::{AppCatalog, AppCatalogEntry}; +use anyhow::{Context, Result}; +use serde_json::Value; +use std::{collections::HashMap, io::Read, os::unix::fs::OpenOptionsExt, path::Path}; + +pub const FILE: &str = "node-app-catalog.json"; +const LIMIT: u64 = 1024 * 1024; + +fn validate(raw: &Value, node_did: &str) -> Result { + anyhow::ensure!( + raw["schema"] == 1 && raw["scope"] == "single-node-demo", + "Unsupported node catalog scope" + ); + anyhow::ensure!( + raw["target_node_did"].as_str() == Some(node_did), + "Catalog belongs to another node" + ); + let expires = chrono::DateTime::parse_from_rfc3339( + raw["expires_at"] + .as_str() + .context("Missing catalog expiry")?, + )?; + anyhow::ensure!(expires > chrono::Utc::now(), "Node catalog has expired"); + anyhow::ensure!( + matches!( + crate::trust::verify_detached(raw)?, + crate::trust::SignatureStatus::Verified { anchored: true, .. } + ), + "Node catalog requires the pinned release-root signature" + ); + let catalog: AppCatalog = serde_json::from_value(raw.clone())?; + anyhow::ensure!( + !catalog.apps.is_empty() && catalog.apps.len() <= 16, + "Invalid demo app count" + ); + for (id, entry) in &catalog.apps { + anyhow::ensure!( + id.starts_with("node-demo-") + && id.len() <= 64 + && id + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'), + "Invalid demo app ID" + ); + let value = entry + .manifest + .clone() + .context("Node demo requires an embedded manifest")?; + anyhow::ensure!( + super::app_catalog::catalog_manifest_overlay(id, value).is_some(), + "Invalid node demo manifest" + ); + } + Ok(catalog) +} + +pub fn verified_body(data_dir: &Path) -> Result> { + let path = data_dir.join(FILE); + let file = match std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(&path) + { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let metadata = file.metadata()?; + anyhow::ensure!( + metadata.is_file() && metadata.len() <= LIMIT, + "Invalid node catalog file" + ); + let mut body = String::new(); + file.take(LIMIT + 1).read_to_string(&mut body)?; + anyhow::ensure!( + body.len() as u64 <= LIMIT, + "Node catalog exceeds size limit" + ); + let public_key = std::fs::read(data_dir.join("identity/node_key.pub"))?; + anyhow::ensure!(public_key.len() == 32, "Invalid local node identity"); + let node_did = crate::identity::did_key_from_pubkey_hex(&hex::encode(public_key))?; + validate(&serde_json::from_str(&body)?, &node_did)?; + Ok(Some(body)) +} + +pub fn entries(data_dir: &Path) -> HashMap { + match verified_body(data_dir) { + Ok(Some(body)) => serde_json::from_str::(&body) + .map(|catalog| catalog.apps) + .unwrap_or_default(), + Ok(None) => HashMap::new(), + Err(error) => { + tracing::warn!("Ignoring invalid node demo catalog: {error}"); + HashMap::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn signed(mut raw: Value, byte: u8) -> Value { + let anchor = ed25519_dalek::SigningKey::from_bytes(&[7; 32]); + std::env::set_var( + "ARCHY_RELEASE_ROOT_PUBKEY", + hex::encode(anchor.verifying_key().to_bytes()), + ); + let key = ed25519_dalek::SigningKey::from_bytes(&[byte; 32]); + let (sig, did) = crate::trust::signed_doc::sign_detached(&key, &raw).unwrap(); + raw["signature"] = sig.into(); + raw["signed_by"] = did.into(); + raw + } + fn fixture() -> Value { + serde_json::json!({"schema":1,"scope":"single-node-demo","target_node_did":"did:key:fixture", + "expires_at":(chrono::Utc::now()+chrono::Duration::days(1)).to_rfc3339(), + "apps":{"node-demo-v4v":{"version":"1","image":"docker.io/library/node:24-alpine", + "manifest":{"app":{"id":"node-demo-v4v","name":"Sovereign Music demo","version":"1", + "container":{"image":"docker.io/library/node:24-alpine"}}}}}}) + } + #[test] + fn audience_signature_expiry_namespace_and_manifest_are_required() { + assert!(validate(&signed(fixture(), 7), "did:key:fixture").is_ok()); + assert!(validate(&signed(fixture(), 7), "did:key:another").is_err()); + assert!(validate(&fixture(), "did:key:fixture").is_err()); + assert!(validate(&signed(fixture(), 11), "did:key:fixture").is_err()); + let mut tampered = signed(fixture(), 7); + tampered["apps"]["node-demo-v4v"]["version"] = "tampered".into(); + assert!(validate(&tampered, "did:key:fixture").is_err()); + let mut expired = fixture(); + expired["expires_at"] = "2020-01-01T00:00:00Z".into(); + assert!(validate(&signed(expired, 7), "did:key:fixture").is_err()); + let mut override_app = fixture(); + let entry = override_app["apps"] + .as_object_mut() + .unwrap() + .remove("node-demo-v4v") + .unwrap(); + override_app["apps"]["gitea"] = entry; + assert!(validate(&signed(override_app, 7), "did:key:fixture").is_err()); + let mut wrong_manifest = fixture(); + wrong_manifest["apps"]["node-demo-v4v"]["manifest"]["app"]["id"] = "node-demo-other".into(); + assert!(validate(&signed(wrong_manifest, 7), "did:key:fixture").is_err()); + } +} diff --git a/demos/node-demo-v4v/manifest.yml b/demos/node-demo-v4v/manifest.yml new file mode 100644 index 00000000..8fbfa7bf --- /dev/null +++ b/demos/node-demo-v4v/manifest.yml @@ -0,0 +1,91 @@ +# Intentionally outside apps/: this demo must never enter the public catalog. +# Publish only inside a release-signed single-node-demo catalog. +app: + id: node-demo-v4v + name: V4V · Sovereign Music + version: 0.6.7-alpha-archy1 + description: Listen to the original V4V demo catalog and explore sovereign music on this node. + category: media + container: + image: source.archipelago-foundation.org/lfg2025/v4v-demo:0.6.7-alpha-archy1 + generated_secrets: + - name: node-demo-v4v-session + kind: hex32 + - name: node-demo-v4v-receipts + kind: hex32 + secret_env: + - key: ALPHA_PASSWORD_HASH + secret_file: node-demo-v4v-password-hash + - key: SESSION_SECRET + secret_file: node-demo-v4v-session + - key: RECEIPT_SIGNING_SECRET + secret_file: node-demo-v4v-receipts + resources: + cpu_limit: 2 + memory_limit: 768Mi + security: + capabilities: [] + readonly_root: true + no_new_privileges: true + network_policy: bridge + ports: + - host: 7475 + container: 5181 + protocol: tcp + bind: 127.0.0.1 + auth: gated + volumes: + - type: volume + source: node-demo-v4v-data + target: /app/data + - type: volume + source: node-demo-v4v-media + target: /app/media + - type: tmpfs + target: /tmp + tmpfs_options: rw,noexec,nosuid,size=64m,mode=1777 + environment: + - NODE_ENV=production + - HOST=0.0.0.0 + - PORT=5181 + - PULSEWIRE_COMMIT=5bc5f61b + - PULSEWIRE_PASSWORD_LOGIN=on + - PULSEWIRE_PASSWORD_OPERATOR=on + - PULSEWIRE_SECURE_COOKIES=off + - PULSEWIRE_NOSTR_REGISTRATION=closed + - PULSEWIRE_STORE=json + - PULSEWIRE_STATE_BACKUP_DIR=data/backups + - PULSEWIRE_RATE_LIMIT_DB_FILE=data/rate-limits.db + - PULSEWIRE_JOBS_DB_FILE=data/jobs.db + - PULSEWIRE_FEED_INDEX_FILE=data/feed-index.db + - PULSEWIRE_FEED_FRONTIER_FILE=data/feed-frontier.db + - PULSEWIRE_LN_MODE=mock + - PULSEWIRE_LOG_FORMAT=json + - PULSEWIRE_ACCESS_LOG=on + - PULSEWIRE_DISCOVERY_DEMO_FIXTURES=on + health_check: + type: http + endpoint: http://127.0.0.1:5181 + path: /healthz + interval: 30s + timeout: 5s + retries: 3 + interfaces: + main: + name: V4V + description: Sovereign music demo + type: ui + port: 7475 + protocol: http + path: / + metadata: + icon: /assets/img/app-icons/v4v-demo.svg + author: V4V contributors + tier: optional + launch: + requires_host_frame: true + media_controls: archipelago-v1 + features: + - Original demo song catalog + - Background playback with dashboard controls + - Demo payments only diff --git a/docs/node-demo-catalog-and-media.md b/docs/node-demo-catalog-and-media.md new file mode 100644 index 00000000..f72cd797 --- /dev/null +++ b/docs/node-demo-catalog-and-media.md @@ -0,0 +1,84 @@ +# Node-scoped demo apps and persistent media + +Status: implementation under qualification; **not deployed or accepted**. + +The V4V demo is restricted to Yaya. The global catalog and other nodes must not +receive an install button or banner for this prototype. Its manifest lives in +`demos/node-demo-v4v/`, deliberately outside public `apps/` generation. + +## Catalog boundary + +A node may load `node-app-catalog.json` beside its normal catalog. This document +must carry a valid pinned release-root signature, `schema: 1`, +`scope: "single-node-demo"`, the exact `target_node_did`, and an unexpired +`expires_at`. Entries use the reserved `node-demo-` namespace, include validated +image manifests, and cannot replace existing catalog IDs. It is a separate +file; global signed bytes remain intact. No public mirror fetch or peer +redistribution is implemented for this file. + +The authenticated `/api/node-app-catalog` endpoint returns the original signed +bytes only after these checks. The dashboard combines these entries/promotions +for display without saving them into its normal browser fallback catalog. +Removal, invalid signatures, expiry or another node's DID remove that demo +listing. They do not erase installed app data. Installation still uses the +normal app manifest, image, port and lifecycle enforcement. + +Current activation: stage the signed file atomically, then restart the backend +to reload its manifest overlay. Automatic delivery of private catalog revisions +is not claimed. Qualification must test copying the file to a different node, +expiry, signature tampering, backend restart and preservation of public entries. + +## V4V app + +Source baseline: private V4V `demo-portainer` commit +`3ae171d6b0c728665a860520fe393c0abb772798`. Retain the original demo songs, +attribution and destinations. The demo keeps `PULSEWIRE_LN_MODE=mock` and its +existing password login. It does **not** inject Archipelago's native Nostr signer. +The media bridge is a distinct, non-signing integration. + +Candidate bridge source: `5bc5f61b`. Session and receipt secrets are generated +by the manifest only when missing. For this migration, preserve the existing +password hash and seed it privately as `node-demo-v4v-password-hash` before +installation. Missing credentials must fail installation; do not fall back to +the upstream shared password. General public first-run credential provisioning +is outside this node-only demo and must be implemented before a public listing. + +The login backdrop was captured from the running app's actual canvas in an +isolated browser context, with the form hidden. Use this asset for the node-only +“Sovereign Music” promotion. The public artifact can include the image; visibility +of the app/promotion is controlled by the scoped catalog. + +Before migration, back up the existing Portainer data/media volumes and secret +configuration privately. Qualify a separate copy first. Do not attach both live +containers to the same writable database. Keep the original stack and volumes +available for rollback until the managed replacement passes lifecycle checks. +No live V4V state or Portainer stack has been changed by this implementation yet. + +## Persistent player contract + +The app declares `metadata.launch.media_controls: archipelago-v1`. The dashboard +retains its iframe while hidden and controls that same player through messages; +it never copies a protected media URL into a second audio player. The app checks +the exact dashboard origin and parent window; the host checks the exact loaded +app origin/window and a per-session nonce. The protocol carries bounded title, +artist, time, duration and playback state, plus play/pause/seek/next/previous +controls. It contains no credentials, signer operations or payment commands. + +The bottom bar is hidden while the app player is open. Closing the app shows the +bar while audio continues; Open app reveals the retained session. Closing the +bar pauses playback and releases the hidden frame. Starting a Cloud track pauses +the app player. Late state from the paused player must not steal playback back. +Logout/unmount must release the frame and its state. + +Required remaining evidence: actual mounted iframe survives close/reopen, +mobile/desktop controls and layout, fresh install and copied-volume upgrade, +restart/rollback, native companion background/resume, real catalog audience +rejection on another node, and exact final artifact hashes. Unit tests alone +are insufficient for this acceptance. + +Qualification checkpoint: the dashboard suite passed 1,241 tests in 155 files. +The app bridge/player tests passed four tests, including pre-login connection, +origin/nonce rejection, locked controls and removal of metadata after relocking. +The isolated container reached HTTP health 200, then exposed the management +reaper's separate-storage ownership bug. Runtime acceptance is blocked on its +tested deployment; the old V4V image and live Portainer volumes are untouched. diff --git a/neode-ui/public/assets/img/app-icons/v4v-demo.svg b/neode-ui/public/assets/img/app-icons/v4v-demo.svg new file mode 100644 index 00000000..73d140f7 --- /dev/null +++ b/neode-ui/public/assets/img/app-icons/v4v-demo.svg @@ -0,0 +1,69 @@ + + v4v + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/neode-ui/public/assets/img/v4v-login-background.png b/neode-ui/public/assets/img/v4v-login-background.png new file mode 100644 index 00000000..abe9c059 Binary files /dev/null and b/neode-ui/public/assets/img/v4v-login-background.png differ diff --git a/neode-ui/src/components/GlobalAudioPlayer.vue b/neode-ui/src/components/GlobalAudioPlayer.vue index cbea60fb..4e339ca6 100644 --- a/neode-ui/src/components/GlobalAudioPlayer.vue +++ b/neode-ui/src/components/GlobalAudioPlayer.vue @@ -2,7 +2,7 @@
@@ -42,6 +42,8 @@

{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}

+ +