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
25 changes: 12 additions & 13 deletions .github/workflows/check-rfc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down
3 changes: 3 additions & 0 deletions docs/rfcs/0002-permission-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<RemotePermission>`). 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.
Comment thread
valentinfernandez1 marked this conversation as resolved.

## 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.
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/truapi-host-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` changes it
for the lifetime of the process. Switching disconnects active product
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/truapi-host-cli/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ use `exec '/script <path>'` 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:`.

Expand Down
2 changes: 1 addition & 1 deletion rust/crates/truapi-host-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions rust/crates/truapi-platform/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
120 changes: 118 additions & 2 deletions rust/crates/truapi-platform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -257,14 +258,54 @@ 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,
) -> Result<String, RuntimeConfigValidationError> {
let trimmed = product_id.trim();
require_non_empty("product_id", trimmed)?;
let normalized = trimmed.nfc().collect::<String>().to_lowercase();
if has_dotns_tld(&normalized)
if has_product_id_tld(&normalized)
|| normalized == "localhost"
|| normalized.starts_with("localhost:")
{
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions rust/crates/truapi-platform/tests/bounds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(|_| ()),
Expand Down
28 changes: 26 additions & 2 deletions rust/crates/truapi-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
|
Expand All @@ -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
Expand Down
21 changes: 19 additions & 2 deletions rust/crates/truapi-server/src/host_logic/dotns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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",
Expand Down
Loading