From 10317d5f2341619f8f7358bb29f86f019501035f Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 18 Aug 2026 15:33:43 +0100 Subject: [PATCH 1/2] feat(platform): auto-grant remote permissions to trusted product labels Product-id TLDs split from the narrower dotNS navigation list so .test is a product scope without bypassing the outbound domain grant. --- docs/rfcs/0002-permission-model.md | 3 + rust/crates/truapi-host-cli/README.md | 2 +- rust/crates/truapi-host-cli/SPEC.md | 2 +- rust/crates/truapi-host-cli/src/main.rs | 2 +- rust/crates/truapi-platform/README.md | 23 ++ rust/crates/truapi-platform/src/lib.rs | 120 +++++- rust/crates/truapi-platform/tests/bounds.rs | 4 + rust/crates/truapi-server/README.md | 28 +- .../truapi-server/src/host_logic/dotns.rs | 21 +- .../src/host_logic/permissions.rs | 366 +++++++++++++++++- 10 files changed, 548 insertions(+), 23 deletions(-) diff --git a/docs/rfcs/0002-permission-model.md b/docs/rfcs/0002-permission-model.md index f5459a114..a9a7e2e42 100644 --- a/docs/rfcs/0002-permission-model.md +++ b/docs/rfcs/0002-permission-model.md @@ -8,6 +8,9 @@ owner: "@johnthecat" > **NOTE (2026-05-26): `remote_permission` reverted to a single permission.** > This RFC below specifies batched remote-permission requests (`remote_permission` taking a `Vec`). That part has been rolled back: `remote_permission` again accepts a single `RemotePermission`. After the initial implementation it became clear that the batched API is hard to justify to the end user — a single prompt covering several distinct grants produces bad UX (the user cannot reason about or selectively approve what they are consenting to). The rest of this RFC (device permissions, lifecycle, persistence, implicit triggering) still stands; only the batching of remote permissions is reverted. +> **NOTE (2026-08-18): first-party products hold remote permissions without a prompt.** +> The lifecycle below specifies that every permission is prompted on first request. That holds for device permissions, identity disclosure and cross-product account access, but not for remote permissions requested by a product on the trusted list in `truapi_platform::REMOTE_PERMISSION_TRUSTED_LABELS`. Those products hold every `RemotePermission` variant — domain access, WebRTC, chain submit, preimage submit, statement submit — without a prompt, because they ship alongside the host and their remote access belongs to the host's own trust boundary. The grant is not persisted, so a `Denied` written through the permission administration surface still outranks it and revokes the access; clearing that denial restores the auto-grant. An empty `Remote` domain bundle remains denied, since it grants nothing. The list holds bare product labels with no TLD, so one entry covers the product on every network. This is an interim mechanism: the allowlist is intended to move into the product manifest, per RFC 0024. + ## Summary The Host API currently has two underdefined permission calls — `host_device_permission` and `remote_permission` — that lack coverage for several device capabilities (NFC, Clipboard, OpenUrl, Biometrics), do not support batched remote-permission requests, and have no specified lifecycle for when prompts occur or how decisions are persisted. This RFC defines the complete set of device and remote permissions, updates the `remote_permission` signature to accept a batch, specifies that permission decisions are prompted once and then stored permanently, and establishes that business methods (`host_sign_raw`, `host_sign_payload`, `host_create_transaction`, `host_create_transaction_with_non_product_account`, `remote_statement_store_submit`, `remote_preimage_submit`, `remote_chain_transaction_broadcast`) implicitly trigger permission prompts if permission has not yet been granted. diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index c1bc231aa..3fa65162a 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -218,7 +218,7 @@ res.match( ); ``` -`--product-id` (a dotNS name ending in `.dot` or `.paseo`, or a `localhost` +`--product-id` (a name ending in `.dot`, `.paseo` or `.test`, 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 diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 8d3e82af5..e63acf41d 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -340,7 +340,7 @@ use `exec '/script '` instead. `/copy` is unavailable. `/clear` and Accepted product identifiers are: -- a name ending in a dotNS TLD (`.dot` or `.paseo`); +- a name ending in a product TLD (`.dot`, `.paseo` or `.test`); - `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 2f271c19e..522d3d82d 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -56,7 +56,7 @@ use crate::terminal_ui::{ }; /// Default product served by the pairing host's frame endpoint. Product ids -/// must be a dotNS name (`.dot` or `.paseo`) or a `localhost` identifier +/// must be a product name (`.dot`, `.paseo` or `.test`) or a `localhost` identifier /// (host-spec product id). const DEFAULT_PRODUCT_ID: &str = "headless-playground.dot"; /// Deeplink scheme advertised by the pairing host. diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md index c22db4c7a..20b24e431 100644 --- a/rust/crates/truapi-platform/README.md +++ b/rust/crates/truapi-platform/README.md @@ -18,6 +18,29 @@ wasm callback boundary, where every parameter is encoded with `parity-scale-codec`; `ProductContext` decodes through its validating constructor, so a context off the wire carries a normalized product id. +## Product Identity + +`normalize_product_identifier` is the single chokepoint that turns a host- or +wire-supplied product id into the canonical form derivation, product storage and +permission scopes are keyed by; `is_product_identifier` is its boolean form. + +Two TLD lists back it, and they are deliberately different sizes: + +- `DOTNS_TLDS` (`dot`, `paseo`) — names navigation resolves back into the host's + own product surface. A name classified this way bypasses the outbound domain + grant, so this list stays narrow. +- `PRODUCT_ID_TLDS` (`dot`, `paseo`, `test`) — TLDs a product identifier may be + scoped under. `test` is a legal product scope but not a dotNS name, so a + `.test` URL stays external and keeps consuming a domain grant. + +`REMOTE_PERMISSION_TRUSTED_LABELS` lists bare product labels — no TLD, so one +entry covers every network in `PRODUCT_ID_TLDS` — whose products hold every +`RemotePermission` without a user prompt, tested with +`has_trusted_remote_permissions`. It covers remote permissions only: device +permissions, identity disclosure and cross-product account access always prompt. +A stored decision outranks the list, so a `Denied` written through `CoreAdmin` +revokes the grant. + ## Host Callback Traits - `ProductStorage`: product-scoped key-value storage. diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 1d15c7aa7..1fcd1464d 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -246,7 +246,8 @@ pub fn is_product_identifier(identifier: &str) -> bool { normalize_product_identifier(identifier).is_ok() } -/// Top-level domains that dotNS deployments register product names under. +/// Top-level domains whose names navigation resolves back into the host's own +/// product surface instead of treating them as arbitrary internet hosts. pub const DOTNS_TLDS: &[&str] = &["dot", "paseo"]; /// Whether `normalized` ends in one of [`DOTNS_TLDS`]. Expects an @@ -257,6 +258,46 @@ pub fn has_dotns_tld(normalized: &str) -> bool { .is_some_and(|(_, tld)| DOTNS_TLDS.contains(&tld)) } +/// Top-level domains a product identifier may be scoped under. +/// +/// A superset of [`DOTNS_TLDS`]: `test` is an accepted product scope but not a +/// name navigation resolves internally, so a `.test` URL stays external and +/// still consumes a domain grant. +pub const PRODUCT_ID_TLDS: &[&str] = &["dot", "paseo", "test"]; + +/// Whether `normalized` ends in one of [`PRODUCT_ID_TLDS`]. Expects an +/// already-lowercased host with no trailing root dot. +pub fn has_product_id_tld(normalized: &str) -> bool { + normalized + .rsplit_once('.') + .is_some_and(|(_, tld)| PRODUCT_ID_TLDS.contains(&tld)) +} + +/// Bare product labels whose products hold every [`RemotePermission`] without a +/// user prompt. +/// +/// These are first-party surfaces shipped alongside the host, so their remote +/// access belongs to the host's own trust boundary rather than to a per-product +/// decision. The list covers remote permissions only: device permissions, +/// identity disclosure and cross-product account access are always asked for. +/// Entries carry no TLD, so one entry covers the product on every network in +/// [`PRODUCT_ID_TLDS`]. +pub const REMOTE_PERMISSION_TRUSTED_LABELS: &[&str] = &["peopl", "dim2", "stash"]; + +/// Whether `product_id` holds every [`RemotePermission`] without prompting. +/// +/// Expects the [`normalize_product_identifier`] form. Matches the whole label +/// and nothing else: `peopl.dot` and `peopl.paseo` are trusted, while +/// `app.peopl.dot` and any `localhost` identifier are separate products and are +/// not. The label is only read out of an id that [`has_product_id_tld`] accepts, +/// so a widened product-id policy cannot promote an arbitrary single-label host. +pub fn has_trusted_remote_permissions(product_id: &str) -> bool { + has_product_id_tld(product_id) + && product_id + .rsplit_once('.') + .is_some_and(|(label, _tld)| REMOTE_PERMISSION_TRUSTED_LABELS.contains(&label)) +} + /// Normalize product identifiers before derivation and policy checks. pub fn normalize_product_identifier( product_id: &str, @@ -264,7 +305,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 has_dotns_tld(&normalized) + if has_product_id_tld(&normalized) || normalized == "localhost" || normalized.starts_with("localhost:") { @@ -872,6 +913,81 @@ mod tests { ); } + #[test] + fn product_id_tlds_are_a_superset_of_dotns_tlds() { + // A name navigation resolves internally must also be a legal product + // scope; the reverse does not hold, which is what keeps a `.test` host + // external and behind the outbound domain grant. + for tld in DOTNS_TLDS { + assert!( + PRODUCT_ID_TLDS.contains(tld), + "{tld} classifies as dotNS but is not an accepted product scope" + ); + } + } + + #[test] + fn trusted_remote_permission_labels_match_the_bare_product_label() { + for product_id in [ + "peopl.dot", + "peopl.paseo", + "peopl.test", + "dim2.dot", + "stash.dot", + ] { + assert!( + has_trusted_remote_permissions(product_id), + "{product_id} must hold remote permissions without a prompt" + ); + } + for product_id in [ + "app.peopl.dot", + "sub.dim2.paseo", + "peopl", + "peopl.com", + "peoplx.dot", + "my-peopl.dot", + "localhost", + "localhost:3000", + "", + "dot", + ] { + assert!( + !has_trusted_remote_permissions(product_id), + "{product_id} is a separate product and must prompt" + ); + } + } + + #[test] + fn every_trusted_remote_permission_label_is_a_product_identifier() { + // A label that product-id validation rejects would never reach the + // permission engine, so the whitelist entry would be silently inert. + for label in REMOTE_PERMISSION_TRUSTED_LABELS { + for tld in PRODUCT_ID_TLDS { + let product_id = format!("{label}.{tld}"); + assert!( + is_product_identifier(&product_id), + "{product_id} must be an accepted product identifier" + ); + assert!( + has_trusted_remote_permissions(&product_id), + "{product_id} must be recognized as trusted" + ); + } + } + } + + #[test] + fn trusted_remote_permission_labels_are_bare_lowercase_labels() { + // The predicate compares against the label of an already-normalized id, + // so an entry carrying a TLD or an uppercase letter can never match. + for label in REMOTE_PERMISSION_TRUSTED_LABELS { + assert!(!label.contains('.'), "{label} must not carry a TLD"); + assert_eq!(*label, label.to_lowercase(), "{label} must be lowercase"); + } + } + #[test] fn core_storage_key_description_is_strict_and_product_scoped() { let permission = CoreStorageKey::device_permission_authorization( diff --git a/rust/crates/truapi-platform/tests/bounds.rs b/rust/crates/truapi-platform/tests/bounds.rs index 948c2b721..764877923 100644 --- a/rust/crates/truapi-platform/tests/bounds.rs +++ b/rust/crates/truapi-platform/tests/bounds.rs @@ -126,6 +126,10 @@ fn product_context_validation_cases() { .map(|context| context.product_id), Ok("host-playground44.paseo".to_string()) ); + assert_eq!( + ProductContext::new("Foo.TEST".to_string()).map(|context| context.product_id), + Ok("foo.test".to_string()) + ); for domain in ["example.com", "example.org", "dotli.dotty"] { assert_eq!( ProductContext::new(domain.to_string()).map(|_| ()), diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 388997266..f082e8e2b 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -93,6 +93,13 @@ role revalidates before touching key material. Permission grants are scoped by product id and typed request, so a grant for one product never authorizes another product or another permission class. +Remote permissions carry one exception. A product whose label is listed in +`truapi_platform::REMOTE_PERMISSION_TRUSTED_LABELS` holds every +`RemotePermission` without a prompt: while nothing is stored the lookup reports +`Authorized` and writes nothing. A stored `Denied` still wins, so the admin +surface revokes it. Device permissions, identity disclosure and account access +always prompt. + ```text Product app (product_id = "my-product") @@ -122,8 +129,8 @@ CoreStorage lookup | +-- Denied -------------------> return PermissionDenied / deny call | - +-- NotDetermined / missing ---+ - | + +-- NotDetermined / missing ---+ (remote + trusted label: allow, + | see below) v Platform prompt callback | @@ -149,6 +156,23 @@ CoreStorage lookup allow call deny call ``` +#### Auto-granted remote permissions + +A remote permission resolves in this order: + +1. `Remote { domains: [] }` is `Denied`. An empty bundle grants nothing, so + failing closed outranks the whitelist. +2. A stored decision wins — the exact slot for a non-domain variant, or the most + specific matching `remote_domain_candidates` entry for a domain. +3. Nothing stored and the product's label is trusted: `Authorized`, with no + prompt and no write. +4. Nothing stored and the label is untrusted: `NotDetermined`, so the lookup + prompts and persists the answer. + +Because a trusted product's grant is never written, revoking its domain access +means writing `Denied` for the `*` pattern; denying a single host leaves every +other host granted. + Permission administration uses the same key without prompting: ```text diff --git a/rust/crates/truapi-server/src/host_logic/dotns.rs b/rust/crates/truapi-server/src/host_logic/dotns.rs index 3c8d196e0..513d46628 100644 --- a/rust/crates/truapi-server/src/host_logic/dotns.rs +++ b/rust/crates/truapi-server/src/host_logic/dotns.rs @@ -95,8 +95,12 @@ fn normalize_host(host: &str) -> String { /// dotNS TLD check, applied to the [`normalize_host`] form so `Example.DOT` /// and the trailing-dot FQDN `example.dot.` classify like `example.dot`. -/// Shares [`truapi_platform::DOTNS_TLDS`] with product-identifier validation -/// so navigation and derivation accept the same per-network names. +/// +/// Reads [`truapi_platform::DOTNS_TLDS`], which is narrower than the set of +/// TLDs a product identifier may be scoped under. A name classified here +/// resolves into the host's own product surface and so bypasses the outbound +/// domain grant; a TLD that a resolver can point anywhere must stay external and +/// keep that gate. fn is_dotns_domain(host: &str) -> bool { has_dotns_tld(&normalize_host(host)) } @@ -281,6 +285,19 @@ mod tests { input: "mytestapp.dot.li", expected: external("https://mytestapp.dot.li/"), }, + TestCase { + // `test` is an accepted product scope but not a dotNS name: a + // resolver can point it anywhere, so it must stay external and + // keep consuming the outbound domain grant. + name: "test is external, not a dotns name", + input: "mytestapp.test", + expected: external("https://mytestapp.test/"), + }, + TestCase { + name: "test with port stays external", + input: "http://mytestapp.test:5173/app", + expected: external("http://mytestapp.test:5173/app"), + }, TestCase { name: "dot with https", input: "https://mytestapp.dot", diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index 68ce36126..7b3f9bcac 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -18,6 +18,13 @@ //! domain pattern, and a lookup for a concrete host resolves through the //! RFC 0002 candidate list ([`remote_domain_candidates`]), letting the most //! specific stored decision win. +//! +//! Remote permissions have one product-scoped exception. A product whose label +//! is listed in [`truapi_platform::REMOTE_PERMISSION_TRUSTED_LABELS`] reads as +//! authorized for every remote permission while nothing is stored, and never +//! reaches the prompt callback. A stored decision still wins, so a denial +//! written through the admin surface revokes the grant. Device permissions, +//! identity disclosure and account access are never covered. use parity_scale_codec::{Decode, Encode}; @@ -27,7 +34,7 @@ use truapi::latest::{ }; use truapi_platform::{ CoreStorage, CoreStorageKey, PermissionAuthorizationRequest, PermissionAuthorizationStatus, - Permissions, remote_domain_candidates, + Permissions, has_trusted_remote_permissions, remote_domain_candidates, }; /// Persisted answer for a single permission request. Keep `Authorized` at @@ -76,6 +83,8 @@ pub struct PermissionsService<'a, S: CoreStorage + ?Sized, P: Permissions + ?Siz storage: &'a S, prompt: &'a P, product_id: &'a str, + /// Whether `product_id` holds every remote permission without prompting. + remote_auto_granted: bool, } impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a, S, P> { @@ -85,6 +94,7 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a storage, prompt, product_id, + remote_auto_granted: has_trusted_remote_permissions(product_id), } } @@ -110,11 +120,8 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a request: &RemotePermissionRequest, ) -> Result { let Some(domains) = requested_domains(request) else { - return authorization_status( - self.storage, - CoreStorageKey::remote_permission_authorization(self.product_id, request), - ) - .await; + let key = CoreStorageKey::remote_permission_authorization(self.product_id, request); + return Ok(self.effective_remote_status(peek_stored(self.storage, key).await?)); }; // An empty bundle grants access to nothing. Reporting it as authorized // would let a malformed request read as a grant, so fail closed. @@ -123,7 +130,7 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a } let mut combined = PermissionAuthorizationStatus::Authorized; for domain in domains { - match self.stored_domain_status(domain).await? { + match self.effective_domain_status(domain).await? { PermissionAuthorizationStatus::Denied => { return Ok(PermissionAuthorizationStatus::Denied); } @@ -136,12 +143,14 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a Ok(combined) } - /// Stored decision covering one concrete host or pattern. + /// Effective decision covering one concrete host or pattern. /// /// Walks [`remote_domain_candidates`] most-specific-first and returns the /// first stored decision, so an explicit grant for `api.example.com` - /// survives a denial of `*.example.com` and vice versa. - async fn stored_domain_status( + /// survives a denial of `*.example.com` and vice versa. With no decision on + /// any candidate the answer comes from [`Self::effective_remote_status`], + /// which is where a trusted product's auto-grant applies. + async fn effective_domain_status( &self, domain: &str, ) -> Result { @@ -151,7 +160,24 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a return Ok(stored.into()); } } - Ok(PermissionAuthorizationStatus::NotDetermined) + Ok(self.effective_remote_status(None)) + } + + /// Resolve a stored remote decision into the status the caller acts on. + /// + /// A stored decision always wins, so a `Denied` written through the admin + /// surface revokes a trusted product's grant. With nothing stored a trusted + /// product is authorized without prompting and without persisting anything; + /// every other product stays undecided and prompts. + fn effective_remote_status( + &self, + stored: Option, + ) -> PermissionAuthorizationStatus { + match stored { + Some(stored) => stored.into(), + None if self.remote_auto_granted => PermissionAuthorizationStatus::Authorized, + None => PermissionAuthorizationStatus::NotDetermined, + } } /// Returns the stored authorization status for a permission request @@ -271,8 +297,9 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) -> Result { let Some(domains) = requested_domains(&request).map(<[String]>::to_vec) else { let key = CoreStorageKey::remote_permission_authorization(self.product_id, &request); - if let Some(cached) = peek_stored(self.storage, key.clone()).await? { - return Ok(cached.into()); + match self.effective_remote_status(peek_stored(self.storage, key.clone()).await?) { + PermissionAuthorizationStatus::NotDetermined => {} + decided => return Ok(decided), } // See `check_or_prompt_device`: persist only a genuine user decision; // transient callback errors leave the authorization ask/default. @@ -289,7 +316,7 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a let mut undetermined = Vec::new(); for domain in &domains { - match self.stored_domain_status(domain).await? { + match self.effective_domain_status(domain).await? { PermissionAuthorizationStatus::Denied => { return Ok(PermissionAuthorizationStatus::Denied); } @@ -733,6 +760,317 @@ mod tests { ); } + /// A trusted product, so `ScriptedPrompt` is built with no scripted answers: + /// reaching either callback panics rather than silently answering. + fn trusted_service<'a>( + storage: &'a MemStorage, + prompt: &'a ScriptedPrompt, + ) -> PermissionsService<'a, MemStorage, ScriptedPrompt> { + PermissionsService::new(storage, prompt, "peopl.dot") + } + + fn remote(permission: RemotePermission) -> RemotePermissionRequest { + RemotePermissionRequest { permission } + } + + fn every_remote_permission() -> Vec { + vec![ + RemotePermission::Remote { + domains: vec!["example.com".to_string()], + }, + RemotePermission::WebRtc, + RemotePermission::ChainSubmit, + RemotePermission::PreimageSubmit, + RemotePermission::StatementSubmit, + ] + } + + #[test] + fn a_trusted_product_holds_every_remote_permission_without_prompting() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + + for permission in every_remote_permission() { + assert_eq!( + futures::executor::block_on( + service.check_or_prompt_remote(remote(permission.clone())) + ) + .unwrap(), + PermissionAuthorizationStatus::Authorized, + "{permission:?} must be granted to a trusted product without a prompt" + ); + } + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 0); + assert!(prompt.domains_asked().is_empty()); + } + + #[test] + fn a_trusted_product_is_authorized_for_any_domain() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + + for domains in [ + vec!["a.com"], + vec!["deep.api.example.com"], + vec!["*"], + vec!["a.com", "b.com", "*.c.com"], + ] { + assert_eq!( + futures::executor::block_on(service.peek_remote(&remote_domains(&domains))) + .unwrap(), + PermissionAuthorizationStatus::Authorized, + "{domains:?} must be authorized for a trusted product" + ); + } + } + + #[test] + fn a_trusted_product_reports_authorized_to_the_admin_surface() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + + for request in [ + remote(RemotePermission::ChainSubmit), + remote_domains(&["a.com"]), + ] { + assert_eq!( + futures::executor::block_on( + service.authorization_status(&PermissionAuthorizationRequest::Remote(request)) + ) + .unwrap(), + PermissionAuthorizationStatus::Authorized + ); + } + } + + #[test] + fn a_trusted_product_grant_is_not_persisted() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + let request = remote(RemotePermission::ChainSubmit); + + futures::executor::block_on(service.check_or_prompt_remote(request.clone())).unwrap(); + + assert_eq!( + futures::executor::block_on(storage.read_core_storage( + CoreStorageKey::remote_permission_authorization("peopl.dot", &request) + )) + .unwrap(), + None, + "an auto-granted permission must leave the slot free for a later user decision" + ); + } + + #[test] + fn a_stored_denial_outranks_a_trusted_product_grant() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + + for request in [ + remote(RemotePermission::ChainSubmit), + remote_domains(&["a.com"]), + ] { + futures::executor::block_on(service.set_authorization_status( + &PermissionAuthorizationRequest::Remote(request.clone()), + PermissionAuthorizationStatus::Denied, + )) + .unwrap(); + + assert_eq!( + futures::executor::block_on(service.peek_remote(&request)).unwrap(), + PermissionAuthorizationStatus::Denied + ); + assert_eq!( + futures::executor::block_on(service.check_or_prompt_remote(request)).unwrap(), + PermissionAuthorizationStatus::Denied + ); + } + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_wildcard_denial_revokes_every_domain_for_a_trusted_product() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + + futures::executor::block_on(service.set_authorization_status( + &PermissionAuthorizationRequest::Remote(remote_domains(&["*"])), + PermissionAuthorizationStatus::Denied, + )) + .unwrap(); + + for domain in ["a.com", "deep.api.example.com"] { + assert_eq!( + futures::executor::block_on(service.peek_remote(&remote_domains(&[domain]))) + .unwrap(), + PermissionAuthorizationStatus::Denied, + "the wildcard denial must be how a trusted product's domain access is revoked" + ); + } + } + + #[test] + fn clearing_a_denial_restores_a_trusted_product_grant() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + let request = PermissionAuthorizationRequest::Remote(remote(RemotePermission::ChainSubmit)); + + for status in [ + PermissionAuthorizationStatus::Denied, + PermissionAuthorizationStatus::NotDetermined, + ] { + futures::executor::block_on(service.set_authorization_status(&request, status)) + .unwrap(); + } + + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::Authorized + ); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn a_trusted_label_on_every_product_network_is_trusted() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + + for product_id in [ + "peopl.dot", + "peopl.paseo", + "peopl.test", + "dim2.dot", + "stash.dot", + ] { + let service = PermissionsService::new(&storage, &prompt, product_id); + assert_eq!( + futures::executor::block_on( + service.peek_remote(&remote(RemotePermission::ChainSubmit)) + ) + .unwrap(), + PermissionAuthorizationStatus::Authorized, + "{product_id} must be trusted on every accepted product network" + ); + } + } + + #[test] + fn a_subdomain_of_a_trusted_label_is_not_trusted() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![true]); + let service = PermissionsService::new(&storage, &prompt, "app.peopl.dot"); + let request = remote(RemotePermission::ChainSubmit); + + assert_eq!( + futures::executor::block_on(service.peek_remote(&request)).unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + assert_eq!( + futures::executor::block_on(service.check_or_prompt_remote(request)).unwrap(), + PermissionAuthorizationStatus::Authorized + ); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_localhost_product_is_not_trusted() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![true, true]); + + for product_id in ["localhost", "localhost:3000"] { + let service = PermissionsService::new(&storage, &prompt, product_id); + let request = remote(RemotePermission::ChainSubmit); + assert_eq!( + futures::executor::block_on(service.peek_remote(&request)).unwrap(), + PermissionAuthorizationStatus::NotDetermined, + "{product_id} carries no label to match and must prompt" + ); + futures::executor::block_on(service.check_or_prompt_remote(request)).unwrap(); + } + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 2); + } + + #[test] + fn an_untrusted_product_still_prompts_for_every_remote_permission() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![true; 5]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + + for permission in every_remote_permission() { + futures::executor::block_on(service.check_or_prompt_remote(remote(permission))) + .unwrap(); + } + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 5); + } + + #[test] + fn a_trusted_product_still_prompts_for_device_permissions() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![false], vec![]); + let service = trusted_service(&storage, &prompt); + + assert_eq!( + futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + assert_eq!( + futures::executor::block_on( + service.check_or_prompt_device(HostDevicePermissionRequest::Camera) + ) + .unwrap(), + PermissionAuthorizationStatus::Denied, + "a trusted product's device answer is the user's, not the whitelist's" + ); + assert_eq!(prompt.device_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_trusted_product_is_not_authorized_for_identity_disclosure_or_account_access() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + + for request in [ + PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationRequest::AccountAccess { + target_product_id: "other.dot".to_string(), + }, + ] { + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::NotDetermined, + "{request:?} is outside the remote-permission whitelist" + ); + } + } + + #[test] + fn an_empty_domain_bundle_is_denied_for_a_trusted_product() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = trusted_service(&storage, &prompt); + + assert_eq!( + futures::executor::block_on(service.peek_remote(&remote_domains(&[]))).unwrap(), + PermissionAuthorizationStatus::Denied + ); + assert_eq!( + futures::executor::block_on(service.check_or_prompt_remote(remote_domains(&[]))) + .unwrap(), + PermissionAuthorizationStatus::Denied, + "an empty bundle grants nothing, so failing closed outranks the whitelist" + ); + assert_eq!(prompt.remote_calls.load(Ordering::SeqCst), 0); + } + #[test] fn device_and_remote_caches_are_independent() { let storage = MemStorage::default(); From 4b7deb9cda862165356b8d40e370470637f0c36d Mon Sep 17 00:00:00 2001 From: Filippo Vecchiato Date: Tue, 18 Aug 2026 20:06:24 +0100 Subject: [PATCH 2/2] ci: gate the truapi interface check on new RFCs only --- .github/workflows/check-rfc.yml | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/check-rfc.yml b/.github/workflows/check-rfc.yml index e90d846a1..8fb0e43fc 100644 --- a/.github/workflows/check-rfc.yml +++ b/.github/workflows/check-rfc.yml @@ -19,25 +19,24 @@ jobs: fetch-depth: 0 persist-credentials: false - # ── Check that the RFC document exists ── - - name: Require RFC document + # ── A new RFC must land with the interfaces it specifies ── + # Edits to an already-accepted RFC (addenda recording what the host + # actually does) are not gated: they describe behavior that often lives + # entirely in the runtime crates. + - name: Require truapi interface changes for new RFCs run: | BASE=${{ github.event.pull_request.base.sha }} - RFC_CHANGED=$(git diff --name-only "$BASE"...HEAD -- docs/rfcs/) - if [ -z "$RFC_CHANGED" ]; then - echo "::error::RFC PRs must include an RFC document in docs/rfcs/." - exit 1 + NEW_RFCS=$(git diff --name-only --diff-filter=A "$BASE"...HEAD -- docs/rfcs/ ':!docs/rfcs/_index.md') + if [ -z "$NEW_RFCS" ]; then + echo "No new RFC documents; nothing to gate." + exit 0 fi - echo "RFC changes detected:" - echo "$RFC_CHANGED" + echo "New RFCs:" + echo "$NEW_RFCS" - # ── Check that truapi interfaces are updated ── - - name: Require truapi interface changes - run: | - BASE=${{ github.event.pull_request.base.sha }} API_CHANGED=$(git diff --name-only "$BASE"...HEAD -- rust/crates/truapi/) if [ -z "$API_CHANGED" ]; then - echo "::error::RFC PRs must include corresponding changes to the truapi interfaces in rust/crates/truapi/." + echo "::error::PRs adding an RFC must include corresponding changes to the truapi interfaces in rust/crates/truapi/." exit 1 fi echo "API changes detected:"