Skip to content

Commit fd47c43

Browse files
committed
feat: permission annotations in Rust doc comments and codegen
Embed `# Permissions` sections in API trait method doc comments so hosts can extract permission requirements directly from the rustdoc JSON output. The codegen pipeline parses these sections and emits `permissions` metadata in the generated TypeScript playground/explorer service descriptors. Methods with no permission requirements carry no `# Permissions` section at all. Annotated methods declare a combination of: - **auth**: whether authentication is required - **prompt**: user-facing confirmation dialog - **permission**: named permission gate (RemotePermission/DevicePermission) - **denial_error**: error variant returned on denial Includes `docs/host-permission-requirements.md` — the canonical permission matrix for all TrUAPI methods across tiers.
1 parent b555d69 commit fd47c43

15 files changed

Lines changed: 467 additions & 0 deletions

File tree

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
# TrUAPI Host Permission Requirements
2+
3+
Every TrUAPI method falls into one of four permission tiers. Hosts **must** enforce the requirements listed below before executing each call.
4+
5+
## Legend
6+
7+
| Column | Meaning |
8+
|--------|---------|
9+
| **Auth** | User must be logged in (`NotConnected` error if not) |
10+
| **Prompt** | Host must show a user-facing confirmation UI before proceeding |
11+
| **Permission type** | Which permission system governs the prompt |
12+
13+
---
14+
15+
## 1. No permission required
16+
17+
These methods work without login and without any user prompt.
18+
19+
| Method | Notes |
20+
|--------|-------|
21+
| `host_handshake` | Protocol negotiation |
22+
| `host_feature_supported` | Capability query |
23+
| `host_local_storage_read` | Product-scoped storage |
24+
| `host_local_storage_write` | Product-scoped storage |
25+
| `host_local_storage_clear` | Product-scoped storage |
26+
| `host_theme_subscribe` | UI theming |
27+
| `host_account_connection_status_subscribe` | Read-only status |
28+
| `host_request_login` | Presents login UI (user controls outcome) |
29+
| `remote_chain_head_follow_subscribe` | Read-only chain data |
30+
| `remote_chain_head_header` | Read-only chain data |
31+
| `remote_chain_head_body` | Read-only chain data |
32+
| `remote_chain_head_storage` | Read-only chain data |
33+
| `remote_chain_head_call` | Read-only chain data |
34+
| `remote_chain_head_unpin` | Read-only chain data |
35+
| `remote_chain_head_continue` | Read-only chain data |
36+
| `remote_chain_head_stop_operation` | Read-only chain data |
37+
| `remote_chain_spec_genesis_hash` | Read-only chain data |
38+
| `remote_chain_spec_chain_name` | Read-only chain data |
39+
| `remote_chain_spec_properties` | Read-only chain data |
40+
| `remote_statement_store_subscribe` | Read-only statement data |
41+
| `remote_preimage_lookup_subscribe` | Read-only preimage data |
42+
43+
---
44+
45+
## 2. Authentication required (no additional prompt)
46+
47+
User must be logged in. The host does **not** show a separate permission prompt — access is granted to any authenticated product.
48+
49+
| Method | Error on no auth |
50+
|--------|------------------|
51+
| `host_account_get` | `NotConnected` / `Rejected` |
52+
| `host_account_get_alias` | `NotConnected` / `Rejected` |
53+
| `host_account_create_proof` | `NotConnected` / `Rejected` |
54+
| `host_derive_entropy` | `Unknown` |
55+
| `host_get_legacy_accounts` | `Rejected` |
56+
| `host_chat_list_subscribe` | Requires active session |
57+
| `host_chat_action_subscribe` | Requires active session |
58+
| `product_chat_custom_message_render_subscribe` | Requires active session |
59+
| `host_payment_status_subscribe` | `PaymentNotFound` |
60+
61+
---
62+
63+
## 3. Authentication + user confirmation prompt
64+
65+
These methods require login **and** an explicit user-facing prompt before proceeding. The host must present a confirmation UI and return `Rejected` / `PermissionDenied` / `Denied` if the user declines.
66+
67+
### 3a. Signing & transaction confirmation
68+
69+
The host shows the user what is being signed or submitted, and the user approves or rejects.
70+
71+
| Method | Prompt trigger | Error on denial |
72+
|--------|---------------|-----------------|
73+
| `host_create_transaction` | Always — user reviews transaction details | `Rejected` / `PermissionDenied` |
74+
| `host_create_transaction_with_legacy_account` | Always — user reviews transaction details | `Rejected` / `PermissionDenied` |
75+
| `host_sign_raw` | Always — user reviews payload | `Rejected` / `PermissionDenied` |
76+
| `host_sign_payload` | Always — user reviews extrinsic payload | `Rejected` / `PermissionDenied` |
77+
| `host_sign_raw_with_legacy_account` | Always — user reviews payload | `Rejected` / `PermissionDenied` |
78+
| `host_sign_payload_with_legacy_account` | Always — user reviews extrinsic payload | `Rejected` / `PermissionDenied` |
79+
80+
### 3b. Identity disclosure
81+
82+
| Method | Prompt trigger | Error on denial |
83+
|--------|---------------|-----------------|
84+
| `host_get_user_id` | Always — user approves revealing their primary DotNS name to the product | `PermissionDenied` |
85+
86+
### 3c. Payment confirmation
87+
88+
| Method | Prompt trigger | Error on denial |
89+
|--------|---------------|-----------------|
90+
| `host_payment_balance_subscribe` | First call — user approves balance disclosure | `PermissionDenied` |
91+
| `host_payment_request` | Always — user approves spend | `Rejected` |
92+
| `host_payment_top_up` | Depends on source — host may prompt for `ProductAccount` source | `InsufficientFunds` / `InvalidSource` |
93+
94+
### 3d. Chat room & bot registration
95+
96+
| Method | Prompt trigger | Error on denial |
97+
|--------|---------------|-----------------|
98+
| `host_chat_create_room` | Host may prompt on first room creation | `PermissionDenied` |
99+
| `host_chat_register_bot` | Host may prompt on first bot registration | `PermissionDenied` |
100+
| `host_chat_post_message` | No prompt (already authorized by room creation) | `MessageTooLarge` |
101+
102+
### 3e. Statement store proof creation
103+
104+
| Method | Prompt trigger | Error on denial |
105+
|--------|---------------|-----------------|
106+
| ~~`remote_statement_store_create_proof`~~ | **Deprecated** — use `create_proof_authorized` instead ||
107+
| `remote_statement_store_create_proof_authorized` | No per-call prompt — uses pre-allocated `AutoSigning` allowance from `host_request_resource_allocation` | `UnableToSign` |
108+
109+
---
110+
111+
## 4. Device & remote permissions (RFC 0002)
112+
113+
These permissions use the RFC 0002 permission model: the host prompts once, persists the user's decision indefinitely, and does not re-prompt on subsequent requests.
114+
115+
### 4a. Explicit permission requests
116+
117+
Products may pre-request permissions; the host shows a one-time prompt.
118+
119+
| Method | Permission |
120+
|--------|------------|
121+
| `host_device_permission` | Requests one `DevicePermission` variant |
122+
| `remote_permission` | Requests one `RemotePermission` variant |
123+
124+
**`DevicePermission` variants:** `Notifications`, `Camera`, `Microphone`, `Bluetooth`, `NFC`, `Location`, `Clipboard`, `OpenUrl`, `Biometrics`
125+
126+
**`RemotePermission` variants:** `Remote { domains }`, `WebRtc`, `ChainSubmit`, `PreimageSubmit`, `StatementSubmit`
127+
128+
### 4b. Implicit permission triggers
129+
130+
These business methods **automatically trigger** a remote permission prompt if the corresponding permission has not been granted yet. The host should prompt for the permission before executing the call.
131+
132+
| Method | Implicitly requires | Error on denial |
133+
|--------|-------------------|-----------------|
134+
| `host_navigate_to` | `DevicePermission::OpenUrl` | `PermissionDenied` |
135+
| `host_push_notification` | `DevicePermission::Notifications` | `Unknown` |
136+
| `host_push_notification_cancel` | `DevicePermission::Notifications` (same grant) | `Unknown` |
137+
| `remote_chain_transaction_broadcast` | `RemotePermission::ChainSubmit` | `GenericError` |
138+
| `remote_chain_transaction_stop` | `RemotePermission::ChainSubmit` (same grant) | `GenericError` |
139+
| `remote_preimage_submit` | `RemotePermission::PreimageSubmit` | `GenericError` |
140+
| `remote_statement_store_submit` | `RemotePermission::StatementSubmit` | `GenericError` |
141+
142+
### 4c. Resource allocation
143+
144+
Pre-allocates capabilities that relax per-call prompts for subsequent operations.
145+
146+
| Method | Notes |
147+
|--------|-------|
148+
| `host_request_resource_allocation` | User approves each `AllocatableResource`. Grants like `AutoSigning` enable `create_proof_authorized` without per-call prompts. Per-resource outcome: `Allocated` / `Rejected` / `NotAvailable`. |
149+
150+
**`AllocatableResource` variants:** `StatementStoreAllowance`, `BulletinAllowance`, `SmartContractAllowance`, `AutoSigning`
151+
152+
---
153+
154+
## Quick reference matrix
155+
156+
| Method | Auth | Prompt | Permission type |
157+
|--------|:----:|:------:|----------------|
158+
| `host_handshake` | | ||
159+
| `host_feature_supported` | | ||
160+
| `host_push_notification` | | | DevicePermission::Notifications |
161+
| `host_push_notification_cancel` | | | DevicePermission::Notifications |
162+
| `host_navigate_to` | | | DevicePermission::OpenUrl |
163+
| `host_device_permission` | | | Explicit prompt |
164+
| `remote_permission` | | | Explicit prompt |
165+
| `host_local_storage_read` | | ||
166+
| `host_local_storage_write` | | ||
167+
| `host_local_storage_clear` | | ||
168+
| `host_account_connection_status_subscribe` | | ||
169+
| `host_account_get` | * | ||
170+
| `host_account_get_alias` | * | ||
171+
| `host_account_create_proof` | * | ||
172+
| `host_get_legacy_accounts` | * | ||
173+
| `host_create_transaction` | * | * | Signing confirmation |
174+
| `host_create_transaction_with_legacy_account` | * | * | Signing confirmation |
175+
| `host_sign_raw_with_legacy_account` | * | * | Signing confirmation |
176+
| `host_sign_payload_with_legacy_account` | * | * | Signing confirmation |
177+
| `host_chat_create_room` | * | * | Chat registration |
178+
| `host_chat_register_bot` | * | * | Chat registration |
179+
| `host_chat_list_subscribe` | * | ||
180+
| `host_chat_post_message` | * | ||
181+
| `host_chat_action_subscribe` | * | ||
182+
| `product_chat_custom_message_render_subscribe` | * | ||
183+
| `remote_statement_store_subscribe` | | ||
184+
| ~~`remote_statement_store_create_proof`~~ | * | * | **Deprecated** |
185+
| `remote_statement_store_create_proof_authorized` | * | | Pre-allocated allowance |
186+
| `remote_statement_store_submit` | | | RemotePermission::StatementSubmit |
187+
| `remote_preimage_lookup_subscribe` | | ||
188+
| `remote_preimage_submit` | | | RemotePermission::PreimageSubmit |
189+
| `remote_chain_head_follow_subscribe` | | ||
190+
| `remote_chain_head_header` | | ||
191+
| `remote_chain_head_body` | | ||
192+
| `remote_chain_head_storage` | | ||
193+
| `remote_chain_head_call` | | ||
194+
| `remote_chain_head_unpin` | | ||
195+
| `remote_chain_head_continue` | | ||
196+
| `remote_chain_head_stop_operation` | | ||
197+
| `remote_chain_spec_genesis_hash` | | ||
198+
| `remote_chain_spec_chain_name` | | ||
199+
| `remote_chain_spec_properties` | | ||
200+
| `remote_chain_transaction_broadcast` | | | RemotePermission::ChainSubmit |
201+
| `remote_chain_transaction_stop` | | | RemotePermission::ChainSubmit |
202+
| `host_theme_subscribe` | | ||
203+
| `host_derive_entropy` | * | ||
204+
| `host_get_user_id` | * | * | Identity disclosure |
205+
| `host_request_login` | | | — (presents login UI) |
206+
| `host_sign_raw` | * | * | Signing confirmation |
207+
| `host_sign_payload` | * | * | Signing confirmation |
208+
| `host_payment_balance_subscribe` | * | * | Balance disclosure |
209+
| `host_payment_top_up` | * | | Source-dependent |
210+
| `host_payment_request` | * | * | Payment confirmation |
211+
| `host_payment_status_subscribe` | * | ||
212+
| `host_request_resource_allocation` | * | * | Per-resource prompt |

