An encrypted personal cloud built on the AT Protocol.

Add inbox command for discovering shared grants via appview

Query the appview's /api/inbox endpoint to list grants where the
current user is the recipient. Signs requests with Ed25519 identity
key using the Opake-Ed25519 auth scheme. Supports --long format and
auto-pagination.

Closes the Phase 2 "inbox" gap — grants live on the owner's PDS,
so discovery requires the appview's recipient-indexed view.

sans-self.org 77f811b3 cf3b53a6

Waiting for spindle ...
+677
+1
CHANGELOG.md
··· 10 10 - Remove bearer token authentication fallback from AppView (#109) 11 11 12 12 ### Added 13 + - Add inbox CLI command for discovering shared grants via appview (#128) 13 14 - Audit workspace dependencies for consolidation and upgrades (#110) 14 15 - Add AppView production readiness: clap, DID auth, XDG, health, docs (#101) 15 16 - Update docs to reflect module directory restructuring (#95)
+2
crates/opake-cli/src/commands/accounts.rs
··· 53 53 config::save_config(&Config { 54 54 default_did: None, 55 55 accounts: BTreeMap::new(), 56 + appview_url: None, 56 57 }) 57 58 .unwrap(); 58 59 ··· 75 76 config::save_config(&Config { 76 77 default_did: Some("did:plc:alice".into()), 77 78 accounts, 79 + appview_url: None, 78 80 }) 79 81 .unwrap(); 80 82
+142
crates/opake-cli/src/commands/inbox.rs
··· 1 + use anyhow::{Context, Result}; 2 + use clap::Args; 3 + use opake_core::client::{fetch_inbox_all, InboxGrant, Session}; 4 + 5 + use crate::commands::Execute; 6 + use crate::config; 7 + use crate::identity; 8 + use crate::session::CommandContext; 9 + use crate::transport::ReqwestTransport; 10 + 11 + #[derive(Args)] 12 + /// List grants shared with you (via appview) 13 + pub struct InboxCommand { 14 + /// Show long format with document URIs and notes 15 + #[arg(short, long)] 16 + long: bool, 17 + 18 + /// AppView URL (overrides OPAKE_APPVIEW_URL and config) 19 + #[arg(long)] 20 + appview: Option<String>, 21 + } 22 + 23 + fn format_short(grants: &[InboxGrant]) -> String { 24 + grants 25 + .iter() 26 + .map(|g| { 27 + let perms = g.permissions.as_deref().unwrap_or("—"); 28 + format!("{}\t{}\t{}", g.owner_did, perms, g.uri) 29 + }) 30 + .collect::<Vec<_>>() 31 + .join("\n") 32 + } 33 + 34 + fn format_long(grants: &[InboxGrant]) -> String { 35 + grants 36 + .iter() 37 + .map(|g| { 38 + let perms = g.permissions.as_deref().unwrap_or("—"); 39 + let note = g 40 + .note 41 + .as_deref() 42 + .map(|n| format!("\n note: {n}")) 43 + .unwrap_or_default(); 44 + format!( 45 + "{:>10} {} {}\n doc: {}\n grant: {}{}", 46 + perms, g.created_at, g.owner_did, g.document_uri, g.uri, note, 47 + ) 48 + }) 49 + .collect::<Vec<_>>() 50 + .join("\n") 51 + } 52 + 53 + impl Execute for InboxCommand { 54 + async fn execute(self, ctx: &CommandContext) -> Result<Option<Session>> { 55 + let appview_url = config::resolve_appview_url(self.appview.as_deref())?; 56 + 57 + let id = identity::load_identity(&ctx.did) 58 + .context("no identity found — run `opake login` first")?; 59 + let signing_key = id 60 + .signing_key_bytes()? 61 + .context("no signing key in identity — re-run `opake login` to migrate")?; 62 + 63 + let transport = ReqwestTransport::new(); 64 + let grants = fetch_inbox_all(&transport, &appview_url, &ctx.did, &signing_key).await?; 65 + 66 + if grants.is_empty() { 67 + println!("no incoming grants"); 68 + return Ok(None); 69 + } 70 + 71 + if self.long { 72 + println!("{}", format_long(&grants)); 73 + } else { 74 + println!("{}", format_short(&grants)); 75 + } 76 + 77 + println!("\n{} grant(s)", grants.len()); 78 + 79 + Ok(None) 80 + } 81 + } 82 + 83 + #[cfg(test)] 84 + mod tests { 85 + use super::*; 86 + 87 + fn grant(owner: &str, doc_suffix: &str, perms: Option<&str>, note: Option<&str>) -> InboxGrant { 88 + InboxGrant { 89 + uri: "at://did:plc:owner/app.opake.cloud.grant/g1".into(), 90 + owner_did: owner.into(), 91 + document_uri: format!("at://did:plc:owner/app.opake.cloud.document/{doc_suffix}"), 92 + permissions: perms.map(|s| s.into()), 93 + note: note.map(|s| s.into()), 94 + created_at: "2026-03-01T12:00:00Z".into(), 95 + } 96 + } 97 + 98 + #[test] 99 + fn short_format() { 100 + let grants = vec![grant("did:plc:alice", "doc1", Some("read"), None)]; 101 + let output = format_short(&grants); 102 + assert!(output.contains("did:plc:alice")); 103 + assert!(output.contains("read")); 104 + assert!(output.contains("grant/g1")); 105 + } 106 + 107 + #[test] 108 + fn short_format_missing_permissions() { 109 + let grants = vec![grant("did:plc:alice", "doc1", None, None)]; 110 + let output = format_short(&grants); 111 + assert!(output.contains('—')); 112 + } 113 + 114 + #[test] 115 + fn long_format_with_note() { 116 + let grants = vec![grant( 117 + "did:plc:alice", 118 + "doc1", 119 + Some("read"), 120 + Some("tax docs"), 121 + )]; 122 + let output = format_long(&grants); 123 + assert!(output.contains("did:plc:alice")); 124 + assert!(output.contains("doc: at://")); 125 + assert!(output.contains("grant: at://")); 126 + assert!(output.contains("note: tax docs")); 127 + } 128 + 129 + #[test] 130 + fn long_format_no_note() { 131 + let grants = vec![grant("did:plc:alice", "doc1", Some("read"), None)]; 132 + let output = format_long(&grants); 133 + assert!(!output.contains("note:")); 134 + } 135 + 136 + #[test] 137 + fn long_format_missing_permissions_shows_em_dash() { 138 + let grants = vec![grant("did:plc:alice", "doc1", None, None)]; 139 + let output = format_long(&grants); 140 + assert!(output.contains('—')); 141 + } 142 + }
+1
crates/opake-cli/src/commands/login.rs
··· 65 65 let mut cfg = config::load_config().unwrap_or(config::Config { 66 66 default_did: None, 67 67 accounts: BTreeMap::new(), 68 + appview_url: None, 68 69 }); 69 70 70 71 cfg.accounts.insert(
+2
crates/opake-cli/src/commands/logout.rs
··· 50 50 config::save_config(&Config { 51 51 default_did: Some("did:plc:alice".into()), 52 52 accounts, 53 + appview_url: None, 53 54 }) 54 55 .unwrap(); 55 56 ··· 78 79 config::save_config(&Config { 79 80 default_did: Some("did:plc:alice".into()), 80 81 accounts, 82 + appview_url: None, 81 83 }) 82 84 .unwrap(); 83 85
+1
crates/opake-cli/src/commands/mod.rs
··· 1 1 pub mod accounts; 2 2 pub mod download; 3 + pub mod inbox; 3 4 pub mod keyring; 4 5 pub mod login; 5 6 pub mod logout;
+1
crates/opake-cli/src/commands/set_default.rs
··· 53 53 Config { 54 54 default_did: Some("did:plc:alice".into()), 55 55 accounts, 56 + appview_url: None, 56 57 } 57 58 } 58 59
+31
crates/opake-cli/src/config.rs
··· 22 22 pub default_did: Option<String>, 23 23 #[serde(default)] 24 24 pub accounts: BTreeMap<String, AccountConfig>, 25 + #[serde(default)] 26 + pub appview_url: Option<String>, 25 27 } 26 28 27 29 /// Per-account configuration stored in the global config.toml. ··· 134 136 let content = fs::read_to_string(&path) 135 137 .with_context(|| format!("no {filename} for {did}: run `opake login` first"))?; 136 138 serde_json::from_str(&content).with_context(|| format!("failed to parse {filename} for {did}")) 139 + } 140 + 141 + /// Resolve the appview URL from (in priority order): 142 + /// 1. Explicit flag value (`--appview`) 143 + /// 2. `OPAKE_APPVIEW_URL` environment variable 144 + /// 3. `appview_url` field in config.toml 145 + /// 146 + /// Returns a clear error if none are set. 147 + pub fn resolve_appview_url(explicit: Option<&str>) -> anyhow::Result<String> { 148 + if let Some(url) = explicit { 149 + return Ok(url.to_string()); 150 + } 151 + 152 + if let Ok(url) = std::env::var("OPAKE_APPVIEW_URL") { 153 + if !url.is_empty() { 154 + return Ok(url); 155 + } 156 + } 157 + 158 + if let Ok(config) = load_config() { 159 + if let Some(url) = config.appview_url { 160 + return Ok(url); 161 + } 162 + } 163 + 164 + anyhow::bail!( 165 + "no appview URL configured — pass --appview <url>, \ 166 + set OPAKE_APPVIEW_URL, or add appview_url to config.toml" 167 + ) 137 168 } 138 169 139 170 #[cfg(test)]
+55
crates/opake-cli/src/config_tests.rs
··· 13 13 Config { 14 14 default_did: Some(did.to_string()), 15 15 accounts, 16 + appview_url: None, 16 17 } 17 18 } 18 19 ··· 51 52 let config = Config { 52 53 default_did: Some("did:plc:alice".into()), 53 54 accounts, 55 + appview_url: None, 54 56 }; 55 57 save_config(&config).unwrap(); 56 58 ··· 240 242 save_config(&Config { 241 243 default_did: Some("did:plc:alice".into()), 242 244 accounts, 245 + appview_url: None, 243 246 }) 244 247 .unwrap(); 245 248 ··· 276 279 assert!(!loaded.accounts.contains_key(did)); 277 280 }); 278 281 } 282 + 283 + // -- resolve_appview_url -- 284 + 285 + #[test] 286 + fn resolve_appview_url_explicit_flag_wins() { 287 + with_test_dir(|_| { 288 + std::env::set_var("OPAKE_APPVIEW_URL", "https://env.test"); 289 + let result = resolve_appview_url(Some("https://flag.test")).unwrap(); 290 + assert_eq!(result, "https://flag.test"); 291 + std::env::remove_var("OPAKE_APPVIEW_URL"); 292 + }); 293 + } 294 + 295 + #[test] 296 + fn resolve_appview_url_env_over_config() { 297 + with_test_dir(|_| { 298 + let mut config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 299 + config.appview_url = Some("https://config.test".into()); 300 + save_config(&config).unwrap(); 301 + 302 + std::env::set_var("OPAKE_APPVIEW_URL", "https://env.test"); 303 + let result = resolve_appview_url(None).unwrap(); 304 + assert_eq!(result, "https://env.test"); 305 + std::env::remove_var("OPAKE_APPVIEW_URL"); 306 + }); 307 + } 308 + 309 + #[test] 310 + fn resolve_appview_url_config_fallback() { 311 + with_test_dir(|_| { 312 + std::env::remove_var("OPAKE_APPVIEW_URL"); 313 + let mut config = test_config("did:plc:alice", "https://pds.test", "alice.test"); 314 + config.appview_url = Some("https://config.test".into()); 315 + save_config(&config).unwrap(); 316 + 317 + let result = resolve_appview_url(None).unwrap(); 318 + assert_eq!(result, "https://config.test"); 319 + }); 320 + } 321 + 322 + #[test] 323 + fn resolve_appview_url_missing_gives_clear_error() { 324 + with_test_dir(|_| { 325 + std::env::remove_var("OPAKE_APPVIEW_URL"); 326 + let err = resolve_appview_url(None).unwrap_err().to_string(); 327 + assert!(err.contains("--appview"), "expected usage hint: {err}"); 328 + assert!( 329 + err.contains("OPAKE_APPVIEW_URL"), 330 + "expected env hint: {err}" 331 + ); 332 + }); 333 + }
+1
crates/opake-cli/src/identity.rs
··· 166 166 config::save_config(&config::Config { 167 167 default_did: Some(did.to_string()), 168 168 accounts, 169 + appview_url: None, 169 170 }) 170 171 .unwrap(); 171 172 }
+1
crates/opake-cli/src/keyring_store.rs
··· 151 151 config::save_config(&config::Config { 152 152 default_did: Some(did.to_string()), 153 153 accounts, 154 + appview_url: None, 154 155 }) 155 156 .unwrap(); 156 157 }
+2
crates/opake-cli/src/main.rs
··· 37 37 SetDefault(commands::set_default::SetDefaultCommand), 38 38 Upload(commands::upload::UploadCommand), 39 39 Download(commands::download::DownloadCommand), 40 + Inbox(commands::inbox::InboxCommand), 40 41 Ls(commands::ls::LsCommand), 41 42 Rm(commands::rm::RmCommand), 42 43 Resolve(commands::resolve::ResolveCommand), ··· 92 93 93 94 Command::Upload(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 94 95 Command::Download(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 96 + Command::Inbox(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 95 97 Command::Ls(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 96 98 Command::Rm(cmd) => run_with_context(as_flag.as_deref(), cmd).await?, 97 99 Command::Resolve(cmd) => run_with_context(as_flag.as_deref(), cmd).await?,
+4
crates/opake-cli/src/session.rs
··· 95 95 config::save_config(&config::Config { 96 96 default_did: Some(did.to_string()), 97 97 accounts, 98 + appview_url: None, 98 99 }) 99 100 .unwrap(); 100 101 } ··· 153 154 config::save_config(&config::Config { 154 155 default_did: Some("did:plc:alice".into()), 155 156 accounts, 157 + appview_url: None, 156 158 }) 157 159 .unwrap(); 158 160 ··· 183 185 config::save_config(&config::Config { 184 186 default_did: Some("did:plc:alice".into()), 185 187 accounts, 188 + appview_url: None, 186 189 }) 187 190 .unwrap(); 188 191 ··· 206 209 config::save_config(&config::Config { 207 210 default_did: None, 208 211 accounts: BTreeMap::new(), 212 + appview_url: None, 209 213 }) 210 214 .unwrap(); 211 215 let err = resolve_context(None).unwrap_err();
+261
crates/opake-core/src/client/appview.rs
··· 1 + // AppView client — fetches inbox grants from the appview JSON API. 2 + // 3 + // Uses the Transport trait for WASM compatibility. Signs each request 4 + // with the caller's Ed25519 key via sign_appview_request. 5 + 6 + use std::time::{SystemTime, UNIX_EPOCH}; 7 + 8 + use crate::client::appview_auth::sign_appview_request; 9 + use crate::client::appview_types::{InboxGrant, InboxResponse}; 10 + use crate::client::transport::{HttpMethod, HttpRequest, Transport}; 11 + use crate::error::Error; 12 + 13 + fn now_unix() -> u64 { 14 + SystemTime::now() 15 + .duration_since(UNIX_EPOCH) 16 + .expect("system clock before unix epoch") 17 + .as_secs() 18 + } 19 + 20 + /// Check an appview JSON response for errors. 21 + fn check_appview_response(status: u16, body: &[u8]) -> Result<(), Error> { 22 + if (200..300).contains(&status) { 23 + return Ok(()); 24 + } 25 + 26 + #[derive(serde::Deserialize)] 27 + struct ErrorBody { 28 + error: Option<String>, 29 + } 30 + 31 + let message = serde_json::from_slice::<ErrorBody>(body) 32 + .ok() 33 + .and_then(|e| e.error) 34 + .unwrap_or_else(|| format!("HTTP {status}")); 35 + 36 + Err(Error::Appview { status, message }) 37 + } 38 + 39 + /// Fetch a single page of inbox grants from the appview. 40 + pub async fn fetch_inbox( 41 + transport: &impl Transport, 42 + appview_url: &str, 43 + did: &str, 44 + signing_key: &[u8; 32], 45 + limit: Option<u32>, 46 + cursor: Option<&str>, 47 + ) -> Result<InboxResponse, Error> { 48 + let path = "/api/inbox"; 49 + let timestamp = now_unix(); 50 + let auth = sign_appview_request("GET", path, did, signing_key, timestamp); 51 + 52 + let mut query = format!("did={did}"); 53 + if let Some(l) = limit { 54 + query.push_str(&format!("&limit={l}")); 55 + } 56 + if let Some(c) = cursor { 57 + query.push_str(&format!("&cursor={c}")); 58 + } 59 + 60 + let url = format!("{appview_url}{path}?{query}"); 61 + 62 + let request = HttpRequest { 63 + method: HttpMethod::Get, 64 + url, 65 + headers: vec![("Authorization".into(), auth)], 66 + body: None, 67 + }; 68 + 69 + let response = transport.send(request).await?; 70 + check_appview_response(response.status, &response.body)?; 71 + 72 + serde_json::from_slice(&response.body).map_err(|e| Error::Appview { 73 + status: response.status, 74 + message: format!("failed to parse inbox response: {e}"), 75 + }) 76 + } 77 + 78 + /// Fetch all inbox grants, paginating automatically until exhausted. 79 + pub async fn fetch_inbox_all( 80 + transport: &impl Transport, 81 + appview_url: &str, 82 + did: &str, 83 + signing_key: &[u8; 32], 84 + ) -> Result<Vec<InboxGrant>, Error> { 85 + let mut all_grants = Vec::new(); 86 + let mut cursor: Option<String> = None; 87 + 88 + loop { 89 + let page = fetch_inbox( 90 + transport, 91 + appview_url, 92 + did, 93 + signing_key, 94 + Some(100), 95 + cursor.as_deref(), 96 + ) 97 + .await?; 98 + 99 + let has_more = page.cursor.is_some(); 100 + cursor = page.cursor; 101 + all_grants.extend(page.grants); 102 + 103 + if !has_more { 104 + break; 105 + } 106 + } 107 + 108 + Ok(all_grants) 109 + } 110 + 111 + #[cfg(test)] 112 + mod tests { 113 + use super::*; 114 + use crate::client::transport::HttpResponse; 115 + use crate::test_utils::MockTransport; 116 + 117 + fn inbox_json(grants: &[&str], cursor: Option<&str>) -> Vec<u8> { 118 + let grants_str = grants.join(","); 119 + let cursor_str = match cursor { 120 + Some(c) => format!(r#","cursor":"{c}""#), 121 + None => String::new(), 122 + }; 123 + format!(r#"{{"grants":[{grants_str}]{cursor_str}}}"#).into_bytes() 124 + } 125 + 126 + fn grant_json(uri_suffix: &str) -> String { 127 + format!( 128 + r#"{{"uri":"at://did:plc:owner/app.opake.cloud.grant/{uri_suffix}","ownerDid":"did:plc:owner","documentUri":"at://did:plc:owner/app.opake.cloud.document/doc1","permissions":"read","note":null,"createdAt":"2026-03-01T12:00:00Z"}}"# 129 + ) 130 + } 131 + 132 + fn dummy_key() -> [u8; 32] { 133 + [42u8; 32] 134 + } 135 + 136 + #[tokio::test] 137 + async fn fetch_inbox_single_page() { 138 + let mock = MockTransport::new(); 139 + mock.enqueue(HttpResponse { 140 + status: 200, 141 + body: inbox_json(&[&grant_json("g1"), &grant_json("g2")], None), 142 + }); 143 + 144 + let resp = fetch_inbox( 145 + &mock, 146 + "https://appview.test", 147 + "did:plc:me", 148 + &dummy_key(), 149 + None, 150 + None, 151 + ) 152 + .await 153 + .unwrap(); 154 + 155 + assert_eq!(resp.grants.len(), 2); 156 + assert!(resp.cursor.is_none()); 157 + 158 + let req = &mock.requests()[0]; 159 + assert!(req.url.contains("/api/inbox?did=did:plc:me")); 160 + assert!(req 161 + .headers 162 + .iter() 163 + .any(|(k, v)| k == "Authorization" && v.starts_with("Opake-Ed25519 "))); 164 + } 165 + 166 + #[tokio::test] 167 + async fn fetch_inbox_all_paginates() { 168 + let mock = MockTransport::new(); 169 + mock.enqueue(HttpResponse { 170 + status: 200, 171 + body: inbox_json(&[&grant_json("g1")], Some("cursor1")), 172 + }); 173 + mock.enqueue(HttpResponse { 174 + status: 200, 175 + body: inbox_json(&[&grant_json("g2")], None), 176 + }); 177 + 178 + let grants = fetch_inbox_all(&mock, "https://appview.test", "did:plc:me", &dummy_key()) 179 + .await 180 + .unwrap(); 181 + 182 + assert_eq!(grants.len(), 2); 183 + assert_eq!(mock.requests().len(), 2); 184 + assert!(mock.requests()[1].url.contains("cursor=cursor1")); 185 + } 186 + 187 + #[tokio::test] 188 + async fn fetch_inbox_empty() { 189 + let mock = MockTransport::new(); 190 + mock.enqueue(HttpResponse { 191 + status: 200, 192 + body: inbox_json(&[], None), 193 + }); 194 + 195 + let resp = fetch_inbox( 196 + &mock, 197 + "https://appview.test", 198 + "did:plc:me", 199 + &dummy_key(), 200 + None, 201 + None, 202 + ) 203 + .await 204 + .unwrap(); 205 + 206 + assert!(resp.grants.is_empty()); 207 + } 208 + 209 + #[tokio::test] 210 + async fn fetch_inbox_401_error() { 211 + let mock = MockTransport::new(); 212 + mock.enqueue(HttpResponse { 213 + status: 401, 214 + body: r#"{"error":"signature verification failed"}"#.as_bytes().to_vec(), 215 + }); 216 + 217 + let err = fetch_inbox( 218 + &mock, 219 + "https://appview.test", 220 + "did:plc:me", 221 + &dummy_key(), 222 + None, 223 + None, 224 + ) 225 + .await 226 + .unwrap_err(); 227 + 228 + match err { 229 + Error::Appview { status, message } => { 230 + assert_eq!(status, 401); 231 + assert!(message.contains("signature verification failed")); 232 + } 233 + other => panic!("expected Appview error, got: {other:?}"), 234 + } 235 + } 236 + 237 + #[tokio::test] 238 + async fn fetch_inbox_500_error() { 239 + let mock = MockTransport::new(); 240 + mock.enqueue(HttpResponse { 241 + status: 500, 242 + body: r#"{"error":"internal server error"}"#.as_bytes().to_vec(), 243 + }); 244 + 245 + let err = fetch_inbox( 246 + &mock, 247 + "https://appview.test", 248 + "did:plc:me", 249 + &dummy_key(), 250 + None, 251 + None, 252 + ) 253 + .await 254 + .unwrap_err(); 255 + 256 + match err { 257 + Error::Appview { status, .. } => assert_eq!(status, 500), 258 + other => panic!("expected Appview error, got: {other:?}"), 259 + } 260 + } 261 + }
+81
crates/opake-core/src/client/appview_auth.rs
··· 1 + // Auth signing for appview requests. 2 + // 3 + // Pure function — no I/O, no clock. The caller provides the timestamp. 4 + // Produces the `Authorization: Opake-Ed25519 <did>:<ts>:<sig>` header value 5 + // that the appview's auth middleware expects. 6 + 7 + use base64::engine::general_purpose::STANDARD as BASE64; 8 + use base64::Engine; 9 + use ed25519_dalek::{Signer, SigningKey}; 10 + 11 + /// Build the signed Authorization header value for an appview request. 12 + /// 13 + /// Signature covers: `<method>:<path>:<timestamp>:<did>` 14 + /// Returns: `Opake-Ed25519 <did>:<timestamp>:<base64(signature)>` 15 + pub fn sign_appview_request( 16 + method: &str, 17 + path: &str, 18 + did: &str, 19 + signing_key: &[u8; 32], 20 + timestamp: u64, 21 + ) -> String { 22 + let key = SigningKey::from_bytes(signing_key); 23 + let message = format!("{method}:{path}:{timestamp}:{did}"); 24 + let signature = key.sign(message.as_bytes()); 25 + let sig_b64 = BASE64.encode(signature.to_bytes()); 26 + format!("Opake-Ed25519 {did}:{timestamp}:{sig_b64}") 27 + } 28 + 29 + #[cfg(test)] 30 + mod tests { 31 + use super::*; 32 + use ed25519_dalek::VerifyingKey; 33 + use ed25519_dalek::{Signature, Verifier}; 34 + 35 + fn test_keypair() -> ([u8; 32], VerifyingKey) { 36 + let secret = [42u8; 32]; 37 + let signing = SigningKey::from_bytes(&secret); 38 + let verifying = signing.verifying_key(); 39 + (secret, verifying) 40 + } 41 + 42 + #[test] 43 + fn roundtrip_signature_verifies() { 44 + let (secret, verifying) = test_keypair(); 45 + let header = sign_appview_request("GET", "/api/inbox", "did:plc:abc", &secret, 1709330400); 46 + 47 + let payload = header.strip_prefix("Opake-Ed25519 ").unwrap(); 48 + // Parse from right: sig, then timestamp, then did 49 + let last_colon = payload.rfind(':').unwrap(); 50 + let sig_b64 = &payload[last_colon + 1..]; 51 + let rest = &payload[..last_colon]; 52 + let second_colon = rest.rfind(':').unwrap(); 53 + let timestamp = &rest[second_colon + 1..]; 54 + let did = &rest[..second_colon]; 55 + 56 + let sig_bytes = BASE64.decode(sig_b64).unwrap(); 57 + let signature = Signature::from_slice(&sig_bytes).unwrap(); 58 + let message = format!("GET:/api/inbox:{timestamp}:{did}"); 59 + verifying.verify(message.as_bytes(), &signature).unwrap(); 60 + } 61 + 62 + #[test] 63 + fn header_format_is_correct() { 64 + let secret = [1u8; 32]; 65 + let header = sign_appview_request("GET", "/api/inbox", "did:plc:test", &secret, 12345); 66 + assert!(header.starts_with("Opake-Ed25519 did:plc:test:12345:")); 67 + } 68 + 69 + #[test] 70 + fn different_inputs_produce_different_signatures() { 71 + let secret = [7u8; 32]; 72 + let sig_a = sign_appview_request("GET", "/api/inbox", "did:plc:a", &secret, 100); 73 + let sig_b = sign_appview_request("GET", "/api/inbox", "did:plc:b", &secret, 100); 74 + let sig_c = sign_appview_request("POST", "/api/inbox", "did:plc:a", &secret, 100); 75 + let sig_d = sign_appview_request("GET", "/api/inbox", "did:plc:a", &secret, 200); 76 + 77 + assert_ne!(sig_a, sig_b); 78 + assert_ne!(sig_a, sig_c); 79 + assert_ne!(sig_a, sig_d); 80 + } 81 + }
+82
crates/opake-core/src/client/appview_types.rs
··· 1 + // Client-side response types for the appview JSON API. 2 + // 3 + // These mirror the appview's server-side types but only carry Deserialize — 4 + // this crate doesn't need to serialize them. 5 + 6 + use serde::Deserialize; 7 + 8 + #[derive(Debug, Clone, Deserialize)] 9 + #[serde(rename_all = "camelCase")] 10 + pub struct InboxGrant { 11 + pub uri: String, 12 + pub owner_did: String, 13 + pub document_uri: String, 14 + pub permissions: Option<String>, 15 + pub note: Option<String>, 16 + pub created_at: String, 17 + } 18 + 19 + #[derive(Debug, Clone, Deserialize)] 20 + pub struct InboxResponse { 21 + pub grants: Vec<InboxGrant>, 22 + pub cursor: Option<String>, 23 + } 24 + 25 + #[cfg(test)] 26 + mod tests { 27 + use super::*; 28 + 29 + #[test] 30 + fn deserialize_full_response() { 31 + let json = r#"{ 32 + "grants": [{ 33 + "uri": "at://did:plc:owner/app.opake.cloud.grant/tid1", 34 + "ownerDid": "did:plc:owner", 35 + "documentUri": "at://did:plc:owner/app.opake.cloud.document/doc1", 36 + "permissions": "read", 37 + "note": "tax docs", 38 + "createdAt": "2026-03-01T12:00:00Z" 39 + }], 40 + "cursor": "next-page" 41 + }"#; 42 + 43 + let resp: InboxResponse = serde_json::from_str(json).unwrap(); 44 + assert_eq!(resp.grants.len(), 1); 45 + assert_eq!(resp.grants[0].owner_did, "did:plc:owner"); 46 + assert_eq!( 47 + resp.grants[0].document_uri, 48 + "at://did:plc:owner/app.opake.cloud.document/doc1" 49 + ); 50 + assert_eq!(resp.grants[0].permissions.as_deref(), Some("read")); 51 + assert_eq!(resp.grants[0].note.as_deref(), Some("tax docs")); 52 + assert_eq!(resp.cursor.as_deref(), Some("next-page")); 53 + } 54 + 55 + #[test] 56 + fn deserialize_empty_response() { 57 + let json = r#"{"grants": []}"#; 58 + let resp: InboxResponse = serde_json::from_str(json).unwrap(); 59 + assert!(resp.grants.is_empty()); 60 + assert!(resp.cursor.is_none()); 61 + } 62 + 63 + #[test] 64 + fn deserialize_null_optionals() { 65 + let json = r#"{ 66 + "grants": [{ 67 + "uri": "at://did:plc:owner/app.opake.cloud.grant/tid1", 68 + "ownerDid": "did:plc:owner", 69 + "documentUri": "at://did:plc:owner/app.opake.cloud.document/doc1", 70 + "permissions": null, 71 + "note": null, 72 + "createdAt": "2026-03-01T12:00:00Z" 73 + }], 74 + "cursor": null 75 + }"#; 76 + 77 + let resp: InboxResponse = serde_json::from_str(json).unwrap(); 78 + assert!(resp.grants[0].permissions.is_none()); 79 + assert!(resp.grants[0].note.is_none()); 80 + assert!(resp.cursor.is_none()); 81 + } 82 + }
+6
crates/opake-core/src/client/mod.rs
··· 1 + mod appview; 2 + mod appview_auth; 3 + mod appview_types; 1 4 mod did; 2 5 mod list; 3 6 mod transport; 4 7 mod xrpc; 5 8 9 + pub use appview::*; 10 + pub use appview_auth::*; 11 + pub use appview_types::*; 6 12 pub use did::*; 7 13 pub use list::*; 8 14 pub use transport::*;
+3
crates/opake-core/src/error.rs
··· 17 17 #[error("XRPC error ({status}): {message}")] 18 18 Xrpc { status: u16, message: String }, 19 19 20 + #[error("appview error ({status}): {message}")] 21 + Appview { status: u16, message: String }, 22 + 20 23 #[error("record not found: {0}")] 21 24 NotFound(String), 22 25