Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs/DOMAIN_EXPANSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ This document is the operator playbook for that work.

## Step 1: list the domains in `~/.apw/config.json`

Add (or update) the `supportedDomains` array in the user config. The
field is validated against the bundle's `Associated Domains` entitlement
at runtime, so it cannot claim more domains than the app is entitled to.
Add (or update) the `supportedDomains` array in the user config. `apw doctor`
uses this field to probe each domain's AASA file before a production rollout.
The app still cannot return credentials for domains missing from the signed
`Associated Domains` entitlement, so config may narrow the active set but cannot
expand beyond the shipped entitlement.

```json
{
Expand Down Expand Up @@ -93,10 +95,12 @@ apw app install

## Step 5: verify with `apw doctor`

Run `apw doctor --json` after install. The `app.frameworks` block
reports the entitlement domains the bundle was signed with, and the
`environment` array (issue #12) probes reachability of each AASA file
under `app.aasa[]`. Any check that fails surfaces a remediation hint.
Run `apw doctor --json` after install. The `environment` array includes an
`associated-domains` check when `supportedDomains` is configured. The probe
fails domains whose AASA response redirects, is not served as
`application/json`, or does not include `webcredentials.apps`. Any check that
fails surfaces a remediation hint. CI or one-off validation can override the
config with a comma-separated `APW_AASA_DOMAINS` value.

## Long-term plan

Expand Down
2 changes: 2 additions & 0 deletions rust/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2678,6 +2678,7 @@ mod tests {
username: "alice".to_string(),
shared_key: String::new(),
secret_source: Some(SecretSource::File),
supported_domains: Vec::new(),
fallback_provider: None,
fallback_provider_path: None,
fallback_provider_timeout_ms: None,
Expand Down Expand Up @@ -2772,6 +2773,7 @@ mod tests {
username: "alice".to_string(),
shared_key: String::new(),
secret_source: Some(SecretSource::File),
supported_domains: Vec::new(),
fallback_provider: None,
fallback_provider_path: None,
fallback_provider_timeout_ms: None,
Expand Down
242 changes: 222 additions & 20 deletions rust/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,28 +247,99 @@ fn check_native_app_bundle() -> DoctorCheck {
.with_remediation("Run `./scripts/build-native-app.sh`, then `apw app install`.")
}

/// Probe each configured associated domain for a reachable AASA file.
/// Domains are read from `APW_AASA_DOMAINS` (comma-separated) so this can
/// be wired ahead of the `supportedDomains` config field landing. See
/// issue #8.
fn check_associated_domains() -> Option<DoctorCheck> {
let raw = std::env::var("APW_AASA_DOMAINS").ok()?;
let domains: Vec<&str> = raw
.split(',')
fn parse_domain_list(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
.filter(|domain| !domain.is_empty())
.map(ToString::to_string)
.collect()
}

fn configured_associated_domains() -> Vec<String> {
if let Ok(raw) = std::env::var("APW_AASA_DOMAINS") {
return parse_domain_list(&raw);
}
crate::utils::configured_supported_domains_non_destructive()
}

fn parse_http_status(line: &str) -> Option<u16> {
let mut parts = line.split_whitespace();
let protocol = parts.next()?;
if !protocol.starts_with("HTTP/") {
return None;
}
parts.next()?.parse().ok()
}

fn has_valid_aasa_content_type(headers: &str) -> bool {
headers.lines().any(|line| {
let Some((name, value)) = line.split_once(':') else {
return false;
};
name.trim().eq_ignore_ascii_case("content-type")
&& value
.trim()
.split(';')
.next()
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("application/json"))
})
}

fn has_redirect_header(headers: &str) -> bool {
headers.lines().any(|line| {
line.split_once(':')
.is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case("location"))
})
}

fn split_curl_response(response: &str) -> Option<(&str, &str)> {
response
.split_once("\r\n\r\n")
.or_else(|| response.split_once("\n\n"))
}

fn aasa_response_is_valid(response: &str) -> bool {
let Some((headers, body)) = split_curl_response(response) else {
return false;
};
let status = headers.lines().rev().find_map(parse_http_status);
if !matches!(status, Some(200..=299)) || has_redirect_header(headers) {
return false;
}
if !has_valid_aasa_content_type(headers) {
return false;
}

let Ok(json) = serde_json::from_str::<Value>(body.trim()) else {
return false;
};
json.pointer("/webcredentials/apps")
.and_then(Value::as_array)
.is_some_and(|apps| {
!apps.is_empty()
&& apps
.iter()
.all(|app| app.as_str().is_some_and(|value| !value.trim().is_empty()))
})
}

/// Probe each configured associated domain for a valid AASA file.
/// Domains come from `supportedDomains` in `~/.apw/config.json`. The
/// `APW_AASA_DOMAINS` environment variable remains a comma-separated override
/// for CI and one-off operator validation. See issue #8.
fn check_associated_domains() -> Option<DoctorCheck> {
let domains = configured_associated_domains();
if domains.is_empty() {
return None;
}

let mut failures: Vec<String> = Vec::new();
for domain in &domains {
let url = format!("https://{domain}/.well-known/apple-app-site-association");
// `curl -fsI` is a small dependency footprint — most macOS / Linux
// hosts have it available, and we just need a HEAD probe.
let probe = run_probe("curl", &["-fsI", "--max-time", "5", &url]);
if probe.is_none() {
// `curl` is present on the supported operator hosts. `-D -` lets us
// validate headers and body from one bounded request.
let probe = run_probe("curl", &["-fsS", "--max-time", "5", "-D", "-", &url]);
if !probe.as_deref().is_some_and(aasa_response_is_valid) {
failures.push(domain.to_string());
}
}
Expand All @@ -279,7 +350,7 @@ fn check_associated_domains() -> Option<DoctorCheck> {
"associated-domains",
CheckStatus::Ok,
format!(
"AASA files reachable for {} configured domain(s).",
"AASA files valid for {} configured domain(s).",
domains.len()
),
)
Expand All @@ -291,12 +362,12 @@ fn check_associated_domains() -> Option<DoctorCheck> {
"associated-domains",
CheckStatus::Fail,
format!(
"AASA file unreachable for: {}",
"AASA file invalid or unreachable for: {}",
failures.join(", ")
),
)
.with_remediation(
"Each domain must serve application/json at /.well-known/apple-app-site-association without redirects. See docs/DOMAIN_EXPANSION.md.",
"Each domain must serve application/json at /.well-known/apple-app-site-association without redirects and include webcredentials.apps. See docs/DOMAIN_EXPANSION.md.",
),
)
}
Expand Down Expand Up @@ -347,6 +418,48 @@ pub fn checks_to_json(checks: &[DoctorCheck]) -> Value {
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
use std::path::Path;
use tempfile::TempDir;

fn with_temp_home<F>(run: F)
where
F: FnOnce(&Path),
{
let temp = TempDir::new().expect("failed to create temp home");
let previous_home = std::env::var_os("HOME");
std::env::set_var("HOME", temp.path());

run(temp.path());

if let Some(previous_home) = previous_home {
std::env::set_var("HOME", previous_home);
} else {
std::env::remove_var("HOME");
}
}

fn write_supported_domains_config(home: &Path, domains: &[&str]) {
let config = json!({
"schema": 1,
"port": 10000,
"host": "127.0.0.1",
"username": "",
"sharedKey": "",
"runtimeMode": "auto",
"secretSource": "file",
"supportedDomains": domains,
"createdAt": "2026-05-23T00:00:00Z"
});
let apw_dir = home.join(".apw");
fs::create_dir_all(&apw_dir).expect("failed to create .apw");
fs::write(
apw_dir.join("config.json"),
serde_json::to_vec_pretty(&config).expect("failed to encode config"),
)
.expect("failed to write config");
}

#[test]
fn check_status_label_is_uppercase() {
Expand Down Expand Up @@ -407,19 +520,108 @@ mod tests {
}

#[test]
#[serial]
fn associated_domains_check_skipped_when_env_unset() {
std::env::remove_var("APW_AASA_DOMAINS");
assert!(check_associated_domains().is_none());
with_temp_home(|_| {
std::env::remove_var("APW_AASA_DOMAINS");
assert!(check_associated_domains().is_none());
});
}

#[test]
#[serial]
fn associated_domains_check_preserves_invalid_config_when_env_unset() {
with_temp_home(|home| {
std::env::remove_var("APW_AASA_DOMAINS");
let apw_dir = home.join(".apw");
fs::create_dir_all(&apw_dir).expect("failed to create .apw");
let config_path = apw_dir.join("config.json");
fs::write(&config_path, "{invalid").expect("failed to write invalid config");

assert!(check_associated_domains().is_none());
assert!(
config_path.exists(),
"doctor must not clear malformed config"
);
});
}

#[test]
#[serial]
fn associated_domains_check_reports_failure_for_unreachable_host() {
// Use a guaranteed-unreachable .invalid TLD (RFC 2606). curl will
// exit non-zero so the probe returns None and the check fails.
std::env::set_var("APW_AASA_DOMAINS", "definitely-not-a-real-host.invalid");
let check = check_associated_domains().expect("expected an AASA check");
std::env::remove_var("APW_AASA_DOMAINS");
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.message.contains("unreachable"));
assert!(check.message.contains("invalid or unreachable"));
}

#[test]
#[serial]
fn associated_domains_check_reads_supported_domains_config() {
with_temp_home(|home| {
std::env::remove_var("APW_AASA_DOMAINS");
write_supported_domains_config(home, &["definitely-not-a-real-host.invalid"]);

let check = check_associated_domains().expect("expected an AASA check");

assert_eq!(check.status, CheckStatus::Fail);
assert!(check.message.contains("definitely-not-a-real-host.invalid"));
assert!(check
.remediation
.unwrap_or_default()
.contains("DOMAIN_EXPANSION"));
});
}

#[test]
fn aasa_response_validation_accepts_json_webcredentials() {
let response = concat!(
"HTTP/2 200\r\n",
"content-type: application/json; charset=utf-8\r\n",
"\r\n",
r#"{"webcredentials":{"apps":["TEAMID.dev.omt.apw"]}}"#
);

assert!(aasa_response_is_valid(response));
}

#[test]
fn aasa_response_validation_rejects_redirects() {
let response = concat!(
"HTTP/2 302\r\n",
"location: https://example.com/aasa.json\r\n",
"content-type: application/json\r\n",
"\r\n",
r#"{"webcredentials":{"apps":["TEAMID.dev.omt.apw"]}}"#
);

assert!(!aasa_response_is_valid(response));
}

#[test]
fn aasa_response_validation_rejects_non_json_content_type() {
let response = concat!(
"HTTP/2 200\r\n",
"content-type: text/html\r\n",
"\r\n",
r#"{"webcredentials":{"apps":["TEAMID.dev.omt.apw"]}}"#
);

assert!(!aasa_response_is_valid(response));
}

#[test]
fn aasa_response_validation_rejects_missing_webcredentials_apps() {
let response = concat!(
"HTTP/2 200\r\n",
"content-type: application/json\r\n",
"\r\n",
r#"{"applinks":{"apps":[]}}"#
);

assert!(!aasa_response_is_valid(response));
}
}
8 changes: 8 additions & 0 deletions rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ pub struct APWConfigV1 {
pub bridge_last_error: Option<String>,
#[serde(rename = "secretSource", default)]
pub secret_source: Option<SecretSource>,
#[serde(
rename = "supportedDomains",
alias = "supported_domains",
default,
skip_serializing_if = "Vec::is_empty"
)]
pub supported_domains: Vec<String>,
#[serde(rename = "fallbackProvider", alias = "fallback_provider", default)]
pub fallback_provider: Option<ExternalFallbackProvider>,
#[serde(
Expand Down Expand Up @@ -214,6 +221,7 @@ impl Default for APWConfigV1 {
bridge_connected_at: None,
bridge_last_error: None,
secret_source: Some(SecretSource::File),
supported_domains: Vec::new(),
fallback_provider: None,
fallback_provider_path: None,
fallback_provider_timeout_ms: None,
Expand Down
Loading
Loading