Merge #e970261b: Fix IndeeHub FIPS playback, terminal, and mesh chat UA…

Fix IndeeHub FIPS playback, terminal, and mesh chat UAT regressions

nostr:nevent1qqswjupxr00327qffnq9rqy98wzslt7lp9gtk0e9p5m5vus5f4tkhlqpz3mhxue69uhhyetvv9ujumn8d96zuer9wc0u8v0w

PR-Author: Personal
nostr:npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg

PR description:

Three-node UAT fixes for FIPS catalog/playback bridging, guarded free-stream policy, developer terminal runtime/proxy/UI, MeshCore public chat parsing, and Connected Nodes mobile navigation. Includes retained acceptance evidence and release gates.
This commit is contained in:
archipelago
2026-10-10 07:56:05 -04:00
23 changed files with 1050 additions and 67 deletions
@@ -443,15 +443,23 @@ private fun injectTopInset(view: WebView) {
return '';
}
function apply() {
// IndeeHub's browse hero is an image, not a body background.
// Padding the body puts a solid strip above the image; let the
// hero start at the display edge while push() keeps its fixed
// header below the status icons. Other routes keep the normal
// content inset.
var imageHero = location.port === '7778' &&
(location.pathname === '/' || location.pathname === '/browse') &&
!!document.querySelector('section img');
var bodyBg = document.body
? getComputedStyle(document.body).backgroundColor : '';
var eff = effectiveTopBg();
// Repaint body only when its declared background is not what
// the page actually renders at the top (or paints nothing).
var fix = eff && eff !== bodyBg
var fix = !imageHero && eff && eff !== bodyBg
? 'background-color:' + eff + ' !important;' : '';
styleEl().textContent =
'body{padding-top:' + SAT + 'px !important;' +
'body{padding-top:' + (imageHero ? 0 : SAT) + 'px !important;' +
'box-sizing:border-box !important;' + fix + '}';
}
function push(el) {
+498 -3
View File
@@ -24,7 +24,7 @@ use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
use crate::monitoring::MetricsStore;
use crate::session::{self, SessionStore};
use crate::state::StateManager;
use anyhow::Result;
use anyhow::{Context, Result};
use hyper::{Method, Request, Response, StatusCode};
use sha2::{Digest, Sha256};
use std::sync::Arc;
@@ -46,6 +46,43 @@ pub(super) fn build_response(
.unwrap_or_else(|_| Response::new(hyper::Body::from("Internal error")))
}
fn zero_sats(value: Option<&serde_json::Value>) -> bool {
match value {
Some(serde_json::Value::Number(number)) => number.as_u64() == Some(0),
Some(serde_json::Value::String(number)) => number.parse::<u64>() == Ok(0),
_ => false,
}
}
/// Public playback must be explicitly free in both the mutable project row and
/// any signed rental offer. A stale project price must never override signed
/// paid terms, and malformed offers fail closed.
fn indeehub_listing_is_publicly_free(listing: &serde_json::Value) -> bool {
if !zero_sats(listing.get("rentalPrice")) {
return false;
}
let Some(offer) = listing.get("offerEvent") else {
return true;
};
if offer.is_null() {
return true;
}
let Some(tags) = offer.get("tags").and_then(serde_json::Value::as_array) else {
return false;
};
let Some(terms) = tags.iter().find_map(|tag| {
let tag = tag.as_array()?;
(tag.first()?.as_str()? == "rental")
.then(|| tag.get(1)?.as_str())
.flatten()
}) else {
return false;
};
serde_json::from_str::<serde_json::Value>(terms)
.ok()
.is_some_and(|terms| zero_sats(terms.get("priceSats")))
}
pub struct ApiHandler {
config: Config,
rpc_handler: Arc<RpcHandler>,
@@ -225,6 +262,339 @@ impl ApiHandler {
))
}
/// Serve IndeeHub's sanitized public project catalog on the FIPS peer
/// listener. The app container remains on loopback; peers never receive
/// its session-gated port directly.
async fn handle_indeehub_public_catalog(&self) -> Result<Response<hyper::Body>> {
let response = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?
.get("http://127.0.0.1:7778/api/projects/public-catalog")
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await;
let response = match response {
Ok(response) => response,
Err(error) => {
tracing::warn!(%error, "IndeeHub public catalog loopback fetch failed");
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
));
}
};
if !response.status().is_success() {
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
));
}
let bytes = response.bytes().await?;
anyhow::ensure!(
bytes.len() <= 8 * 1024 * 1024,
"IndeeHub catalog exceeds limit"
);
let value: serde_json::Value =
serde_json::from_slice(&bytes).context("IndeeHub catalog is not valid JSON")?;
anyhow::ensure!(value.is_array(), "IndeeHub catalog must be an array");
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(bytes),
))
}
/// Resolve a free IndeeHub title to its local HLS manifest. IndeeHub's
/// optional-auth guard remains the authority: paid content returns 403.
async fn handle_indeehub_public_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
let id = path
.strip_prefix("/api/public-indeehub-stream/")
.unwrap_or("");
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid content id"}"#),
));
}
let catalog = reqwest::Client::new()
.get("http://127.0.0.1:7778/api/projects/public-catalog")
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?;
if !catalog.status().is_success() {
return Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(r#"{"error":"IndeeHub catalog unavailable"}"#),
));
}
let listings: serde_json::Value = catalog.json().await?;
let listing = listings.as_array().and_then(|items| {
items
.iter()
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some(id))
});
let Some(listing) = listing else {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"film is not publicly listed"}"#),
));
};
if !indeehub_listing_is_publicly_free(listing) {
return Ok(build_response(
StatusCode::PAYMENT_REQUIRED,
"application/json",
hyper::Body::from(r#"{"error":"rental required"}"#),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/api/contents/{id}/stream"))
.header(reqwest::header::ACCEPT, "application/json")
.send()
.await?;
let status = upstream.status();
let mut value: serde_json::Value = upstream
.json()
.await
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
if status.is_success() {
let content_id = value.get("id").and_then(|v| v.as_str()).unwrap_or(id);
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
if let Some(storage_path) = file.strip_prefix("/storage/") {
value["file"] = serde_json::json!(format!(
"/api/public-indeehub-media/{content_id}/{storage_path}"
));
}
}
}
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
}
/// Proxy encrypted HLS manifests and segments from IndeeHub's public
/// bucket. The manifest key URI is rebound to the guarded free-key route.
async fn handle_indeehub_public_media(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path
.strip_prefix("/api/public-indeehub-media/")
.unwrap_or("");
let Some((content_id, object)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid media path"),
));
};
let safe_id = !content_id.is_empty()
&& content_id
.chars()
.all(|c| c.is_ascii_hexdigit() || c == '-');
let safe_object = !object.is_empty()
&& !object.split('/').any(|part| part == "..")
&& object
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'));
if !safe_id || !safe_object {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid media path"),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/storage/{object}"))
.send()
.await?;
let status = upstream.status();
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_owned();
let mut bytes = upstream.bytes().await?.to_vec();
if object.ends_with(".m3u8") && status.is_success() {
let manifest = String::from_utf8(bytes)?;
bytes = manifest
.replace(
&format!("/api/contents/{content_id}/key"),
&format!("/api/public-indeehub-key/{content_id}"),
)
.into_bytes();
}
Ok(Response::builder()
.status(status)
.header("Content-Type", content_type)
.header(
"Cache-Control",
if object.ends_with(".m3u8") {
"no-store"
} else {
"public, max-age=86400"
},
)
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(bytes))?)
}
/// Fetch the HLS key through IndeeHub's access check. With no forwarded
/// user credentials, only genuinely free content can succeed here.
async fn handle_indeehub_public_key(&self, path: &str) -> Result<Response<hyper::Body>> {
let id = path.strip_prefix("/api/public-indeehub-key/").unwrap_or("");
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid content id"),
));
}
let upstream = reqwest::Client::new()
.get(format!("http://127.0.0.1:7778/api/contents/{id}/key"))
.send()
.await?;
let status = upstream.status();
let bytes = upstream.bytes().await?;
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/octet-stream")
.header("Cache-Control", "no-store")
.header("Access-Control-Allow-Origin", "*")
.body(hyper::Body::from(bytes))?)
}
async fn request_indeehub_peer(&self, onion: &str, path: &str) -> Result<reqwest::Response> {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let node = nodes
.iter()
.find(|node| node.onion == onion)
.context("IndeeHub peer is not connected")?;
let (response, _transport) =
crate::fips::dial::PeerRequest::new(node.fips_npub.as_deref(), &node.onion, path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(15))
.send_get()
.await?;
Ok(response)
}
/// Same-origin browser bridge for a connected peer's free stream. The
/// remote node still performs IndeeHub's free/paid authorization check.
async fn handle_indeehub_peer_stream(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path
.strip_prefix("/api/peer-indeehub-stream/")
.unwrap_or("");
let Some((onion, id)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid peer stream"}"#),
));
};
if id.is_empty() || !id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"invalid content id"}"#),
));
}
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-stream/{id}"))
.await?;
let status = upstream.status();
let mut value: serde_json::Value = upstream
.json()
.await
.unwrap_or_else(|_| serde_json::json!({"error":"stream unavailable"}));
if status.is_success() {
if let Some(file) = value.get("file").and_then(|v| v.as_str()) {
if let Some(remote_path) = file.strip_prefix("/api/public-indeehub-media/") {
value["file"] = serde_json::json!(format!(
"/api/peer-indeehub-media/{onion}/{remote_path}"
));
}
}
}
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(serde_json::to_vec(&value)?))?)
}
async fn handle_indeehub_peer_media(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path.strip_prefix("/api/peer-indeehub-media/").unwrap_or("");
let Some((onion, remote_path)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid peer media"),
));
};
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-media/{remote_path}"))
.await?;
let status = upstream.status();
let content_type = upstream
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_owned();
let mut bytes = upstream.bytes().await?.to_vec();
if remote_path.ends_with(".m3u8") && status.is_success() {
let manifest = String::from_utf8(bytes)?;
bytes = manifest
.replace(
"/api/public-indeehub-key/",
&format!("/api/peer-indeehub-key/{onion}/"),
)
.into_bytes();
}
Ok(Response::builder()
.status(status)
.header("Content-Type", content_type)
.header(
"Cache-Control",
if remote_path.ends_with(".m3u8") {
"no-store"
} else {
"public, max-age=86400"
},
)
.body(hyper::Body::from(bytes))?)
}
async fn handle_indeehub_peer_key(&self, path: &str) -> Result<Response<hyper::Body>> {
let tail = path.strip_prefix("/api/peer-indeehub-key/").unwrap_or("");
let Some((onion, id)) = tail.split_once('/') else {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("invalid peer key"),
));
};
let upstream = self
.request_indeehub_peer(onion, &format!("/api/public-indeehub-key/{id}"))
.await?;
let status = upstream.status();
let bytes = upstream.bytes().await?;
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/octet-stream")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(bytes))?)
}
/// Serve an encrypted backup archive (`<data_dir>/backups/<id>.bak`) as a
/// browser download. The archive is passphrase-encrypted at rest; the
/// session gate at the route controls who can fetch it.
@@ -338,6 +708,20 @@ impl ApiHandler {
origin == format!("http://{host}") || origin == format!("https://{host}")
}
// Browsers do not send Origin on a same-origin GET. Fetch Metadata is a
// browser-controlled header and distinguishes the dashboard from an app on
// another port, which is only same-site. Mutating requests and WebSockets
// still require the exact Origin above.
fn has_terminal_read_origin(headers: &hyper::HeaderMap) -> bool {
if headers.contains_key(hyper::header::ORIGIN) {
return Self::has_terminal_origin(headers);
}
headers
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
== Some("same-origin")
}
/// Permissive origin check for the share-to-mesh iframe intent: any scheme
/// http(s):// followed by the configured host_ip, optionally `:port`. Apps
/// proxied under other ports (APP_PORTS) call this from within the same
@@ -533,7 +917,25 @@ impl ApiHandler {
match (method, path.as_str()) {
// RPC — auth is handled inside rpc handler per-method
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
(Method::POST, "/rpc/v1") => {
let origin = self.app_cors_origin(&headers);
let mut response = self.rpc_handler.clone().handle(req_with_bytes).await?;
if !origin.is_empty() {
response.headers_mut().insert(
"access-control-allow-origin",
hyper::header::HeaderValue::from_str(&origin)?,
);
response.headers_mut().insert(
"access-control-allow-credentials",
hyper::header::HeaderValue::from_static("true"),
);
response.headers_mut().insert(
hyper::header::VARY,
hyper::header::HeaderValue::from_static("Origin"),
);
}
Ok(response)
}
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
// replacing the unauthenticated claude-api-proxy.py sidecar and
@@ -586,7 +988,7 @@ impl ApiHandler {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
if !Self::has_terminal_origin(&headers) {
if !Self::has_terminal_read_origin(&headers) {
return Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
@@ -763,6 +1165,39 @@ impl ApiHandler {
.body(hyper::Body::from(body))?)
}
// Public IndeeHub metadata is also exposed on the FIPS peer
// listener, whose path filter is the only ingress gate here.
(Method::GET, "/api/public-catalog/indeedhub") => {
self.handle_indeehub_public_catalog().await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-stream/") => {
self.handle_indeehub_public_stream(p).await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-media/") => {
self.handle_indeehub_public_media(p).await
}
(Method::GET, p) if p.starts_with("/api/public-indeehub-key/") => {
self.handle_indeehub_public_key(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-stream/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_stream(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-media/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_media(p).await
}
(Method::GET, p) if p.starts_with("/api/peer-indeehub-key/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
self.handle_indeehub_peer_key(p).await
}
(Method::GET, "/api/app-catalog") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
@@ -939,6 +1374,18 @@ mod terminal_origin_tests {
ApiHandler::has_terminal_origin(&headers)
}
fn read_allowed(host: &str, origin: Option<&str>, fetch_site: Option<&str>) -> bool {
let mut headers = HeaderMap::new();
headers.insert(HOST, HeaderValue::from_str(host).unwrap());
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
if let Some(fetch_site) = fetch_site {
headers.insert("sec-fetch-site", HeaderValue::from_str(fetch_site).unwrap());
}
ApiHandler::has_terminal_read_origin(&headers)
}
#[test]
fn terminal_accepts_only_the_exact_dashboard_authority() {
assert!(allowed("node.local", Some("https://node.local")));
@@ -949,4 +1396,52 @@ mod terminal_origin_tests {
assert!(!allowed("node.local", Some("null")));
assert!(!allowed("node.local", None));
}
#[test]
fn terminal_read_accepts_same_origin_fetch_but_not_same_site_apps() {
assert!(read_allowed("node.local", None, Some("same-origin")));
assert!(!read_allowed("node.local", None, Some("same-site")));
assert!(!read_allowed("node.local", None, Some("cross-site")));
assert!(!read_allowed("node.local", None, None));
assert!(!read_allowed(
"node.local",
Some("https://evil.example"),
Some("same-site")
));
assert!(!read_allowed(
"node.local",
Some("https://evil.example"),
Some("same-origin")
));
}
}
#[cfg(test)]
mod indeehub_public_stream_tests {
use super::indeehub_listing_is_publicly_free;
use serde_json::json;
#[test]
fn public_stream_requires_explicitly_free_project_and_signed_terms() {
assert!(indeehub_listing_is_publicly_free(&json!({
"rentalPrice": 0,
"offerEvent": null
})));
assert!(indeehub_listing_is_publicly_free(&json!({
"rentalPrice": "0",
"offerEvent": {"tags": [["rental", "{\"priceSats\":0}"]]}
})));
assert!(!indeehub_listing_is_publicly_free(&json!({
"rentalPrice": 15,
"offerEvent": null
})));
assert!(!indeehub_listing_is_publicly_free(&json!({
"rentalPrice": 0,
"offerEvent": {"tags": [["rental", "{\"priceSats\":15}"]]}
})));
assert!(!indeehub_listing_is_publicly_free(&json!({
"rentalPrice": 0,
"offerEvent": {"tags": [["rental", "invalid"]]}
})));
}
}
+13 -2
View File
@@ -178,10 +178,21 @@ pub(crate) async fn create(body: &[u8]) -> Result<Response<hyper::Body>> {
}
let id = format!("s-{}", Uuid::new_v4().simple());
let tmux_name = format!("archy-{id}");
let output = Command::new("tmux")
let output = match Command::new("tmux")
.args(["new-session", "-d", "-s", &tmux_name, "-c", &workspace])
.output()
.await?;
.await
{
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(r#"{"error":"Terminal runtime is not installed on this node"}"#),
));
}
Err(error) => return Err(error.into()),
};
if !output.status.success() {
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
+70
View File
@@ -1418,6 +1418,76 @@ impl RpcHandler {
}))
}
/// Browse IndeeHub's public film catalogs through the native peer bridge.
/// The bridge selects FIPS first (with the normal safe fallback policy),
/// so the browser never needs a peer LAN address or an onion connection.
pub(super) async fn handle_content_browse_indeehub_peers(&self) -> Result<serde_json::Value> {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let peers: Vec<_> = nodes
.into_iter()
.filter(|node| is_valid_v3_onion(&node.onion))
.collect();
let calls = peers.into_iter().map(|node| async move {
let onion = node.onion.clone();
let request_onion = onion.clone();
let fips = node.fips_npub.clone();
let result = tokio::time::timeout(std::time::Duration::from_secs(12), async move {
let (response, transport) = crate::fips::dial::PeerRequest::new(
fips.as_deref(),
&request_onion,
"/api/public-catalog/indeedhub",
)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(10))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await?;
if !response.status().is_success() {
anyhow::bail!("peer returned {}", response.status());
}
let listings: serde_json::Value = response
.json()
.await
.context("invalid IndeeHub peer catalog")?;
Ok::<_, anyhow::Error>((listings, transport))
})
.await
.ok()
.and_then(Result::ok);
(onion, result)
});
let mut items = Vec::new();
let mut reached = 0usize;
for (onion, result) in futures_util::future::join_all(calls).await {
let Some((listings, transport)) = result else {
continue;
};
let Some(listings) = listings.as_array() else {
continue;
};
reached += 1;
for listing in listings {
let Some(mut listing) = listing.as_object().cloned() else {
continue;
};
listing.insert("peer".to_string(), serde_json::json!(onion));
listing.insert(
"transport".to_string(),
serde_json::json!(transport.to_string()),
);
items.push(serde_json::Value::Object(listing));
}
}
Ok(serde_json::json!({
"items": items,
"peers_reached": reached,
"peers_total": reached,
"partial": false,
}))
}
/// `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(
@@ -354,6 +354,7 @@ impl RpcHandler {
"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.browse-indeehub-peers" => self.handle_content_browse_indeehub_peers().await,
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
"content.playback-start" => self.handle_playback_start(params, session_token).await,
+5 -1
View File
@@ -146,7 +146,11 @@ impl RpcHandler {
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(20))
.send_content_json(&self.config.data_dir, &binding.contract.seller_did, &body)
.send_rental_control_json(
&self.config.data_dir,
&binding.contract.seller_did,
&body,
)
.await?;
anyhow::ensure!(
response.status().is_success(),
+69 -1
View File
@@ -35,6 +35,23 @@ use tokio::net::UdpSocket;
/// path filter can restrict the exposed surface.
pub const PEER_PORT: u16 = 5679;
fn is_rental_control_route(path: &str) -> bool {
let parts: Vec<_> = path.split('/').collect();
let valid_id = |value: &str| {
!value.is_empty()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
};
parts.len() == 6
&& parts[0].is_empty()
&& parts[1] == "content"
&& valid_id(parts[2])
&& parts[3] == "rental"
&& valid_id(parts[4])
&& matches!(parts[5], "prepare" | "start")
}
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
/// doesn't expose that path (e.g. a peer on an older build with a stricter
@@ -527,8 +544,27 @@ impl<'a> PeerRequest<'a> {
request.send_encoded_json(&encoded).await
}
/// Rental preparation/start carries an already-settled capability, never a
/// payment token. Authenticate the exact control route without relaxing the
/// purchase-only guard above or replaying any purchase on a retry.
pub(crate) async fn send_rental_control_json<B: serde::Serialize>(
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
anyhow::ensure!(
is_rental_control_route(self.path),
"Not a rental control route"
);
let (request, encoded) = self
.prepare_authenticated_content_json(data_dir, expected_seller, body)
.await?;
request.send_encoded_json(&encoded).await
}
async fn prepare_content_json<B: serde::Serialize>(
mut self,
self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
@@ -537,6 +573,16 @@ impl<'a> PeerRequest<'a> {
self.path.starts_with("/content/purchase/"),
"Not a purchase route"
);
self.prepare_authenticated_content_json(data_dir, expected_seller, body)
.await
}
async fn prepare_authenticated_content_json<B: serde::Serialize>(
mut self,
data_dir: &std::path::Path,
expected_seller: &str,
body: &B,
) -> Result<(Self, Vec<u8>)> {
let peer = crate::federation::load_unique_payment_peer(data_dir, self.onion_host).await?;
anyhow::ensure!(
peer.did == expected_seller,
@@ -887,6 +933,28 @@ impl<'a> PeerRequest<'a> {
mod tests {
use super::*;
#[test]
fn rental_control_routes_are_exact_and_cannot_be_purchase_routes() {
assert!(is_rental_control_route(
"/content/registered_abc/rental/1234-5678/prepare"
));
assert!(is_rental_control_route(
"/content/registered_abc/rental/1234-5678/start"
));
for path in [
"/content/purchase/abc",
"/content/registered_abc/rental/1234-5678/settle",
"/content/registered_abc/rental/1234-5678/prepare/extra",
"/content/../rental/1234-5678/prepare",
"/content/registered_abc/rental/1234-5678/prepare?token=x",
] {
assert!(
!is_rental_control_route(path),
"unexpected rental control route: {path}"
);
}
}
#[tokio::test]
async fn purchase_post_serializes_once_and_binds_the_actual_bytes_to_the_peer() {
use std::sync::atomic::{AtomicUsize, Ordering};
+30 -11
View File
@@ -551,17 +551,19 @@ pub fn parse_contact_msg_v3(data: &[u8]) -> Result<(String, String, i8)> {
}
/// Parse RESP_CHANNEL_MSG_V3 (0x11) - channel message.
/// Format: channel_idx(1B) + path_len(1B) + txt_type(1B) + timestamp(4B) + text
/// Format after the response code has been removed:
/// SNR(1B) + reserved(2B) + channel_idx(1B) + path_len(1B) +
/// txt_type(1B) + timestamp(4B) + text.
/// Returns (channel_idx, text).
pub fn parse_channel_msg_v3(data: &[u8]) -> Result<(u8, String)> {
if data.len() < 7 {
if data.len() < 10 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
// data[1] = path_len, data[2] = txt_type
// data[3..7] = timestamp
let text = if data.len() > 7 {
String::from_utf8_lossy(&data[7..])
let channel_idx = data[3];
// data[0] = SNR, data[1..3] = reserved, data[4] = path_len,
// data[5] = txt_type, data[6..10] = timestamp.
let text = if data.len() > 10 {
String::from_utf8_lossy(&data[10..])
.trim_end_matches('\0')
.to_string()
} else {
@@ -649,12 +651,12 @@ pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
/// Returns (channel_idx, raw_payload_bytes).
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
if data.len() < 7 {
if data.len() < 10 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
let payload = if data.len() > 7 {
let mut p = data[7..].to_vec();
let channel_idx = data[3];
let payload = if data.len() > 10 {
let mut p = data[10..].to_vec();
// Strip trailing NUL bytes
while p.last() == Some(&0) {
p.pop();
@@ -779,6 +781,23 @@ pub fn parse_identity_broadcast(msg: &str) -> Option<(String, String, String)> {
mod tests {
use super::*;
#[test]
fn parses_v3_channel_after_snr_and_reserved_prefix() -> Result<()> {
// decode_frame removes the 0x11 response code before this parser sees
// the payload. A real V3 frame then begins with SNR + two reserved
// bytes; treating SNR as the channel index filed public messages under
// random channels such as 47 or 208.
let data = [
0xd0, 0x00, 0x00, // SNR and reserved
0x00, 0xff, 0x00, // public channel, path, text type
0x78, 0x56, 0x34, 0x12, // timestamp
b'h', b'e', b'l', b'l', b'o', 0x00,
];
assert_eq!(parse_channel_msg_v3(&data)?, (0, "hello".to_string()));
assert_eq!(parse_channel_msg_v3_raw(&data)?, (0, b"hello".to_vec()));
Ok(())
}
#[test]
fn test_encode_frame() {
let frame = encode_frame(&[CMD_DEVICE_QUERY, PROTOCOL_VERSION]);
+16
View File
@@ -1468,6 +1468,9 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
| "/archipelago/mesh-typed"
| "/dwn"
| "/transport/inbox"
// IndeeHub public metadata is loopback-fetched by the native
// handler; the app port itself is never exposed on FIPS.
| "/api/public-catalog/indeedhub"
// Content *catalog* — the peer-browse entry point. This is the
// exact path `/content` (no trailing slash); the prefix match
// below only covers `/content/<id>` item fetches, so without
@@ -1484,6 +1487,11 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
|| path.starts_with("/blob/")
// DWN sync — /dwn/health is step 1 of every sync; same story.
|| path.starts_with("/dwn/")
// Free IndeeHub playback. Stream and key authorization is delegated
// to IndeeHub; encrypted public segments are safe to proxy verbatim.
|| path.starts_with("/api/public-indeehub-stream/")
|| path.starts_with("/api/public-indeehub-media/")
|| path.starts_with("/api/public-indeehub-key/")
}
/// The ordinary API listener is a management surface even when contacted
@@ -2419,12 +2427,20 @@ mod merge_tests {
// 100% Tor by construction.
assert!(is_peer_allowed_path("/blob/abc123"), "blob fetch by CID");
assert!(is_peer_allowed_path("/dwn/health"), "DWN sync step 1");
assert!(is_peer_allowed_path("/api/public-indeehub-stream/1234",));
assert!(is_peer_allowed_path(
"/api/public-indeehub-media/1234/projects/film/transcoded/seg_000.ts",
));
assert!(is_peer_allowed_path("/api/public-indeehub-key/1234"));
// Not on the allow-list → rejected (no broad surface over the mesh).
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/"));
assert!(!is_peer_allowed_path("/rpc/v2"));
assert!(!is_peer_allowed_path("/blobber"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/dwnx"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/api/public-indeehub-stream"));
assert!(!is_peer_allowed_path("/api/public-indeehub-media"));
assert!(!is_peer_allowed_path("/api/public-indeehub-key"));
}
#[test]
+75
View File
@@ -42,6 +42,56 @@ acceptance; this is a new paid-file incident.
## Current tasks
- 2026-10-10 three-node IndeeHub/terminal UAT checkpoint: Yaya, Framework and
Shorty serve byte-identical IndeeHub entry pages
(`0a2ef3393aacd96d834ad4666d4aa4c64c5fe0cc28f973351cfbc73a78156e7a`),
dashboard entry pages
(`b41a8d3c87ba923c9a4e5f7d0a61dafac593a3438510482d9e6b85a53bb63c9f`)
and management binaries
(`daf39c71b7029c0b51f8b74cc146fb3dd9af287744856e0e6a63bb63b40829ab`).
All manager health endpoints report `ok` with crash recovery complete.
Bitcoin/LND container IDs remained exact across the manager restarts. The
canonical runtime Nginx source is identical on all three nodes at
`524fd418ce71c6399f2772f7a105a968254e9a6414c96e45376422c414c18658`;
this matters because manager bootstrap otherwise restored an older runtime
template and removed terminal/playback routes on restart.
Terminal creation and authenticated listing pass on Yaya and Framework.
The live failure was missing `tmux`, after routing and origin failures had
separately been corrected. `tmux` is installed on all three UAT nodes and is
now required by both ISO installation paths. Shorty's proxy/runtime is
installed and Nginx-valid, but authenticated creation was not accepted because
its dashboard password differs; keep that as unverified rather than a pass.
Test-created Yaya/Framework session records were moved recoverably under the
support directory after acceptance and no longer appear in the session list.
The UI now includes actual response details for create failures instead of the
generic message, and its empty state occupies and centers within the full
terminal viewport. That dashboard build is byte-identical on all three nodes.
Cross-node public film discovery now works through FIPS in both directions:
Yaya receives Framework's *Arch x Indie* and Framework receives Yaya's
*Web5 Explained*. Framework's explicitly free film returned a JSON stream,
HLS manifest, 16-byte AES key and 1,736,192-byte MPEG-TS segment through the
Yaya same-origin bridge. A negative test found Yaya's project row said zero
while its signed offer required 15 sats; the first bridge build would have
treated that mismatch as free. The deployed guard now requires both project
data and signed terms to be explicitly zero. Framework receives HTTP 402
`rental required` and no stream URL for that title. Never weaken this guard to
make a paid playback test pass. Paid Cashu/Lightning rental, metered ecash,
entitlement recovery without another payment, and the intended native-identity
replacement for the old `0494` offer remain open end-to-end acceptance gates.
MeshCore V3 parsing now reads the channel after SNR/reserved bytes. The UI
recovers already-persisted impossible MeshCore channel IDs into Public while
preserving valid channels and other transports. Connected Nodes uses `Map`,
with full-width My connections/Find Nodes controls on mobile. Focused frontend
tests (8), TypeScript, production build, terminal packaging regression and
formatting pass. Three targeted backend regressions pass through the isolated
runner: guarded public streaming, MeshCore V3 channel parsing, and the peer
content path filter. Source review/commit, ngit proposal disposition, exact
main mirroring and a complete mirror audit remain required before OTA/ISO. No
OTA or ISO was published by this UAT deployment.
- 2026-10-09 dual-node UAT correction: Archy `main` at `5b0b35401` was already
serving identical backend and dashboard artifacts on Yaya and Framework;
browser cache hid the new device-model screens. Yaya's terminal failed
@@ -1794,3 +1844,28 @@ isolated runner. The full isolated suite, production backend build, another
explicit native update, post-update runtime/data proofs, and paired browser
acceptance remain pending. No wallet, payment, personal-media or profile changes
are part of this readiness correction.
## 2026-10-09: two-node IndeeHub UAT — open acceptance items
Yaya and Framework now have matching UAT frontend bundles, but this is **not**
feature acceptance or release publication. The backstage `indeehub.com/watch/`
slug control has been removed from the active bundle. The local My List button
now calls the library API instead of only toggling its icon; signed-in browser
acceptance is pending. Cross-node saves remain open because the library API is
local-project-only. Cross-node comments, reactions and zaps remain open: social
events use node-origin-specific content IDs and each `/relay` requires that
node's login. Do not treat cross-node public film listing as social federation.
Framework logged `content.playback-prepare: Not a purchase route` after a Cashu
rental attempt. The buyer sent a rental-control path through a purchase-only
transport guard. A narrow authenticated rental-control route is implemented;
the release binary compiled, its route unit test passed through the isolated
runner, and the identical binary is deployed on both UAT nodes with healthy
RPC and session services. An existing-purchase retry without another payment
remains pending. The earlier
Framework LND startup fix does not close this paid-file recovery incident.
Lightning purchase/zap still lacks configured BTCPay credentials on both UAT
nodes; the API now reports service unavailable instead of an opaque Invalid URL,
but payment is not accepted. Framework's listed film has no verified media
registration or signed stream offer; listing is not proof of playable media.
No OTA, ISO or public release follows from these UAT changes.
@@ -386,6 +386,7 @@ DOCKERFILE_HEAD
python3 \
curl \
git \
tmux \
vim-tiny \
nano \
ca-certificates \
@@ -178,6 +178,7 @@ chroot /mnt/archipelago apt-get install -y \
curl \
wget \
htop \
tmux \
vim-tiny \
nano \
ca-certificates \
+84 -2
View File
@@ -37,6 +37,21 @@ map "$archy_management_private_source:$archy_management_private_client:$archy_ma
}
# END ARCHIPELAGO MANAGEMENT SOURCE GUARD
# Only IndeeHub on this same node may read the owner's FileBrowser API for
# its in-app Cloud picker. Never reflect arbitrary cross-port origins.
map "$host|$http_origin" $archy_cloud_picker_origin {
default "";
"~^([^|]+)\|http://\1:7778$" $http_origin;
}
map "$request_method|$http_sec_fetch_site|$http_origin" $archy_terminal_origin {
default $http_origin;
~^GET\|same-origin\| "$scheme://$http_host";
# Older WebViews can omit both Origin and Fetch Metadata on same-origin
# GETs. Only synthesize in that header-free case; a cross-origin request
# carrying Origin still reaches the backend unchanged and is rejected.
"GET||" "$scheme://$http_host";
}
# Rate limit zones
limit_req_zone $binary_remote_addr zone=rpc:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=3r/s;
@@ -284,7 +299,20 @@ server {
location ^~ /api/terminal/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_set_header Origin $archy_terminal_origin;
proxy_buffering off;
}
# Browser-facing bridge for IndeeHub streams hosted by connected nodes.
# The backend enforces the owner session on peer routes and free-content
# policy on public routes; keep these ahead of the dashboard SPA fallback.
location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_buffering off;
@@ -326,6 +354,12 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Browser-mediated Cloud selection uses the existing node session.
# Only this node's own IndeeHub origin is admitted by the map above.
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
add_header Access-Control-Allow-Credentials "true" always;
# Limit request body to 1MB for RPC calls
client_max_body_size 1m;
@@ -390,6 +424,24 @@ server {
error_page 504 = @backend_timeout;
}
# Public, sanitized IndeeHub listings only. The upstream is the direct
# loopback app port; video, keys and payments remain behind the app gate.
location = /api/public-catalog/indeedhub {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
proxy_set_header Host $host;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Cache-Control;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=30" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Pine node status — live node facts for the Pine launcher page and the
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
# backend; nginx only forwards.
@@ -614,6 +666,8 @@ server {
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
add_header Access-Control-Allow-Origin $archy_cloud_picker_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header X-Content-Type-Options "nosniff" always;
proxy_request_buffering off;
proxy_set_header Accept-Encoding "";
@@ -1271,7 +1325,17 @@ server {
location ^~ /api/terminal/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_set_header Origin $archy_terminal_origin;
proxy_buffering off;
}
location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie $http_cookie;
proxy_buffering off;
@@ -1326,6 +1390,24 @@ server {
error_page 504 = @backend_timeout;
}
# Public, sanitized IndeeHub listings only. The upstream is the direct
# loopback app port; video, keys and payments remain behind the app gate.
location = /api/public-catalog/indeedhub {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:7778/api/projects/public-catalog;
proxy_set_header Host $host;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Credentials;
proxy_hide_header Cache-Control;
proxy_connect_timeout 5s;
proxy_read_timeout 10s;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "public, max-age=30" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Pine node status — live node facts for the Pine launcher page and the
# seeded Home Assistant sensors. Sensitive fields are token-gated at the
# backend; nginx only forwards.
+5 -2
View File
@@ -88,7 +88,10 @@ async function createSession() {
error.value = ''
try {
const response = await fetch('/api/terminal/sessions', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Archipelago work' }) })
if (!response.ok) throw new Error('Could not create a developer session.')
if (!response.ok) {
const payload = await response.json().catch(() => null) as { error?: string } | null
throw new Error(payload?.error || `Could not create a developer session (${response.status}).`)
}
const session = await response.json()
sessions.value = [session, ...sessions.value]
selectedId.value = session.id
@@ -215,6 +218,6 @@ onBeforeUnmount(() => {
.terminal-select, .terminal-icon-button, .terminal-button { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); color: white; padding: 0 9px; font-size: 12px; }
.terminal-icon-button:hover, .terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); }
.terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; }
.terminal-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
.terminal-empty { display: flex; flex: 1 1 100%; width: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
@media (max-width: 640px) { .terminal-panel { width: calc(100vw - 1rem); height: calc(100vh - 1rem); max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); } .terminal-header { align-items: flex-start; flex-direction: column; } .terminal-actions { width: 100%; flex-wrap: wrap; } .terminal-select { flex: 1; min-width: 0; } .terminal-status { display: none; } }
</style>
@@ -1,14 +1,19 @@
<template>
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/70 p-3">
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-md overflow-y-auto rounded-2xl p-5 text-white">
<p class="text-xs text-white/50">IndeeHub · Your node wallet</p>
<h2 class="mt-2 break-words text-xl font-semibold">{{ request.title }}</h2>
<dl class="my-4 space-y-2 text-sm"><div>Rental: {{ request.terms.priceSats }} sats</div><div>Viewing period: {{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes after you press Start</div><template v-if="quote"><div>Payment: Cashu · {{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</div><div class="break-all">Mint: {{ quote.mint_url }}</div></template><div v-if="quote" class="font-semibold">Total wallet debit: {{ quote.wallet_debit_sats }} sats</div></dl>
<p class="text-sm text-white/60">Review the exact wallet debit before paying. If a response is lost, reopen this title to recover the same purchase.</p>
<p v-if="error" role="alert" class="mt-3 break-words text-sm text-red-200">{{ error }}</p>
<p v-if="busy" role="status" class="mt-4 text-sm">{{ phase === 'preparing' ? progress : phase === 'paying' ? 'Recovering or completing your payment…' : 'Checking the original purchase and current fees…' }}</p>
<button type="button" v-if="quote" :disabled="busy" class="mt-3 min-h-11 w-full rounded-lg border border-white/20 text-sm disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
<footer class="mt-5 flex flex-col gap-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-3" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg px-3 text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats` : 'Check purchase' }}</button></footer>
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/80 p-3 backdrop-blur-sm">
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-lg overflow-y-auto rounded-2xl border border-white/15 p-5 text-white shadow-2xl sm:p-7">
<header class="flex items-start gap-3">
<div class="min-w-0 flex-1"><p class="text-xs uppercase tracking-widest text-white/45">IndeeHub · Node rental</p><h2 class="mt-2 break-words text-2xl font-bold">{{ request.title }}</h2></div>
<button type="button" aria-label="Close rental" class="min-h-11 min-w-11 rounded-lg text-white/60 hover:bg-white/10 hover:text-white" @click="$emit('cancel')">✕</button>
</header>
<div class="my-6 rounded-xl border border-white/10 bg-white/5 p-5">
<div class="flex items-start justify-between gap-4"><div><h3 class="text-lg font-semibold">Rental details</h3><p class="mt-1 text-sm text-white/60">{{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes from when you press Start viewing</p></div><div class="shrink-0 text-right"><p class="text-2xl font-bold">{{ request.terms.priceSats.toLocaleString() }} <span class="text-sm font-normal text-white/55">sats</span></p><p class="text-xs text-white/50">Film price</p></div></div>
<div v-if="quote" class="mt-5 space-y-2 border-t border-white/10 pt-4 text-sm"><div class="flex justify-between gap-3"><span class="text-white/60">Payment method</span><span>Node wallet · Cashu</span></div><div class="flex justify-between gap-3"><span class="text-white/60">Network</span><span>{{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</span></div><div class="flex justify-between gap-3 border-t border-white/10 pt-3 font-semibold"><span>Total wallet debit, including fees</span><span>{{ quote.wallet_debit_sats.toLocaleString() }} sats</span></div><details class="pt-2 text-xs text-white/45"><summary class="cursor-pointer">Mint details</summary><p class="mt-2 break-all">{{ quote.mint_url }}</p></details></div>
</div>
<p class="text-sm leading-relaxed text-white/60">No viewing time starts until you press Start viewing. Reopening this title recovers the same purchase; it must not charge you twice.</p>
<p v-if="error" role="alert" class="mt-4 rounded-lg border border-red-400/25 bg-red-500/10 p-3 break-words text-sm text-red-200">{{ error }}</p>
<p v-if="busy" role="status" class="mt-4 text-sm text-white/70">{{ phase === 'preparing' ? progress : phase === 'paying' ? 'Completing or recovering the original payment…' : 'Checking the original purchase and current fees…' }}</p>
<button type="button" v-if="quote" :disabled="busy" class="mt-4 min-h-11 w-full rounded-lg border border-white/20 text-sm text-white/70 disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
<footer class="mt-6 flex flex-col-reverse gap-3 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-4" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg border border-orange-400/30 px-4 font-semibold text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats from node wallet` : 'Check purchase' }}</button></footer>
</section>
</div>
</template>
+20 -14
View File
@@ -20,6 +20,7 @@ import MediaLightbox from '@/components/cloud/MediaLightbox.vue'
import type { FileBrowserItem } from '@/api/filebrowser-client'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import { channelContactId, isChannelMessage, isPublicChannelMessage } from '@/views/mesh/channelMessages'
import '@/views/mesh/mesh-styles.css'
const mesh = useMeshStore()
@@ -78,18 +79,18 @@ let pollInterval: ReturnType<typeof setInterval> | null = null
let wsUnsub: (() => void) | null = null
// The Public channel (always available on Meshcore)
// "Public" maps to meshcore slot 1 — the configured "archipelago" channel
// in mesh-config.json. Slot 0 is the firmware default Public, which uses
// the universal meshcore key and only works between devices sharing keys
// + RF region. Slot 1 is set by archipelago first-boot to a hash derived
// from the channel_name, so all archipelago nodes are guaranteed on the
// same channel regardless of region.
// "Public" is the stock MeshCore/Meshtastic public channel in slot 0.
const publicChannel = { index: 0, name: 'Public' }
// Channel contact_id convention: matches backend u32::MAX - channel_index
function channelContactId(channelIndex: number): number {
return 4294967295 - channelIndex // u32::MAX - index
}
const publicChannelUnread = computed(() => {
const contactIds = new Set([
channelContactId(publicChannel.index),
...mesh.messages.filter(isPublicChannelMessage).map(message => message.peer_contact_id),
])
return Object.entries(mesh.unreadCounts)
.filter(([contactId]) => contactIds.has(Number(contactId)))
.reduce((total, [, count]) => total + count, 0)
})
// Archipelago Channel — Tor-based messaging to all federated/peered nodes
const archChannelActive = ref(false)
@@ -714,8 +715,10 @@ const chatMessages = computed(() => {
const HIDDEN_TYPES = new Set(['reaction', 'read_receipt', 'edit', 'presence', 'channel_invite', 'contact_card'])
const hideReactions = (m: MeshMessage) => !m.message_type || !HIDDEN_TYPES.has(m.message_type)
if (activeChatChannel.value) {
const chanId = channelContactId(activeChatChannel.value.index)
return mesh.messages.filter(m => m.peer_contact_id === chanId && hideReactions(m))
return mesh.messages.filter(m => {
const belongsToChannel = isChannelMessage(m, activeChatChannel.value!.index)
return belongsToChannel && hideReactions(m)
})
}
if (activeChatPeer.value) {
// Pull from every underlying contact_id in the merged group so radio
@@ -1134,7 +1137,10 @@ function openChannelChat(channel: { index: number; name: string }) {
messageText.value = ''
activeTab.value = 'chat'
mobileShowChat.value = true
mesh.markChatRead(channelContactId(channel.index))
const contactIds = channel.index === publicChannel.index
? mesh.messages.filter(isPublicChannelMessage).map(message => message.peer_contact_id)
: [channelContactId(channel.index)]
mesh.markChatRead([...new Set([channelContactId(channel.index), ...contactIds])])
nextTick(() => scrollChatToBottom())
}
@@ -2275,7 +2281,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<div class="mesh-peer-name">Public</div>
<div class="mesh-peer-sub">Mesh radio</div>
</div>
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
<span v-if="publicChannelUnread" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ publicChannelUnread }}</span>
</div>
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
No contacts match “{{ peerSearch.trim() }}”.
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import type { MeshMessage } from '@/stores/mesh'
import { channelContactId, isChannelMessage, isPublicChannelMessage } from '../channelMessages'
function message(peerContactId: number, transport = 'meshcore'): MeshMessage {
return {
id: 1,
direction: 'received',
peer_contact_id: peerContactId,
peer_name: null,
plaintext: 'hello',
timestamp: '2026-10-10T00:00:00Z',
delivered: true,
encrypted: false,
transport,
message_type: 'text',
typed_payload: null,
sender_pubkey: null,
sender_seq: null,
}
}
describe('Mesh public-channel compatibility', () => {
it('shows a correctly decoded slot-zero message in Public', () => {
expect(isPublicChannelMessage(message(channelContactId(0)))).toBe(true)
})
it('recovers persisted V3 messages whose SNR was mistaken for a channel index', () => {
expect(isPublicChannelMessage(message(channelContactId(47)))).toBe(true)
expect(isPublicChannelMessage(message(channelContactId(208)))).toBe(true)
})
it('does not merge valid non-public slots or another transport into Public', () => {
expect(isPublicChannelMessage(message(channelContactId(1)))).toBe(false)
expect(isPublicChannelMessage(message(channelContactId(47), 'meshtastic'))).toBe(false)
expect(isChannelMessage(message(channelContactId(1)), 1)).toBe(true)
})
})
@@ -0,0 +1,27 @@
import type { MeshMessage } from '@/stores/mesh'
const MAX_U32 = 4294967295
const MAX_MESHCORE_CHANNEL_INDEX = 7
export function channelContactId(channelIndex: number): number {
return MAX_U32 - channelIndex
}
export function isPublicChannelMessage(message: MeshMessage): boolean {
if (message.peer_contact_id === channelContactId(0)) return true
// Compatibility for messages persisted by the old V3 decoder, which used
// SNR as the channel index. MeshCore has only slots 0..7, making 8..255 an
// unambiguous legacy decoder artifact.
const derivedIndex = MAX_U32 - message.peer_contact_id
return message.transport === 'meshcore'
&& derivedIndex > MAX_MESHCORE_CHANNEL_INDEX
&& derivedIndex <= 255
}
export function isChannelMessage(message: MeshMessage, channelIndex: number): boolean {
return channelIndex === 0
? isPublicChannelMessage(message)
: message.peer_contact_id === channelContactId(channelIndex)
}
+20 -13
View File
@@ -166,24 +166,31 @@
</div>
</div>
<div class="mt-auto shrink-0 pt-4 space-y-3">
<!-- Always the bottom 50/50 pair, every screen size — consistent with
the other web5 cards' full-width bottom actions. -->
<div class="grid grid-cols-2 gap-3">
<button
@click="router.push('/dashboard/server/federation')"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('web5.findNodes') }}
</button>
<div class="mt-auto shrink-0 pt-4 border-t border-white/10">
<nav aria-label="Node connections" class="space-y-2 sm:grid sm:grid-cols-[repeat(3,minmax(0,1fr))_2.75rem] sm:gap-2 sm:space-y-0">
<div class="grid grid-cols-2 gap-2 sm:contents">
<button @click="router.push({ name: 'federation', query: { view: 'connected' } })" class="mobile-card-action glass-button rounded-lg w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
My connections
</button>
<button @click="router.push({ name: 'federation', query: { view: 'discover' } })" class="mobile-card-action glass-button rounded-lg w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
Find Nodes
</button>
</div>
<div class="flex gap-2 sm:contents">
<button @click="router.push({ name: 'federation', query: { view: 'map' } })" class="mobile-card-action glass-button rounded-lg flex-1 w-full px-2 text-xs sm:text-sm font-medium text-white/90 hover:text-white text-center min-h-11">
Map
</button>
<button
@click="refreshActiveTab"
:disabled="loadingPeers || loadingRequests"
class="mobile-card-action glass-button rounded-lg text-sm font-medium text-white/90 hover:text-white transition-colors"
class="mobile-card-action glass-button rounded-lg min-h-11 w-11 flex items-center justify-center text-white/80 hover:text-white disabled:opacity-50"
:aria-label="loadingPeers || loadingRequests ? 'Refreshing connected nodes' : 'Refresh connected nodes'"
title="Refresh connected nodes"
>
{{ loadingPeers || loadingRequests ? t('common.loading') : t('common.refresh') }}
<svg class="w-4 h-4" :class="{ 'animate-spin': loadingPeers || loadingRequests }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 11a8 8 0 10-2.4 6M20 4v7h-7" /></svg>
</button>
</div>
</div>
</nav>
</div>
</div>
+1 -5
View File
@@ -1,10 +1,6 @@
<template>
<!-- Quick Actions Container -->
<div class="glass-card p-6 mb-6">
<div class="flex flex-wrap gap-3 mb-4" aria-label="Node connections">
<RouterLink :to="{ name: 'federation', query: { view: 'discover' } }" class="glass-button px-4 py-2 rounded-lg text-sm text-white">Connect with Nodes</RouterLink>
<RouterLink :to="{ name: 'federation', query: { view: 'connected' } }" class="glass-button px-4 py-2 rounded-lg text-sm text-white">Connected Nodes</RouterLink>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-5 gap-4 stagger-grid">
<!-- Networking Profits -->
<div data-controller-container tabindex="0" :class="{ 'card-stagger': showStagger }" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0" style="--stagger-index: 0">
@@ -186,7 +182,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import type { ProfitsData, NostrRelayStatsData, HwWalletDevice } from './types'
@@ -5,8 +5,9 @@ import { rpcClient } from '@/api/rpc-client'
enableAutoUnmount(afterEach)
const pushRoute = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: pushRoute }),
}))
vi.mock('vue-i18n', () => ({
@@ -47,6 +48,23 @@ describe('Web5ConnectedNodes', () => {
sessionStorage.clear()
})
it('puts the three federation destinations and icon refresh in the bottom bar', async () => {
const wrapper = mount(Web5ConnectedNodes)
const nav = wrapper.get('nav[aria-label="Node connections"]')
expect(nav.text()).toContain('My connections')
expect(nav.text()).toContain('Find Nodes')
expect(nav.text()).toContain('Map')
expect(nav.text()).not.toContain('Network Map')
const mobilePrimaryRow = nav.get('div.grid.grid-cols-2')
expect(mobilePrimaryRow.findAll('button')).toHaveLength(2)
expect(mobilePrimaryRow.findAll('button').every(button => button.classes().includes('w-full'))).toBe(true)
await nav.findAll('button')[0]!.trigger('click')
await nav.findAll('button')[1]!.trigger('click')
await nav.findAll('button')[2]!.trigger('click')
expect(pushRoute.mock.calls.map(([route]) => route.query.view)).toEqual(['connected', 'discover', 'map'])
expect(nav.findAll('button')[3]!.attributes('aria-label')).toBe('Refresh connected nodes')
})
it('shows a loading state for empty trusted nodes while peers are loading', async () => {
const wrapper = mount(Web5ConnectedNodes)
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Keep the browser terminal's runtime and exact-authority proxy in releases."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
builder = (ROOT / "image-recipe/_archived/build-auto-installer-iso.sh").read_text()
installer = (ROOT / "image-recipe/archipelago-scripts/install-to-disk.sh").read_text()
nginx = (ROOT / "image-recipe/configs/nginx-archipelago.conf").read_text()
rootfs_packages = builder.split("cat >> \"$WORK_DIR/Dockerfile.rootfs\"", 1)[1].split(
"# Create archipelago systemd service", 1
)[0]
assert " tmux \\\n" in rootfs_packages, "fresh ISO rootfs omits tmux"
assert " tmux \\\n" in installer, "install-to-disk path omits tmux"
assert 'map "$request_method|$http_sec_fetch_site|$http_origin" $archy_terminal_origin' in nginx
terminal_locations = nginx.split("location ^~ /api/terminal/ {")[1:]
assert len(terminal_locations) == 2, "expected HTTP and HTTPS terminal proxies"
for location in terminal_locations:
block = location.split("}", 1)[0]
assert "proxy_set_header Host $http_host;" in block, "terminal proxy drops authority port"
assert "proxy_set_header Origin $archy_terminal_origin;" in block
assert nginx.count(
"location ~ ^/api/(peer|public)-indeehub-(stream|media|key)/ {"
) == 2, "HTTP and HTTPS must proxy IndeeHub playback before the SPA fallback"
print("PASS terminal runtime packaging and proxy authority")
+1
View File
@@ -73,6 +73,7 @@ stage "git-diff-check" git diff --check
stage "mirror-gate-regression" python3 scripts/tests/test_git_mirrors.py
stage "iso-boot-runner-regression" python3 scripts/tests/test_iso_qemu_runner.py
stage "iso-qualified-web-ui" python3 tests/regression/iso-qualified-web-ui.py
stage "terminal-runtime-packaging" python3 tests/regression/terminal-runtime-packaging.py
stage "first-boot-retry" python3 tests/regression/first-boot-retry.py
stage "demo-rpc-parity" timeout 120 node neode-ui/scripts/mock-rpc-parity.mjs
stage "demo-resumable-uploads" timeout 120 node --test neode-ui/scripts/demo-upload-test.mjs