diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 181d46428..227e39bbd 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -4394,18 +4394,18 @@ public func FfiConverterTypeNativeRuntimeConfigError_lower(_ value: NativeRuntim * a raw string so the dispatcher can reject invalid input before reaching * any platform callback. The open variants carry the ready-to-load canonical * URL; `DotName` and `Localhost` keep the dotns/localhost identity visible so - * env-aware hosts can rewrite `.dot` names for their active environment and + * env-aware hosts can rewrite dotNS names for their active environment and * re-parse without losing information. */ public enum NavigateDecision: Equatable, Hashable { /** - * A `.dot` identifier plus path/query/hash suffix (no leading `/`). + * A dotNS identifier plus path/query/hash suffix (no leading `/`). */ case dotName( /** - * Lower-cased `.dot` host (e.g. `mytestapp.dot`). + * Lower-cased dotNS host (e.g. `mytestapp.dot`). */identifier: String, /** * Path/query/hash suffix without a leading `/`. @@ -4437,7 +4437,7 @@ public enum NavigateDecision: Equatable, Hashable { */url: String ) /** - * Input that fails every branch: empty, unparseable, or a `.dot` URL + * Input that fails every branch: empty, unparseable, or a dotNS URL * carrying port/userinfo (both forbidden since dotns resolves via the * chain and has no notion of either). */ @@ -5228,7 +5228,7 @@ public func uniffiForeignFutureHandleCountTruapiServer() -> Int { } /** * Classify a navigation input exactly like the core's internal navigate host - * call: `.dot` first, then `localhost`, then normalized external, with + * call: dotNS first, then `localhost`, then normalized external, with * everything else rejected. Pure and stateless; hosts call it on every * webview-internal navigation. */ @@ -5269,7 +5269,7 @@ private let initializationResult: InitializationResult = { if bindings_contract_version != scaffolding_contract_version { return InitializationResult.contractVersionMismatch } - if (uniffi_truapi_server_checksum_func_parse_navigate() != 58140) { + if (uniffi_truapi_server_checksum_func_parse_navigate() != 62582) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_func_set_log_level() != 13010) { diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 8071e2e0a..e74990398 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -215,7 +215,8 @@ res.match( ); ``` -`--product-id` (a `.dot` name or `localhost` identifier; default +`--product-id` (a dotNS name ending in `.dot` or `.paseo`, or a `localhost` +identifier; default `headless-playground.dot`) sets the initial product. `/product ` changes it for the lifetime of the process. Switching disconnects active product WebSockets so clients reconnect with a new product context; the network, diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 303096b0f..00595da75 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -339,7 +339,7 @@ use `exec '/script '` instead. `/copy` is unavailable. `/clear` and Accepted product identifiers are: -- a name ending in `.dot`; +- a name ending in a dotNS TLD (`.dot` or `.paseo`); - `localhost`; or - a string beginning with `localhost:`. diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 7a95427ca..5f881239c 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -51,7 +51,8 @@ use crate::terminal_ui::{ }; /// Default product served by the pairing host's frame endpoint. Product ids -/// must be a `.dot` name or a `localhost` identifier (host-spec product id). +/// must be a dotNS name (`.dot` or `.paseo`) or a `localhost` identifier +/// (host-spec product id). const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot"; /// Deeplink scheme advertised by the pairing host. const DEEPLINK_SCHEME: &str = "polkadotapp"; diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 7ec82c2c6..a37bb40e9 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -226,6 +226,17 @@ pub fn is_product_identifier(identifier: &str) -> bool { normalize_product_identifier(identifier).is_ok() } +/// Top-level domains that dotNS deployments register product names under. +pub const DOTNS_TLDS: &[&str] = &["dot", "paseo"]; + +/// Whether `normalized` ends in one of [`DOTNS_TLDS`]. Expects an +/// already-lowercased host with no trailing root dot. +pub fn has_dotns_tld(normalized: &str) -> bool { + normalized + .rsplit_once('.') + .is_some_and(|(_, tld)| DOTNS_TLDS.contains(&tld)) +} + /// Normalize product identifiers before derivation and policy checks. pub fn normalize_product_identifier( product_id: &str, @@ -233,7 +244,7 @@ pub fn normalize_product_identifier( let trimmed = product_id.trim(); require_non_empty("product_id", trimmed)?; let normalized = trimmed.nfc().collect::().to_lowercase(); - if normalized.ends_with(".dot") + if has_dotns_tld(&normalized) || normalized == "localhost" || normalized.starts_with("localhost:") { @@ -279,8 +290,8 @@ pub enum RuntimeConfigValidationError { /// Actual deeplink scheme value. scheme: String, }, - /// Product id was not a `.dot` or localhost product identifier. - #[display("product_id must be a .dot or localhost product identifier, got {product_id:?}")] + /// Product id was not a dotNS or localhost product identifier. + #[display("product_id must be a dotNS or localhost product identifier, got {product_id:?}")] InvalidProductId { /// Actual product id value. product_id: String, diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index 29ce74543..948c2b721 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -122,11 +122,19 @@ fn product_context_validation_cases() { Ok(()) ); assert_eq!( - ProductContext::new("example.com".to_string()).map(|_| ()), - Err(RuntimeConfigValidationError::InvalidProductId { - product_id: "example.com".to_string(), - }) + ProductContext::new("Host-Playground44.PASEO".to_string()) + .map(|context| context.product_id), + Ok("host-playground44.paseo".to_string()) ); + for domain in ["example.com", "example.org", "dotli.dotty"] { + assert_eq!( + ProductContext::new(domain.to_string()).map(|_| ()), + Err(RuntimeConfigValidationError::InvalidProductId { + product_id: domain.to_string(), + }), + "{domain} must not be accepted as a product identifier" + ); + } assert_eq!( ProductContext::new(" ".to_string()).map(|_| ()), Err(RuntimeConfigValidationError::EmptyField { diff --git a/rust/crates/truapi-server/src/host_logic/dotns.rs b/rust/crates/truapi-server/src/host_logic/dotns.rs index a3fed51a6..3ad74b124 100644 --- a/rust/crates/truapi-server/src/host_logic/dotns.rs +++ b/rust/crates/truapi-server/src/host_logic/dotns.rs @@ -4,6 +4,7 @@ //! same categorization and the `navigate_to` callback only receives //! already-validated input. +use truapi_platform::has_dotns_tld; use unicode_normalization::UnicodeNormalization; use url::Url; @@ -11,14 +12,14 @@ use url::Url; /// a raw string so the dispatcher can reject invalid input before reaching /// any platform callback. The open variants carry the ready-to-load canonical /// URL; `DotName` and `Localhost` keep the dotns/localhost identity visible so -/// env-aware hosts can rewrite `.dot` names for their active environment and +/// env-aware hosts can rewrite dotNS names for their active environment and /// re-parse without losing information. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Enum))] pub enum NavigateDecision { - /// A `.dot` identifier plus path/query/hash suffix (no leading `/`). + /// A dotNS identifier plus path/query/hash suffix (no leading `/`). DotName { - /// Lower-cased `.dot` host (e.g. `mytestapp.dot`). + /// Lower-cased dotNS host (e.g. `mytestapp.dot`). identifier: String, /// Path/query/hash suffix without a leading `/`. path: String, @@ -39,7 +40,7 @@ pub enum NavigateDecision { /// Canonical URL string. url: String, }, - /// Input that fails every branch: empty, unparseable, or a `.dot` URL + /// Input that fails every branch: empty, unparseable, or a dotNS URL /// carrying port/userinfo (both forbidden since dotns resolves via the /// chain and has no notion of either). Reject { @@ -56,7 +57,7 @@ fn join_url(scheme: &str, host: &str, path: &str) -> String { } } -/// Classify a URL the way the host navigation handler does: try `.dot` first, +/// Classify a URL the way the host navigation handler does: try dotNS first, /// then `localhost`, then normalize as external. pub fn parse_navigate(input: &str) -> NavigateDecision { let trimmed = input.trim(); @@ -66,7 +67,7 @@ pub fn parse_navigate(input: &str) -> NavigateDecision { }; } - if let Some(decision) = classify_dot(trimmed) { + if let Some(decision) = classify_dotns(trimmed) { return decision; } @@ -92,10 +93,12 @@ fn normalize_host(host: &str) -> String { .to_string() } -/// `.dot` TLD check, applied to the [`normalize_host`] form so `Example.DOT` +/// dotNS TLD check, applied to the [`normalize_host`] form so `Example.DOT` /// and the trailing-dot FQDN `example.dot.` classify like `example.dot`. -fn is_dot_domain(host: &str) -> bool { - normalize_host(host).ends_with(".dot") +/// Shares [`truapi_platform::DOTNS_TLDS`] with product-identifier validation +/// so navigation and derivation accept the same per-network names. +fn is_dotns_domain(host: &str) -> bool { + has_dotns_tld(&normalize_host(host)) } fn parse_with_explicit_https(input: &str) -> Option { @@ -105,12 +108,12 @@ fn parse_with_explicit_https(input: &str) -> Option { Url::parse(&format!("https://{input}")).ok() } -/// Recognize `.dot` URLs (including the `polkadot://` scheme). Returns: -/// - `Some(DotName)` for a clean `.dot` URL -/// - `Some(Reject)` for a `.dot` URL with port or userinfo -/// - `None` when the input isn't a `.dot` URL (caller falls through to +/// Recognize dotNS URLs (including the `polkadot://` scheme). Returns: +/// - `Some(DotName)` for a clean dotNS URL +/// - `Some(Reject)` for a dotNS URL with port or userinfo +/// - `None` when the input isn't a dotNS URL (caller falls through to /// localhost / external) -fn classify_dot(input: &str) -> Option { +fn classify_dotns(input: &str) -> Option { let parsed = if input.starts_with("polkadot://") { Url::parse(input).ok()? } else { @@ -118,7 +121,7 @@ fn classify_dot(input: &str) -> Option { }; let hostname = parsed.host_str()?; - if !is_dot_domain(hostname) { + if !is_dotns_domain(hostname) { return None; } @@ -334,6 +337,36 @@ mod tests { input: "https://user:pass@x.dot/path", expected: Expected::Reject, }, + TestCase { + name: "paseo bare", + input: "mytestapp.paseo", + expected: dot("mytestapp.paseo", ""), + }, + TestCase { + name: "paseo with path query hash", + input: "pr508.faucet.paseo/nested/path?embed=1#frame=compact", + expected: dot("pr508.faucet.paseo", "nested/path?embed=1#frame=compact"), + }, + TestCase { + name: "paseo mixed case", + input: "Example.PASEO/Path", + expected: dot("example.paseo", "Path"), + }, + TestCase { + name: "polkadot scheme paseo host", + input: "polkadot://currenthost.paseo/mytestapp.paseo", + expected: dot("currenthost.paseo", "mytestapp.paseo"), + }, + TestCase { + name: "paseo with port is rejected", + input: "https://x.paseo:8443/path", + expected: Expected::Reject, + }, + TestCase { + name: "paseo with userinfo is rejected", + input: "https://user:pass@x.paseo/path", + expected: Expected::Reject, + }, TestCase { name: "trim whitespace", input: " mytestapp.dot/path ", diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 94e4338dc..73955de00 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -362,7 +362,7 @@ impl From for v01::HostNavigateToError { } /// Classify a navigation input exactly like the core's internal navigate host -/// call: `.dot` first, then `localhost`, then normalized external, with +/// call: dotNS first, then `localhost`, then normalized external, with /// everything else rejected. Pure and stateless; hosts call it on every /// webview-internal navigation. #[uniffi::export] diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index c80738141..95da42029 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -568,7 +568,7 @@ fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue ), RuntimeConfigValidationError::InvalidProductId { product_id } => { JsValue::from_str(&format!( - "runtimeConfig.productId must be a .dot or localhost product identifier, got {product_id:?}" + "runtimeConfig.productId must be a dotNS or localhost product identifier, got {product_id:?}" )) } }