js/packages/truapi/src/playground/services-types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ export interface MethodInfo {
1414
responseType?: string;
1515
/** DataType id of the method's error. */
1616
errorType?: string;
17+
/** Permission requirements for the method. Absent means no permissions needed. */
18+
permissions?: MethodPermissions;
19+
}
20+
21+
export interface MethodPermissions {
22+
auth?: string;
23+
prompt?: string;
24+
permissionType?: string;
25+
denialError?: string;
1726
}
1827

1928
export interface ServiceInfo {

rust/crates/truapi-codegen/src/ts/playground.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ fn generate_playground_services_code(
136136
{
137137
writeln!(out, " errorType: {},", ts_string_literal(&id)).unwrap();
138138
}
139+
if let Some(perms) = &docs.permissions {
140+
emit_permissions(&mut out, perms);
141+
}
139142
writeln!(out, " }},").unwrap();
140143
}
141144

@@ -158,23 +161,37 @@ fn generate_playground_services_code(
158161
pub(super) struct PlaygroundDocs {
159162
pub(super) description: Option<String>,
160163
pub(super) client_example: Option<String>,
164+
pub(super) permissions: Option<MethodPermissions>,
165+
}
166+
167+
/// Structured permission requirements extracted from a `# Permissions` doc section.
168+
#[derive(Debug)]
169+
pub(super) struct MethodPermissions {
170+
pub(super) auth: Option<String>,
171+
pub(super) prompt: Option<String>,
172+
pub(super) permission_type: Option<String>,
173+
pub(super) denial_error: Option<String>,
161174
}
162175

163176
pub(super) fn split_playground_docs(docs: Option<&str>) -> Result<PlaygroundDocs> {
164177
let Some(docs) = docs else {
165178
return Ok(PlaygroundDocs {
166179
description: None,
167180
client_example: None,
181+
permissions: None,
168182
});
169183
};
170184

171185
let mut description = Vec::new();
172186
let mut client_example = Vec::new();
187+
let mut permission_lines = Vec::new();
173188
let mut in_client_example = false;
189+
let mut in_permissions = false;
174190
for line in docs.lines() {
175191
let trimmed = line.trim();
176192
if trimmed == "```ts" {
177193
in_client_example = true;
194+
in_permissions = false;
178195
continue;
179196
}
180197
if in_client_example && trimmed == "```" {
@@ -183,17 +200,69 @@ pub(super) fn split_playground_docs(docs: Option<&str>) -> Result<PlaygroundDocs
183200
}
184201
if in_client_example {
185202
client_example.push(line);
203+
continue;
204+
}
205+
if trimmed == "# Permissions" {
206+
in_permissions = true;
207+
continue;
208+
}
209+
if in_permissions && trimmed.starts_with("# ") {
210+
in_permissions = false;
211+
}
212+
if in_permissions {
213+
permission_lines.push(trimmed);
186214
} else {
187215
description.push(line);
188216
}
189217
}
190218

191219
let description = trim_doc_lines(&description);
192220
let client_example = trim_doc_lines(&client_example);
221+
let permissions = parse_permissions(&permission_lines);
193222

194223
Ok(PlaygroundDocs {
195224
description,
196225
client_example,
226+
permissions,
227+
})
228+
}
229+
230+
/// Parses `- **key**: value` lines from a `# Permissions` section.
231+
fn parse_permissions(lines: &[&str]) -> Option<MethodPermissions> {
232+
let mut auth = None;
233+
let mut prompt = None;
234+
let mut permission_type = None;
235+
let mut denial_error = None;
236+
237+
for line in lines {
238+
let Some(rest) = line.strip_prefix("- **") else {
239+
continue;
240+
};
241+
let Some((key, value)) = rest.split_once("**:") else {
242+
continue;
243+
};
244+
let value = value.trim();
245+
if value.is_empty() {
246+
continue;
247+
}
248+
match key {
249+
"auth" => auth = Some(value.to_string()),
250+
"prompt" => prompt = Some(value.to_string()),
251+
"permission" => permission_type = Some(value.to_string()),
252+
"denial_error" => denial_error = Some(value.to_string()),
253+
_ => {}
254+
}
255+
}
256+
257+
if auth.is_none() && prompt.is_none() && permission_type.is_none() && denial_error.is_none() {
258+
return None;
259+
}
260+
261+
Some(MethodPermissions {
262+
auth,
263+
prompt,
264+
permission_type,
265+
denial_error,
197266
})
198267
}
199268

@@ -238,6 +307,23 @@ fn validate_example_docs(trait_name: &str, method_name: &str, docs: Option<&str>
238307
Ok(())
239308
}
240309

310+
fn emit_permissions(out: &mut String, perms: &MethodPermissions) {
311+
writeln!(out, " permissions: {{").unwrap();
312+
if let Some(auth) = &perms.auth {
313+
writeln!(out, " auth: {},", ts_string_literal(auth)).unwrap();
314+
}
315+
if let Some(prompt) = &perms.prompt {
316+
writeln!(out, " prompt: {},", ts_string_literal(prompt)).unwrap();
317+
}
318+
if let Some(ptype) = &perms.permission_type {
319+
writeln!(out, " permissionType: {},", ts_string_literal(ptype)).unwrap();
320+
}
321+
if let Some(denial) = &perms.denial_error {
322+
writeln!(out, " denialError: {},", ts_string_literal(denial)).unwrap();
323+
}
324+
writeln!(out, " }},").unwrap();
325+
}
326+
241327
pub(super) fn playground_type_name(value: &str) -> String {
242328
value.replace("T.", "")
243329
}

0 commit comments

Comments
 (0)