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:
@@ -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) {
|
||||
|
||||
@@ -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"]]}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user