Integrate recoverable native purchases, registered rentals and explicit payment consent

This commit is contained in:
archipelago
2026-10-06 22:44:06 -04:00
parent e4eae71314
commit 49703d7e88
63 changed files with 8028 additions and 134 deletions
@@ -218,7 +218,7 @@ impl DockerPackageScanner {
None
},
static_files: StaticFiles {
license: "MIT".to_string(),
license: String::new(),
instructions: metadata.description.clone(),
icon: manifest_icon.unwrap_or_else(|| metadata.icon.clone()),
},
@@ -231,7 +231,7 @@ impl DockerPackageScanner {
long: metadata.description.clone(),
},
release_notes: "Docker container".to_string(),
license: "MIT".to_string(),
license: String::new(),
wrapper_repo: metadata.repo.clone(),
upstream_repo: metadata.repo.clone(),
support_site: metadata.repo.clone(),
@@ -512,6 +512,32 @@ mod lifecycle_regression_tests {
assert_eq!(main.lan_config.as_deref(), Some("kept"));
}
#[test]
fn manifest_presentation_only_reports_declared_licenses() {
let mut entry = installing_fixture();
for (metadata, expected) in [
(serde_json::json!({"license":"MIT"}), "MIT"),
(
serde_json::json!({"license":" BSD-3-Clause "}),
"BSD-3-Clause",
),
(serde_json::json!({}), ""),
(serde_json::json!({"license":null}), ""),
(serde_json::json!({"license":true}), ""),
(serde_json::json!({"license":" "}), ""),
] {
apply_manifest_value(
&serde_json::json!({"app":{"metadata":metadata}}),
&mut entry,
);
assert_eq!(entry.manifest.license, expected);
assert_eq!(entry.static_files.license, expected);
}
apply_manifest_value(&serde_json::json!({"app":{}}), &mut entry);
assert_eq!(entry.manifest.license, "");
assert_eq!(entry.static_files.license, "");
}
#[test]
fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() {
let mut entry = installing_fixture();
@@ -821,6 +847,14 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry)
.filter(|s| !s.is_empty())
.map(str::to_owned)
};
// Absence is not a license grant. Empty strings are omitted by the UI.
let license = text(
app.get("metadata")
.and_then(|metadata| metadata.get("license")),
)
.unwrap_or_default();
entry.manifest.license = license.clone();
entry.static_files.license = license;
if let Some(name) = text(app.get("name")) {
entry.manifest.title = name;
}
@@ -27,6 +27,81 @@ pub struct RegistrationPin {
pub node_did: String,
pub app_audience: String,
}
/// Fixed installed app context used by native media selection and local playback
/// handles. Caller must still authenticate owner session/CSRF or its scoped handle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct InstalledAppContext {
pub app_id: String,
pub backend_id: String,
pub app_audience: String,
pub node_did: String,
pub node_public_key: String,
pub app_origins: Vec<String>,
}
/// Blocking lookup; never provisions a new identity/audience. No request chooses
/// app scope. Revalidate fresh installation state before using a playback handle.
pub(crate) fn installed_context(
data_dir: &Path,
identity: &crate::identity::NodeIdentity,
state: &crate::data_model::DataModel,
) -> Result<InstalledAppContext> {
for id in ["indeedhub", "indeedhub-api"] {
let entry = state
.package_data
.get(id)
.context("IndeeHub is not installed")?;
anyhow::ensure!(
matches!(entry.state, crate::data_model::PackageState::Running)
&& entry.installed.is_some(),
"IndeeHub installation is not running"
);
}
let app = state.package_data.get("indeedhub").unwrap();
let installed = app.installed.as_ref().unwrap();
let mut origins = Vec::new();
for address in installed.interface_addresses.values() {
for text in
std::iter::once(address.tor_address.as_str()).chain(address.lan_address.as_deref())
{
let onion_url = text
.strip_suffix(".onion")
.filter(|host| {
host.len() == 56
&& host
.bytes()
.all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b))
})
.map(|_| format!("http://{text}"));
if let Ok(url) = reqwest::Url::parse(onion_url.as_deref().unwrap_or(text)) {
if matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
{
origins.push(url.origin().ascii_serialization());
}
}
}
}
origins.sort();
origins.dedup();
anyhow::ensure!(
!origins.is_empty(),
"IndeeHub has no installed browser origin"
);
let pin = load_existing(data_dir, "indeedhub-api", identity)?;
Ok(InstalledAppContext {
app_id: "indeedhub".into(),
backend_id: "indeedhub-api".into(),
app_audience: pin.app_audience,
node_did: pin.node_did,
node_public_key: pin.node_public_key,
app_origins: origins,
})
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Marker {
@@ -371,4 +446,48 @@ mod tests {
manifest.app.container.media_registration_identity = false;
assert!(apply_environment(&mut manifest, &pin).is_err());
}
#[tokio::test]
async fn installed_media_context_requires_both_apps_and_existing_pin_and_tracks_origin_changes()
{
let (root, identity) = fixture().await;
let mut state = crate::data_model::DataModel::default();
for id in ["indeedhub", "indeedhub-api"] {
let entry = serde_json::from_value(serde_json::json!({
"state":"running", "static-files":{"license":"","instructions":"","icon":""},
"manifest":{"id":id,"title":id,"version":"fixture",
"description":{"short":"fixture","long":""},"release-notes":"","license":"",
"wrapper-repo":"","upstream-repo":"","support-site":"","marketing-site":""},
"installed":{"current-dependents":{},"current-dependencies":{},"last-backup":null,"status":"running",
"interface-addresses":{"main":{"tor-address":"","lan-address":"https://localhost:7778/browse"}}}
})).unwrap();
state.package_data.insert(id.into(), entry);
}
assert!(installed_context(root.path(), &identity, &state).is_err());
let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap();
let first = installed_context(root.path(), &identity, &state).unwrap();
assert_eq!(first.app_audience, pin.app_audience);
assert_eq!(first.app_origins, vec!["https://localhost:7778"]);
state.package_data.get_mut("indeedhub-api").unwrap().state =
crate::data_model::PackageState::Stopped;
assert!(installed_context(root.path(), &identity, &state).is_err());
state.package_data.get_mut("indeedhub-api").unwrap().state =
crate::data_model::PackageState::Running;
state
.package_data
.get_mut("indeedhub")
.unwrap()
.installed
.as_mut()
.unwrap()
.interface_addresses
.get_mut("main")
.unwrap()
.lan_address = Some("https://localhost:7779".into());
assert_ne!(
installed_context(root.path(), &identity, &state).unwrap(),
first
);
state.package_data.remove("indeedhub");
assert!(installed_context(root.path(), &identity, &state).is_err());
}
}