fix terminal mesh chat and guarded IndeeHub playback

This commit is contained in:
archipelago
2026-10-10 07:52:47 -04:00
parent bbf8dc76c4
commit 71de1a3c27
15 changed files with 363 additions and 43 deletions
+99
View File
@@ -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>,
@@ -282,6 +319,38 @@ impl ApiHandler {
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")
@@ -1346,3 +1415,33 @@ mod terminal_origin_tests {
));
}
}
#[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,
+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]);