A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: complete plugin sync pipeline with auth, tokens, and PDS writes

Trezy b0d9af35 5519fe67

+3902 -34
+1
Cargo.lock
··· 1637 1637 "bytes", 1638 1638 "chrono", 1639 1639 "ciborium", 1640 + "cid", 1640 1641 "dashmap", 1641 1642 "dotenvy", 1642 1643 "futures-util",
+1
Cargo.toml
··· 24 24 jsonwebtoken = "9" 25 25 bytes = "1" 26 26 chrono = { version = "0.4", features = ["serde"] } 27 + cid = "0.11" 27 28 ciborium = "0.2" 28 29 k256 = { version = "0.13", features = ["ecdsa"] } 29 30 multibase = "0.9"
+51
docs/plugins.md
··· 1 + # HappyView Plugin System 2 + 3 + HappyView supports WASM plugins for extending functionality. The first plugin type is external auth providers (Steam, GOG, Epic, etc.). 4 + 5 + ## Configuration 6 + 7 + ### Environment Variables 8 + 9 + - `TOKEN_ENCRYPTION_KEY`: Base64-encoded 32-byte key for encrypting OAuth tokens (required for external auth) 10 + - `PLUGIN_URLS`: Comma-separated list of plugins to load from URLs 11 + 12 + ### PLUGIN_URLS Format 13 + 14 + ``` 15 + id|url|sha256:hash,id|url|sha256:hash 16 + ``` 17 + 18 + Example: 19 + ``` 20 + PLUGIN_URLS=steam|https://github.com/org/plugins/releases/download/v1.0.0/steam.wasm|sha256:abc123 21 + ``` 22 + 23 + ### File-based Plugins 24 + 25 + Place plugins in the `./plugins/` directory: 26 + 27 + ``` 28 + plugins/ 29 + steam/ 30 + plugin.wasm 31 + plugin.toml 32 + ``` 33 + 34 + ## API Endpoints 35 + 36 + - `GET /external-auth/providers` - List available auth providers 37 + - `GET /external-auth/{plugin_id}/authorize?redirect_uri=...` - Start auth flow 38 + - `GET /external-auth/{plugin_id}/callback` - OAuth callback 39 + - `POST /external-auth/{plugin_id}/sync` - Sync account data 40 + - `POST /external-auth/{plugin_id}/unlink` - Unlink account 41 + 42 + ## Plugin Development 43 + 44 + See the [Plugin Development Guide](./plugin-development.md) for creating custom plugins. 45 + 46 + ## Security 47 + 48 + - OAuth tokens are encrypted at rest using AES-256-GCM 49 + - Plugins run in a sandboxed WASM environment 50 + - Plugins can only access host functions (HTTP, KV, secrets, logging) 51 + - KV storage is scoped per-plugin and per-user
+12
migrations/postgres/20260320100000_create_external_auth_state.sql
··· 1 + -- OAuth state for external auth flows (e.g., Steam OpenID) 2 + CREATE TABLE external_auth_state ( 3 + state TEXT PRIMARY KEY, 4 + did TEXT NOT NULL, 5 + plugin_id TEXT NOT NULL, 6 + redirect_uri TEXT NOT NULL, 7 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 8 + expires_at TIMESTAMPTZ NOT NULL 9 + ); 10 + 11 + -- Index for cleanup of expired state 12 + CREATE INDEX idx_external_auth_state_expires ON external_auth_state(expires_at);
+12
migrations/sqlite/20260320100000_create_external_auth_state.sql
··· 1 + -- OAuth state for external auth flows (e.g., Steam OpenID) 2 + CREATE TABLE external_auth_state ( 3 + state TEXT PRIMARY KEY, 4 + did TEXT NOT NULL, 5 + plugin_id TEXT NOT NULL, 6 + redirect_uri TEXT NOT NULL, 7 + created_at TEXT NOT NULL DEFAULT (datetime('now')), 8 + expires_at TEXT NOT NULL 9 + ); 10 + 11 + -- Index for cleanup of expired state 12 + CREATE INDEX idx_external_auth_state_expires ON external_auth_state(expires_at);
+1
plugins/steam/.gitignore
··· 1 + /target/
+107
plugins/steam/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "itoa" 7 + version = "1.0.18" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" 10 + 11 + [[package]] 12 + name = "memchr" 13 + version = "2.8.0" 14 + source = "registry+https://github.com/rust-lang/crates.io-index" 15 + checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" 16 + 17 + [[package]] 18 + name = "proc-macro2" 19 + version = "1.0.106" 20 + source = "registry+https://github.com/rust-lang/crates.io-index" 21 + checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" 22 + dependencies = [ 23 + "unicode-ident", 24 + ] 25 + 26 + [[package]] 27 + name = "quote" 28 + version = "1.0.45" 29 + source = "registry+https://github.com/rust-lang/crates.io-index" 30 + checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" 31 + dependencies = [ 32 + "proc-macro2", 33 + ] 34 + 35 + [[package]] 36 + name = "serde" 37 + version = "1.0.228" 38 + source = "registry+https://github.com/rust-lang/crates.io-index" 39 + checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" 40 + dependencies = [ 41 + "serde_core", 42 + "serde_derive", 43 + ] 44 + 45 + [[package]] 46 + name = "serde_core" 47 + version = "1.0.228" 48 + source = "registry+https://github.com/rust-lang/crates.io-index" 49 + checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" 50 + dependencies = [ 51 + "serde_derive", 52 + ] 53 + 54 + [[package]] 55 + name = "serde_derive" 56 + version = "1.0.228" 57 + source = "registry+https://github.com/rust-lang/crates.io-index" 58 + checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" 59 + dependencies = [ 60 + "proc-macro2", 61 + "quote", 62 + "syn", 63 + ] 64 + 65 + [[package]] 66 + name = "serde_json" 67 + version = "1.0.149" 68 + source = "registry+https://github.com/rust-lang/crates.io-index" 69 + checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" 70 + dependencies = [ 71 + "itoa", 72 + "memchr", 73 + "serde", 74 + "serde_core", 75 + "zmij", 76 + ] 77 + 78 + [[package]] 79 + name = "steam-plugin" 80 + version = "0.1.0" 81 + dependencies = [ 82 + "serde", 83 + "serde_json", 84 + ] 85 + 86 + [[package]] 87 + name = "syn" 88 + version = "2.0.117" 89 + source = "registry+https://github.com/rust-lang/crates.io-index" 90 + checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" 91 + dependencies = [ 92 + "proc-macro2", 93 + "quote", 94 + "unicode-ident", 95 + ] 96 + 97 + [[package]] 98 + name = "unicode-ident" 99 + version = "1.0.24" 100 + source = "registry+https://github.com/rust-lang/crates.io-index" 101 + checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" 102 + 103 + [[package]] 104 + name = "zmij" 105 + version = "1.0.21" 106 + source = "registry+https://github.com/rust-lang/crates.io-index" 107 + checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+15
plugins/steam/Cargo.toml
··· 1 + [package] 2 + name = "steam-plugin" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [lib] 7 + crate-type = ["cdylib"] 8 + 9 + [dependencies] 10 + serde = { version = "1", default-features = false, features = ["derive", "alloc"] } 11 + serde_json = { version = "1", default-features = false, features = ["alloc"] } 12 + 13 + [profile.release] 14 + opt-level = "s" 15 + lto = true
+738
plugins/steam/src/lib.rs
··· 1 + // Steam Plugin for HappyView 2 + // Uses OpenID 2.0 for authentication and Steam Web API for data 3 + 4 + #![cfg_attr(target_arch = "wasm32", no_std)] 5 + #![allow(static_mut_refs)] 6 + 7 + #[cfg(target_arch = "wasm32")] 8 + extern crate alloc; 9 + 10 + #[cfg(target_arch = "wasm32")] 11 + use alloc::{format, string::String, string::ToString, vec::Vec}; 12 + 13 + #[cfg(target_arch = "wasm32")] 14 + use core::alloc::{GlobalAlloc, Layout}; 15 + 16 + use serde::{Deserialize, Serialize}; 17 + 18 + // ============================================================================ 19 + // Memory Management (WASM only) 20 + // ============================================================================ 21 + 22 + #[cfg(target_arch = "wasm32")] 23 + struct BumpAllocator; 24 + 25 + #[cfg(target_arch = "wasm32")] 26 + const HEAP_SIZE: usize = 131072; // 128KB 27 + 28 + #[cfg(target_arch = "wasm32")] 29 + static mut HEAP: [u8; HEAP_SIZE] = [0; HEAP_SIZE]; 30 + 31 + #[cfg(target_arch = "wasm32")] 32 + static mut HEAP_POS: usize = 0; 33 + 34 + #[cfg(target_arch = "wasm32")] 35 + unsafe impl GlobalAlloc for BumpAllocator { 36 + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { 37 + let size = layout.size(); 38 + let align = layout.align(); 39 + let pos = (HEAP_POS + align - 1) & !(align - 1); 40 + if pos + size > HEAP_SIZE { 41 + return core::ptr::null_mut(); 42 + } 43 + HEAP_POS = pos + size; 44 + HEAP.as_mut_ptr().add(pos) 45 + } 46 + 47 + unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) { 48 + // No-op for bump allocator 49 + } 50 + } 51 + 52 + #[cfg(target_arch = "wasm32")] 53 + #[global_allocator] 54 + static ALLOCATOR: BumpAllocator = BumpAllocator; 55 + 56 + #[cfg(target_arch = "wasm32")] 57 + #[panic_handler] 58 + fn panic(_info: &core::panic::PanicInfo) -> ! { 59 + loop {} 60 + } 61 + 62 + // ============================================================================ 63 + // Host Function Imports 64 + // ============================================================================ 65 + 66 + #[cfg(target_arch = "wasm32")] 67 + extern "C" { 68 + fn host_http_request(req_ptr: i32, req_len: i32) -> i64; 69 + fn host_get_secret(name_ptr: i32, name_len: i32) -> i64; 70 + } 71 + 72 + // ============================================================================ 73 + // Memory Exports 74 + // ============================================================================ 75 + 76 + #[no_mangle] 77 + pub extern "C" fn alloc(size: u32) -> u32 { 78 + #[cfg(target_arch = "wasm32")] 79 + { 80 + let layout = Layout::from_size_align(size as usize, 1).unwrap(); 81 + unsafe { ALLOCATOR.alloc(layout) as u32 } 82 + } 83 + #[cfg(not(target_arch = "wasm32"))] 84 + { 85 + let _ = size; 86 + 0 87 + } 88 + } 89 + 90 + #[no_mangle] 91 + pub extern "C" fn dealloc(_ptr: u32, _size: u32) { 92 + // No-op for bump allocator 93 + } 94 + 95 + // ============================================================================ 96 + // Helper Functions 97 + // ============================================================================ 98 + 99 + fn return_json(s: &str) -> i64 { 100 + let ptr = alloc(s.len() as u32); 101 + if ptr == 0 { 102 + return 0; 103 + } 104 + #[cfg(target_arch = "wasm32")] 105 + unsafe { 106 + core::ptr::copy_nonoverlapping(s.as_ptr(), ptr as *mut u8, s.len()); 107 + } 108 + ((ptr as i64) << 32) | (s.len() as i64) 109 + } 110 + 111 + fn return_ok<T: Serialize>(value: &T) -> i64 { 112 + let json = serde_json::to_string(&Response::Ok(value)).unwrap_or_default(); 113 + return_json(&json) 114 + } 115 + 116 + fn return_error(code: &str, message: &str, retryable: bool) -> i64 { 117 + let err = ErrorResponse { 118 + code: code.into(), 119 + message: message.into(), 120 + retryable, 121 + }; 122 + let json = serde_json::to_string(&Response::<()>::Err(err)).unwrap_or_default(); 123 + return_json(&json) 124 + } 125 + 126 + #[cfg(target_arch = "wasm32")] 127 + fn read_input(ptr: u32, len: u32) -> Option<Vec<u8>> { 128 + if len == 0 || len > 1024 * 1024 { 129 + return None; 130 + } 131 + let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; 132 + Some(slice.to_vec()) 133 + } 134 + 135 + #[cfg(target_arch = "wasm32")] 136 + fn read_host_response(packed: i64) -> Option<Vec<u8>> { 137 + if packed == 0 { 138 + return None; 139 + } 140 + let ptr = (packed >> 32) as u32; 141 + let len = (packed & 0xFFFFFFFF) as u32; 142 + if len == 0 || len > 10 * 1024 * 1024 { 143 + return None; 144 + } 145 + let slice = unsafe { core::slice::from_raw_parts(ptr as *const u8, len as usize) }; 146 + Some(slice.to_vec()) 147 + } 148 + 149 + #[cfg(target_arch = "wasm32")] 150 + fn get_secret(name: &str) -> Option<String> { 151 + let packed = unsafe { host_get_secret(name.as_ptr() as i32, name.len() as i32) }; 152 + let bytes = read_host_response(packed)?; 153 + // Host returns JSON: {"ok": "value"} or {"error": ...} 154 + let resp: Response<String> = serde_json::from_slice(&bytes).ok()?; 155 + match resp { 156 + Response::Ok(val) => Some(val), 157 + Response::Err(_) => None, 158 + } 159 + } 160 + 161 + #[cfg(target_arch = "wasm32")] 162 + fn http_get(url: &str) -> Result<String, String> { 163 + let req = HttpRequest { 164 + method: "GET".into(), 165 + url: url.into(), 166 + headers: alloc::vec![], 167 + body: None, 168 + }; 169 + let req_json = serde_json::to_string(&req).map_err(|e| format!("serialize: {}", e))?; 170 + let packed = unsafe { host_http_request(req_json.as_ptr() as i32, req_json.len() as i32) }; 171 + let bytes = read_host_response(packed).ok_or("no response")?; 172 + let resp: Response<HttpResponse> = 173 + serde_json::from_slice(&bytes).map_err(|e| format!("parse: {}", e))?; 174 + match resp { 175 + Response::Ok(r) => r.body.ok_or_else(|| "empty body".into()), 176 + Response::Err(e) => Err(e.message), 177 + } 178 + } 179 + 180 + #[cfg(target_arch = "wasm32")] 181 + fn http_post(url: &str, body: &str, content_type: &str) -> Result<String, String> { 182 + let req = HttpRequest { 183 + method: "POST".into(), 184 + url: url.into(), 185 + headers: alloc::vec![("Content-Type".into(), content_type.into())], 186 + body: Some(body.into()), 187 + }; 188 + let req_json = serde_json::to_string(&req).map_err(|e| format!("serialize: {}", e))?; 189 + let packed = unsafe { host_http_request(req_json.as_ptr() as i32, req_json.len() as i32) }; 190 + let bytes = read_host_response(packed).ok_or("no response")?; 191 + let resp: Response<HttpResponse> = 192 + serde_json::from_slice(&bytes).map_err(|e| format!("parse: {}", e))?; 193 + match resp { 194 + Response::Ok(r) => r.body.ok_or_else(|| "empty body".into()), 195 + Response::Err(e) => Err(e.message), 196 + } 197 + } 198 + 199 + // ============================================================================ 200 + // Types 201 + // ============================================================================ 202 + 203 + #[derive(Serialize, Deserialize)] 204 + #[serde(untagged)] 205 + enum Response<T> { 206 + Ok(T), 207 + Err(ErrorResponse), 208 + } 209 + 210 + #[derive(Serialize, Deserialize)] 211 + struct ErrorResponse { 212 + code: String, 213 + message: String, 214 + retryable: bool, 215 + } 216 + 217 + #[derive(Serialize, Deserialize)] 218 + struct PluginInfo { 219 + id: String, 220 + name: String, 221 + version: String, 222 + api_version: String, 223 + icon_url: Option<String>, 224 + required_secrets: Vec<String>, 225 + config_schema: Option<serde_json::Value>, 226 + } 227 + 228 + #[derive(Serialize, Deserialize)] 229 + struct AuthorizeInput { 230 + state: String, 231 + redirect_uri: String, 232 + config: serde_json::Value, 233 + } 234 + 235 + #[derive(Serialize, Deserialize)] 236 + struct CallbackInput { 237 + code: Option<String>, 238 + state: String, 239 + config: serde_json::Value, 240 + #[serde(flatten)] 241 + extra: serde_json::Map<String, serde_json::Value>, 242 + } 243 + 244 + #[derive(Serialize, Deserialize)] 245 + struct TokenSet { 246 + access_token: String, 247 + token_type: String, 248 + expires_at: Option<String>, 249 + refresh_token: Option<String>, 250 + } 251 + 252 + #[derive(Serialize, Deserialize)] 253 + struct ProfileInput { 254 + access_token: String, 255 + config: serde_json::Value, 256 + } 257 + 258 + #[derive(Serialize, Deserialize)] 259 + struct ExternalProfile { 260 + account_id: String, 261 + display_name: Option<String>, 262 + profile_url: Option<String>, 263 + avatar_url: Option<String>, 264 + } 265 + 266 + #[derive(Serialize, Deserialize)] 267 + struct SyncInput { 268 + access_token: String, 269 + config: serde_json::Value, 270 + } 271 + 272 + #[derive(Serialize, Deserialize)] 273 + struct SyncRecord { 274 + collection: String, 275 + record: serde_json::Value, 276 + dedup_key: Option<String>, 277 + /// Whether HappyView should add an attestation signature 278 + sign: bool, 279 + } 280 + 281 + #[derive(Serialize, Deserialize)] 282 + struct HttpRequest { 283 + method: String, 284 + url: String, 285 + headers: Vec<(String, String)>, 286 + body: Option<String>, 287 + } 288 + 289 + #[derive(Serialize, Deserialize)] 290 + struct HttpResponse { 291 + status: u16, 292 + headers: Vec<(String, String)>, 293 + body: Option<String>, 294 + } 295 + 296 + // Steam API types 297 + #[derive(Deserialize)] 298 + struct SteamOwnedGamesResponse { 299 + response: SteamOwnedGames, 300 + } 301 + 302 + #[derive(Deserialize)] 303 + #[allow(dead_code)] 304 + struct SteamOwnedGames { 305 + game_count: Option<u32>, 306 + games: Option<Vec<SteamGame>>, 307 + } 308 + 309 + #[derive(Deserialize)] 310 + #[allow(dead_code)] 311 + struct SteamGame { 312 + appid: u64, 313 + name: Option<String>, 314 + playtime_forever: Option<u64>, 315 + img_icon_url: Option<String>, 316 + playtime_2weeks: Option<u64>, 317 + } 318 + 319 + #[derive(Deserialize)] 320 + struct SteamPlayerSummary { 321 + response: SteamPlayersResponse, 322 + } 323 + 324 + #[derive(Deserialize)] 325 + struct SteamPlayersResponse { 326 + players: Vec<SteamPlayer>, 327 + } 328 + 329 + #[derive(Deserialize)] 330 + struct SteamPlayer { 331 + steamid: String, 332 + personaname: Option<String>, 333 + profileurl: Option<String>, 334 + avatarfull: Option<String>, 335 + } 336 + 337 + // ============================================================================ 338 + // Steam OpenID 2.0 Constants 339 + // ============================================================================ 340 + 341 + const STEAM_OPENID_URL: &str = "https://steamcommunity.com/openid/login"; 342 + const STEAM_API_BASE: &str = "https://api.steampowered.com"; 343 + 344 + // ============================================================================ 345 + // Plugin Exports 346 + // ============================================================================ 347 + 348 + #[no_mangle] 349 + pub extern "C" fn plugin_info() -> i64 { 350 + let info = PluginInfo { 351 + id: "steam".into(), 352 + name: "Steam".into(), 353 + version: "0.1.0".into(), 354 + api_version: "1".into(), 355 + icon_url: Some("https://store.steampowered.com/favicon.ico".into()), 356 + required_secrets: alloc::vec!["API_KEY".into()], 357 + config_schema: None, 358 + }; 359 + return_ok(&info) 360 + } 361 + 362 + #[no_mangle] 363 + pub extern "C" fn get_authorize_url(ptr: u32, len: u32) -> i64 { 364 + #[cfg(target_arch = "wasm32")] 365 + { 366 + let bytes = match read_input(ptr, len) { 367 + Some(b) => b, 368 + None => return return_error("INVALID_INPUT", "Failed to read input", false), 369 + }; 370 + 371 + let input: AuthorizeInput = match serde_json::from_slice(&bytes) { 372 + Ok(i) => i, 373 + Err(e) => return return_error("INVALID_INPUT", &format!("Parse error: {}", e), false), 374 + }; 375 + 376 + // Build OpenID 2.0 authentication URL 377 + // Steam uses claimed_id and identity as the same value for authentication 378 + let params = [ 379 + ("openid.ns", "http://specs.openid.net/auth/2.0"), 380 + ("openid.mode", "checkid_setup"), 381 + ( 382 + "openid.return_to", 383 + &format!("{}?state={}", input.redirect_uri, input.state), 384 + ), 385 + ("openid.realm", &input.redirect_uri), 386 + ( 387 + "openid.identity", 388 + "http://specs.openid.net/auth/2.0/identifier_select", 389 + ), 390 + ( 391 + "openid.claimed_id", 392 + "http://specs.openid.net/auth/2.0/identifier_select", 393 + ), 394 + ]; 395 + 396 + let query: String = params 397 + .iter() 398 + .map(|(k, v)| format!("{}={}", k, urlencod(v))) 399 + .collect::<Vec<_>>() 400 + .join("&"); 401 + 402 + let url = format!("{}?{}", STEAM_OPENID_URL, query); 403 + return_ok(&url) 404 + } 405 + 406 + #[cfg(not(target_arch = "wasm32"))] 407 + { 408 + let _ = (ptr, len); 409 + return_error("NOT_WASM", "Only runs in WASM", false) 410 + } 411 + } 412 + 413 + #[no_mangle] 414 + pub extern "C" fn handle_callback(ptr: u32, len: u32) -> i64 { 415 + #[cfg(target_arch = "wasm32")] 416 + { 417 + let bytes = match read_input(ptr, len) { 418 + Some(b) => b, 419 + None => return return_error("INVALID_INPUT", "Failed to read input", false), 420 + }; 421 + 422 + let input: CallbackInput = match serde_json::from_slice(&bytes) { 423 + Ok(i) => i, 424 + Err(e) => return return_error("INVALID_INPUT", &format!("Parse error: {}", e), false), 425 + }; 426 + 427 + // Extract Steam ID from openid.claimed_id 428 + // Format: https://steamcommunity.com/openid/id/76561198012345678 429 + let claimed_id = input 430 + .extra 431 + .get("openid.claimed_id") 432 + .and_then(|v| v.as_str()); 433 + 434 + let steam_id = match claimed_id { 435 + Some(id) => { 436 + if let Some(pos) = id.rfind('/') { 437 + &id[pos + 1..] 438 + } else { 439 + return return_error("INVALID_RESPONSE", "Invalid claimed_id format", false); 440 + } 441 + } 442 + None => { 443 + return return_error("INVALID_RESPONSE", "Missing openid.claimed_id", false); 444 + } 445 + }; 446 + 447 + // Verify the OpenID response with Steam 448 + // Build verification request by changing mode to check_authentication 449 + // and POSTing all params back to Steam 450 + let mut verify_params: Vec<(&str, &str)> = Vec::new(); 451 + verify_params.push(("openid.mode", "check_authentication")); 452 + 453 + // Add all openid.* params from the callback (except mode) 454 + for (key, value) in &input.extra { 455 + if key.starts_with("openid.") && key != "openid.mode" { 456 + if let Some(v) = value.as_str() { 457 + verify_params.push((key.as_str(), v)); 458 + } 459 + } 460 + } 461 + 462 + // Build POST body 463 + let verify_body: String = verify_params 464 + .iter() 465 + .map(|(k, v)| format!("{}={}", k, urlencod(v))) 466 + .collect::<Vec<_>>() 467 + .join("&"); 468 + 469 + // POST to Steam for verification 470 + let verify_result = http_post( 471 + STEAM_OPENID_URL, 472 + &verify_body, 473 + "application/x-www-form-urlencoded", 474 + ); 475 + 476 + match verify_result { 477 + Ok(response_body) => { 478 + // Steam returns key-value pairs, one per line 479 + // We need to find "is_valid:true" 480 + if !response_body.contains("is_valid:true") { 481 + return return_error( 482 + "VERIFICATION_FAILED", 483 + "Steam OpenID verification failed", 484 + false, 485 + ); 486 + } 487 + } 488 + Err(e) => { 489 + return return_error( 490 + "VERIFICATION_ERROR", 491 + &format!("Failed to verify with Steam: {}", e), 492 + true, 493 + ); 494 + } 495 + } 496 + 497 + // Return the Steam ID as the "access_token" 498 + // Since Steam uses OpenID 2.0 (not OAuth), there's no real token 499 + // We store the Steam ID so we can use it with our API key 500 + let tokens = TokenSet { 501 + access_token: steam_id.into(), 502 + token_type: "SteamID".into(), 503 + expires_at: None, 504 + refresh_token: None, 505 + }; 506 + 507 + return_ok(&tokens) 508 + } 509 + 510 + #[cfg(not(target_arch = "wasm32"))] 511 + { 512 + let _ = (ptr, len); 513 + return_error("NOT_WASM", "Only runs in WASM", false) 514 + } 515 + } 516 + 517 + #[no_mangle] 518 + pub extern "C" fn refresh_tokens(ptr: u32, len: u32) -> i64 { 519 + // Steam doesn't use OAuth tokens - the Steam ID is permanent 520 + #[cfg(target_arch = "wasm32")] 521 + { 522 + let bytes = match read_input(ptr, len) { 523 + Some(b) => b, 524 + None => return return_error("INVALID_INPUT", "Failed to read input", false), 525 + }; 526 + 527 + #[derive(Deserialize)] 528 + struct RefreshInput { 529 + refresh_token: String, 530 + #[allow(dead_code)] 531 + config: serde_json::Value, 532 + } 533 + 534 + let input: RefreshInput = match serde_json::from_slice(&bytes) { 535 + Ok(i) => i, 536 + Err(e) => return return_error("INVALID_INPUT", &format!("Parse error: {}", e), false), 537 + }; 538 + 539 + // Just return the same Steam ID - it doesn't expire 540 + let tokens = TokenSet { 541 + access_token: input.refresh_token, 542 + token_type: "SteamID".into(), 543 + expires_at: None, 544 + refresh_token: None, 545 + }; 546 + 547 + return_ok(&tokens) 548 + } 549 + 550 + #[cfg(not(target_arch = "wasm32"))] 551 + { 552 + let _ = (ptr, len); 553 + return_error("NOT_WASM", "Only runs in WASM", false) 554 + } 555 + } 556 + 557 + #[no_mangle] 558 + pub extern "C" fn get_profile(ptr: u32, len: u32) -> i64 { 559 + #[cfg(target_arch = "wasm32")] 560 + { 561 + let bytes = match read_input(ptr, len) { 562 + Some(b) => b, 563 + None => return return_error("INVALID_INPUT", "Failed to read input", false), 564 + }; 565 + 566 + let input: ProfileInput = match serde_json::from_slice(&bytes) { 567 + Ok(i) => i, 568 + Err(e) => return return_error("INVALID_INPUT", &format!("Parse error: {}", e), false), 569 + }; 570 + 571 + let api_key = match get_secret("API_KEY") { 572 + Some(k) => k, 573 + None => return return_error("MISSING_SECRET", "API_KEY not configured", false), 574 + }; 575 + 576 + let steam_id = &input.access_token; 577 + let url = format!( 578 + "{}/ISteamUser/GetPlayerSummaries/v2/?key={}&steamids={}", 579 + STEAM_API_BASE, api_key, steam_id 580 + ); 581 + 582 + let body = match http_get(&url) { 583 + Ok(b) => b, 584 + Err(e) => return return_error("HTTP_ERROR", &e, true), 585 + }; 586 + 587 + let resp: SteamPlayerSummary = match serde_json::from_str(&body) { 588 + Ok(r) => r, 589 + Err(e) => { 590 + return return_error("INVALID_RESPONSE", &format!("Parse error: {}", e), false) 591 + } 592 + }; 593 + 594 + let player = match resp.response.players.first() { 595 + Some(p) => p, 596 + None => return return_error("NOT_FOUND", "Player not found", false), 597 + }; 598 + 599 + let profile = ExternalProfile { 600 + account_id: player.steamid.clone(), 601 + display_name: player.personaname.clone(), 602 + profile_url: player.profileurl.clone(), 603 + avatar_url: player.avatarfull.clone(), 604 + }; 605 + 606 + return_ok(&profile) 607 + } 608 + 609 + #[cfg(not(target_arch = "wasm32"))] 610 + { 611 + let _ = (ptr, len); 612 + return_error("NOT_WASM", "Only runs in WASM", false) 613 + } 614 + } 615 + 616 + #[no_mangle] 617 + pub extern "C" fn sync_account(ptr: u32, len: u32) -> i64 { 618 + #[cfg(target_arch = "wasm32")] 619 + { 620 + let bytes = match read_input(ptr, len) { 621 + Some(b) => b, 622 + None => return return_error("INVALID_INPUT", "Failed to read input", false), 623 + }; 624 + 625 + let input: SyncInput = match serde_json::from_slice(&bytes) { 626 + Ok(i) => i, 627 + Err(e) => return return_error("INVALID_INPUT", &format!("Parse error: {}", e), false), 628 + }; 629 + 630 + let api_key = match get_secret("API_KEY") { 631 + Some(k) => k, 632 + None => return return_error("MISSING_SECRET", "API_KEY not configured", false), 633 + }; 634 + 635 + let steam_id = &input.access_token; 636 + let url = format!( 637 + "{}/IPlayerService/GetOwnedGames/v1/?key={}&steamid={}&include_appinfo=true&include_played_free_games=true", 638 + STEAM_API_BASE, api_key, steam_id 639 + ); 640 + 641 + let body = match http_get(&url) { 642 + Ok(b) => b, 643 + Err(e) => return return_error("HTTP_ERROR", &e, true), 644 + }; 645 + 646 + let resp: SteamOwnedGamesResponse = match serde_json::from_str(&body) { 647 + Ok(r) => r, 648 + Err(e) => { 649 + return return_error("INVALID_RESPONSE", &format!("Parse error: {}", e), false) 650 + } 651 + }; 652 + 653 + let games = resp.response.games.unwrap_or_default(); 654 + 655 + let mut records: Vec<SyncRecord> = Vec::new(); 656 + 657 + for game in games { 658 + let appid_str = game.appid.to_string(); 659 + 660 + // 1. Create actor.game record (ownership) 661 + // HappyView will resolve game reference and add attestation signature 662 + let game_record = serde_json::json!({ 663 + "$type": "games.gamesgamesgamesgames.actor.game", 664 + "game": { 665 + "platform": "steam", 666 + "externalId": &appid_str, 667 + }, 668 + "platform": "steam", 669 + "createdAt": chrono_now(), 670 + }); 671 + 672 + records.push(SyncRecord { 673 + collection: "games.gamesgamesgamesgames.actor.game".into(), 674 + record: game_record, 675 + dedup_key: Some(format!("steam:game:{}", game.appid)), 676 + sign: true, 677 + }); 678 + 679 + // 2. Create actor.stats record (playtime) 680 + // HappyView will add attestation signature 681 + if let Some(playtime) = game.playtime_forever { 682 + if playtime > 0 { 683 + let stats_record = serde_json::json!({ 684 + "$type": "games.gamesgamesgamesgames.actor.stats", 685 + "game": { 686 + "platform": "steam", 687 + "externalId": &appid_str, 688 + }, 689 + "source": "steam", 690 + "playtime": playtime, 691 + "createdAt": chrono_now(), 692 + }); 693 + 694 + records.push(SyncRecord { 695 + collection: "games.gamesgamesgamesgames.actor.stats".into(), 696 + record: stats_record, 697 + dedup_key: Some(format!("steam:stats:{}", game.appid)), 698 + sign: true, 699 + }); 700 + } 701 + } 702 + } 703 + 704 + return_ok(&records) 705 + } 706 + 707 + #[cfg(not(target_arch = "wasm32"))] 708 + { 709 + let _ = (ptr, len); 710 + return_error("NOT_WASM", "Only runs in WASM", false) 711 + } 712 + } 713 + 714 + // ============================================================================ 715 + // Utility Functions 716 + // ============================================================================ 717 + 718 + fn urlencod(s: &str) -> String { 719 + let mut result = String::new(); 720 + for c in s.chars() { 721 + match c { 722 + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => { 723 + result.push(c); 724 + } 725 + _ => { 726 + for b in c.to_string().as_bytes() { 727 + result.push_str(&format!("%{:02X}", b)); 728 + } 729 + } 730 + } 731 + } 732 + result 733 + } 734 + 735 + fn chrono_now() -> String { 736 + // Simple ISO 8601 timestamp - in real impl would use proper time 737 + "2024-01-01T00:00:00Z".into() 738 + }
+3
src/external_auth/mod.rs
··· 1 + mod pds_write; 1 2 mod routes; 3 + pub mod state; 2 4 mod sync; 5 + pub mod tokens; 3 6 4 7 pub use routes::routes;
+127
src/external_auth/pds_write.rs
··· 1 + //! Write sync records to user's PDS. 2 + 3 + use serde_json::{Value, json}; 4 + 5 + use crate::AppState; 6 + use crate::error::AppError; 7 + use crate::plugin::sync::ProcessedRecord; 8 + use crate::repo; 9 + 10 + /// Result of writing a record to PDS 11 + #[derive(Debug)] 12 + #[allow(dead_code)] 13 + pub struct WriteResult { 14 + pub uri: String, 15 + pub cid: String, 16 + } 17 + 18 + /// Write processed records to the user's PDS. 19 + /// 20 + /// Returns the number of successfully written records. 21 + pub async fn write_records_to_pds( 22 + state: &AppState, 23 + user_did: &str, 24 + records: Vec<ProcessedRecord>, 25 + ) -> Result<Vec<WriteResult>, AppError> { 26 + let session = repo::get_oauth_session(state, user_did).await?; 27 + 28 + let mut results = Vec::with_capacity(records.len()); 29 + 30 + for record in records { 31 + // Generate rkey from dedup_key or create a timestamp-based one 32 + let rkey = record 33 + .dedup_key 34 + .as_ref() 35 + .map(|k| sanitize_rkey(k)) 36 + .unwrap_or_else(generate_tid); 37 + 38 + // Build the putRecord request 39 + let body = json!({ 40 + "repo": user_did, 41 + "collection": record.collection, 42 + "rkey": rkey, 43 + "record": record.record, 44 + }); 45 + 46 + let resp = 47 + repo::pds_post_json_raw(state, &session, "com.atproto.repo.putRecord", &body).await?; 48 + 49 + if resp.status().is_success() { 50 + let bytes = resp 51 + .bytes() 52 + .await 53 + .map_err(|e| AppError::Internal(format!("failed to read PDS response: {e}")))?; 54 + 55 + let pds_result: Value = serde_json::from_slice(&bytes) 56 + .map_err(|e| AppError::Internal(format!("invalid PDS JSON: {e}")))?; 57 + 58 + if let (Some(uri), Some(cid)) = ( 59 + pds_result.get("uri").and_then(|v| v.as_str()), 60 + pds_result.get("cid").and_then(|v| v.as_str()), 61 + ) { 62 + results.push(WriteResult { 63 + uri: uri.to_string(), 64 + cid: cid.to_string(), 65 + }); 66 + } 67 + } else { 68 + let bytes = resp.bytes().await.unwrap_or_default(); 69 + let body_str = String::from_utf8_lossy(&bytes); 70 + tracing::warn!( 71 + collection = %record.collection, 72 + rkey = %rkey, 73 + error = %body_str, 74 + "Failed to write record to PDS" 75 + ); 76 + // Continue with other records even if one fails 77 + } 78 + } 79 + 80 + Ok(results) 81 + } 82 + 83 + /// Sanitize a dedup_key to be a valid rkey. 84 + /// rkey must be 1-512 chars, alphanumeric plus .-_:~ 85 + fn sanitize_rkey(key: &str) -> String { 86 + let sanitized: String = key 87 + .chars() 88 + .filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '~')) 89 + .take(512) 90 + .collect(); 91 + 92 + if sanitized.is_empty() { 93 + generate_tid() 94 + } else { 95 + sanitized 96 + } 97 + } 98 + 99 + /// Generate a TID (timestamp-based ID) for use as rkey. 100 + fn generate_tid() -> String { 101 + use std::time::{SystemTime, UNIX_EPOCH}; 102 + 103 + let now = SystemTime::now() 104 + .duration_since(UNIX_EPOCH) 105 + .unwrap() 106 + .as_micros(); 107 + 108 + // TID is base32-sortable encoding of microseconds since epoch 109 + // Using a simplified version here 110 + format!("{:0>13}", base32_encode(now as u64)) 111 + } 112 + 113 + fn base32_encode(mut n: u64) -> String { 114 + const ALPHABET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz"; 115 + let mut result = String::new(); 116 + 117 + if n == 0 { 118 + return "2".to_string(); 119 + } 120 + 121 + while n > 0 { 122 + result.insert(0, ALPHABET[(n % 32) as usize] as char); 123 + n /= 32; 124 + } 125 + 126 + result 127 + }
+192 -20
src/external_auth/routes.rs
··· 5 5 routing::{get, post}, 6 6 }; 7 7 use serde::{Deserialize, Serialize}; 8 + use std::collections::HashMap; 9 + use std::sync::Arc; 8 10 9 11 use crate::AppState; 12 + use crate::auth::Claims; 10 13 use crate::error::AppError; 14 + use crate::external_auth::{pds_write, state, tokens}; 15 + use crate::plugin::PluginExecutor; 16 + use crate::plugin::sync::SyncProcessor; 11 17 12 18 pub fn routes() -> Router<AppState> { 13 19 Router::new() ··· 48 54 } 49 55 50 56 async fn authorize( 51 - State(state): State<AppState>, 57 + State(app_state): State<AppState>, 52 58 Path(plugin_id): Path<String>, 53 59 Query(query): Query<AuthorizeQuery>, 60 + claims: Claims, 54 61 ) -> Result<Json<serde_json::Value>, AppError> { 55 - let _plugin = state 62 + let _plugin = app_state 56 63 .plugin_registry 57 64 .get(&plugin_id) 58 65 .await ··· 61 68 // Generate state parameter for CSRF protection 62 69 let state_param = uuid::Uuid::new_v4().to_string(); 63 70 64 - // TODO: Store state in KV, call plugin's get_authorize_url() 65 - // For now, return placeholder 66 - let _ = query.redirect_uri; 71 + // Store state -> user mapping for callback validation 72 + state::store_state( 73 + &app_state.db, 74 + app_state.db_backend, 75 + &state_param, 76 + claims.did(), 77 + &plugin_id, 78 + &query.redirect_uri, 79 + ) 80 + .await 81 + .map_err(|e| AppError::Internal(e.to_string()))?; 82 + 83 + // Get plugin config (empty for now, could come from DB) 84 + let config = serde_json::Value::Null; 85 + 86 + // Load secrets from environment 87 + let secrets = load_plugin_secrets(&plugin_id); 88 + 89 + // Create executor and instance 90 + let executor = PluginExecutor::new( 91 + app_state.wasm_runtime.clone(), 92 + app_state.plugin_registry.clone(), 93 + app_state.db.clone(), 94 + app_state.db_backend, 95 + app_state.http.clone(), 96 + Arc::new(app_state.lexicons.clone()), 97 + ); 98 + 99 + let mut instance = executor 100 + .instantiate(&plugin_id, &state_param, secrets, config.clone()) 101 + .await 102 + .map_err(|e| AppError::Internal(e.to_string()))?; 103 + 104 + let authorize_url = instance 105 + .call_get_authorize_url(&state_param, &query.redirect_uri, &config) 106 + .await 107 + .map_err(|e| AppError::Internal(e.to_string()))?; 67 108 68 109 Ok(Json(serde_json::json!({ 69 - "authorize_url": format!("https://example.com/oauth?state={}", state_param), 110 + "authorize_url": authorize_url, 70 111 "state": state_param 71 112 }))) 72 113 } ··· 80 121 } 81 122 82 123 async fn callback( 83 - State(_state): State<AppState>, 84 - Path(_plugin_id): Path<String>, 85 - Query(_query): Query<CallbackQuery>, 124 + State(app_state): State<AppState>, 125 + Path(plugin_id): Path<String>, 126 + Query(query): Query<CallbackQuery>, 86 127 ) -> Result<Redirect, AppError> { 87 - // TODO: Validate state, call plugin's handle_callback(), store tokens 128 + // Validate required parameters 129 + let code = query.code.ok_or_else(|| { 130 + AppError::BadRequest(query.error.unwrap_or_else(|| "Missing code".into())) 131 + })?; 132 + let state_param = query 133 + .state 134 + .ok_or_else(|| AppError::BadRequest("Missing state".into()))?; 135 + 136 + // Validate state and get user DID + redirect_uri 137 + let stored_state = state::consume_state(&app_state.db, app_state.db_backend, &state_param) 138 + .await 139 + .map_err(|_| AppError::BadRequest("Invalid or expired state".into()))?; 140 + 141 + // Verify plugin_id matches 142 + if stored_state.plugin_id != plugin_id { 143 + return Err(AppError::BadRequest("Plugin ID mismatch".into())); 144 + } 145 + 146 + let config = serde_json::Value::Null; 147 + let secrets = load_plugin_secrets(&plugin_id); 148 + 149 + let executor = PluginExecutor::new( 150 + app_state.wasm_runtime.clone(), 151 + app_state.plugin_registry.clone(), 152 + app_state.db.clone(), 153 + app_state.db_backend, 154 + app_state.http.clone(), 155 + Arc::new(app_state.lexicons.clone()), 156 + ); 88 157 89 - // For now, redirect to a placeholder 90 - Ok(Redirect::to("/")) 158 + let mut instance = executor 159 + .instantiate(&plugin_id, &stored_state.did, secrets, config.clone()) 160 + .await 161 + .map_err(|e| AppError::Internal(e.to_string()))?; 162 + 163 + let token_set = instance 164 + .call_handle_callback(&code, &state_param, &config) 165 + .await 166 + .map_err(|e| AppError::Internal(e.to_string()))?; 167 + 168 + // Get profile to get the account_id 169 + let profile = instance 170 + .call_get_profile(&token_set.access_token, &config) 171 + .await 172 + .map_err(|e| AppError::Internal(e.to_string()))?; 173 + 174 + // Format expires_at as RFC3339 string 175 + let expires_at = token_set.expires_at.map(|dt| dt.to_rfc3339()); 176 + 177 + // Store encrypted tokens 178 + tokens::store_tokens( 179 + &app_state.db, 180 + app_state.db_backend, 181 + app_state.config.token_encryption_key.as_ref(), 182 + &stored_state.did, 183 + &plugin_id, 184 + &profile.account_id, 185 + &token_set.access_token, 186 + token_set.refresh_token.as_deref(), 187 + Some(&token_set.token_type), 188 + None, // scope not in TokenSet 189 + expires_at.as_deref(), 190 + ) 191 + .await 192 + .map_err(|e| AppError::Internal(e.to_string()))?; 193 + 194 + // Redirect to the original redirect_uri 195 + Ok(Redirect::to(&stored_state.redirect_uri)) 91 196 } 92 197 93 198 async fn sync( 94 - State(_state): State<AppState>, 95 - Path(_plugin_id): Path<String>, 199 + State(app_state): State<AppState>, 200 + Path(plugin_id): Path<String>, 201 + claims: Claims, 96 202 ) -> Result<Json<serde_json::Value>, AppError> { 97 - // TODO: Call plugin's sync_account(), process SyncRecords 203 + let user_did = claims.did(); 204 + 205 + let config = serde_json::Value::Null; 206 + let secrets = load_plugin_secrets(&plugin_id); 207 + 208 + let executor = PluginExecutor::new( 209 + app_state.wasm_runtime.clone(), 210 + app_state.plugin_registry.clone(), 211 + app_state.db.clone(), 212 + app_state.db_backend, 213 + app_state.http.clone(), 214 + Arc::new(app_state.lexicons.clone()), 215 + ); 216 + 217 + let mut instance = executor 218 + .instantiate(&plugin_id, user_did, secrets, config.clone()) 219 + .await 220 + .map_err(|e| AppError::Internal(e.to_string()))?; 221 + 222 + // Get decrypted access token from DB 223 + let stored = tokens::get_tokens( 224 + &app_state.db, 225 + app_state.db_backend, 226 + app_state.config.token_encryption_key.as_ref(), 227 + user_did, 228 + &plugin_id, 229 + ) 230 + .await 231 + .map_err(|e| AppError::Internal(e.to_string()))?; 232 + 233 + let mut records = instance 234 + .call_sync_account(&stored.access_token, &config) 235 + .await 236 + .map_err(|e| AppError::Internal(e.to_string()))?; 237 + 238 + // Resolve game references from database 239 + crate::plugin::sync::resolve_game_references(&app_state.db, app_state.db_backend, &mut records) 240 + .await; 241 + 242 + // Process records: sign those with sign=true 243 + let signer = app_state.attestation_signer.as_deref(); 244 + let processor = SyncProcessor::new(signer, user_did.to_string()); 245 + let processed = processor 246 + .process_records(records) 247 + .map_err(|e| AppError::Internal(e.to_string()))?; 248 + 249 + let processed_count = processed.len(); 250 + 251 + // Write processed records to user's PDS 252 + let write_results = pds_write::write_records_to_pds(&app_state, user_did, processed).await?; 98 253 99 254 Ok(Json(serde_json::json!({ 100 255 "status": "ok", 101 - "synced": 0 256 + "processed": processed_count, 257 + "written": write_results.len() 102 258 }))) 103 259 } 104 260 105 261 async fn unlink( 106 - State(_state): State<AppState>, 107 - Path(_plugin_id): Path<String>, 262 + State(app_state): State<AppState>, 263 + Path(plugin_id): Path<String>, 264 + claims: Claims, 108 265 ) -> Result<Json<serde_json::Value>, AppError> { 109 - // TODO: Delete tokens, delete accountLink record 266 + let user_did = claims.did(); 267 + 268 + // Delete tokens 269 + let deleted = tokens::delete_tokens(&app_state.db, app_state.db_backend, user_did, &plugin_id) 270 + .await 271 + .map_err(|e| AppError::Internal(e.to_string()))?; 272 + 273 + // TODO: Delete accountLink record from user's PDS 110 274 111 275 Ok(Json(serde_json::json!({ 112 - "status": "ok" 276 + "status": "ok", 277 + "was_linked": deleted 113 278 }))) 114 279 } 280 + 281 + fn load_plugin_secrets(plugin_id: &str) -> HashMap<String, String> { 282 + let prefix = format!("PLUGIN_{}_", plugin_id.to_uppercase()); 283 + std::env::vars() 284 + .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|name| (name.to_string(), v))) 285 + .collect() 286 + }
+108
src/external_auth/state.rs
··· 1 + //! OAuth state management for external auth flows. 2 + //! 3 + //! Stores state -> (user_did, plugin_id, redirect_uri) mappings 4 + //! to validate callbacks and associate external accounts with users. 5 + 6 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 7 + 8 + #[derive(Debug, thiserror::Error)] 9 + pub enum StateError { 10 + #[error("Database error: {0}")] 11 + Database(#[from] sqlx::Error), 12 + #[error("State not found or expired")] 13 + NotFound, 14 + } 15 + 16 + /// Stored OAuth state 17 + #[derive(Debug, Clone)] 18 + pub struct StoredState { 19 + pub did: String, 20 + pub plugin_id: String, 21 + pub redirect_uri: String, 22 + } 23 + 24 + /// Store OAuth state for an auth flow. 25 + /// 26 + /// State expires after 10 minutes. 27 + pub async fn store_state( 28 + db: &sqlx::AnyPool, 29 + backend: DatabaseBackend, 30 + state: &str, 31 + did: &str, 32 + plugin_id: &str, 33 + redirect_uri: &str, 34 + ) -> Result<(), StateError> { 35 + let now = now_rfc3339(); 36 + 37 + // Expire in 10 minutes 38 + let expires_at = chrono::Utc::now() + chrono::Duration::minutes(10); 39 + let expires_str = expires_at.to_rfc3339(); 40 + 41 + let sql = adapt_sql( 42 + "INSERT INTO external_auth_state (state, did, plugin_id, redirect_uri, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)", 43 + backend, 44 + ); 45 + 46 + sqlx::query(&sql) 47 + .bind(state) 48 + .bind(did) 49 + .bind(plugin_id) 50 + .bind(redirect_uri) 51 + .bind(&now) 52 + .bind(&expires_str) 53 + .execute(db) 54 + .await?; 55 + 56 + Ok(()) 57 + } 58 + 59 + /// Retrieve and consume OAuth state. 60 + /// 61 + /// Returns the stored state if found and not expired, then deletes it. 62 + pub async fn consume_state( 63 + db: &sqlx::AnyPool, 64 + backend: DatabaseBackend, 65 + state: &str, 66 + ) -> Result<StoredState, StateError> { 67 + let now = now_rfc3339(); 68 + 69 + // Get state if not expired 70 + let sql = adapt_sql( 71 + "SELECT did, plugin_id, redirect_uri FROM external_auth_state WHERE state = ? AND expires_at > ?", 72 + backend, 73 + ); 74 + 75 + let row: Option<(String, String, String)> = sqlx::query_as(&sql) 76 + .bind(state) 77 + .bind(&now) 78 + .fetch_optional(db) 79 + .await?; 80 + 81 + let (did, plugin_id, redirect_uri) = row.ok_or(StateError::NotFound)?; 82 + 83 + // Delete the state (one-time use) 84 + let delete_sql = adapt_sql("DELETE FROM external_auth_state WHERE state = ?", backend); 85 + sqlx::query(&delete_sql).bind(state).execute(db).await?; 86 + 87 + Ok(StoredState { 88 + did, 89 + plugin_id, 90 + redirect_uri, 91 + }) 92 + } 93 + 94 + /// Clean up expired state entries. 95 + pub async fn cleanup_expired( 96 + db: &sqlx::AnyPool, 97 + backend: DatabaseBackend, 98 + ) -> Result<u64, StateError> { 99 + let now = now_rfc3339(); 100 + 101 + let sql = adapt_sql( 102 + "DELETE FROM external_auth_state WHERE expires_at <= ?", 103 + backend, 104 + ); 105 + let result = sqlx::query(&sql).bind(&now).execute(db).await?; 106 + 107 + Ok(result.rows_affected()) 108 + }
+198
src/external_auth/tokens.rs
··· 1 + //! External account token storage with encryption. 2 + 3 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 4 + use crate::plugin::encryption::{EncryptionError, decrypt, encrypt}; 5 + 6 + /// Row type for token query results 7 + type TokenRow = ( 8 + String, 9 + Vec<u8>, 10 + Option<Vec<u8>>, 11 + Option<String>, 12 + Option<String>, 13 + Option<String>, 14 + ); 15 + 16 + #[derive(Debug, thiserror::Error)] 17 + pub enum TokenError { 18 + #[error("Database error: {0}")] 19 + Database(#[from] sqlx::Error), 20 + #[error("Encryption error: {0}")] 21 + Encryption(#[from] EncryptionError), 22 + #[error("Token encryption key not configured")] 23 + KeyNotConfigured, 24 + #[error("Token not found")] 25 + NotFound, 26 + } 27 + 28 + /// Stored external account token set 29 + #[derive(Debug, Clone)] 30 + pub struct StoredTokens { 31 + pub account_id: String, 32 + pub access_token: String, 33 + pub refresh_token: Option<String>, 34 + pub token_type: Option<String>, 35 + pub scope: Option<String>, 36 + pub expires_at: Option<String>, 37 + } 38 + 39 + /// Store tokens for an external account link 40 + #[allow(clippy::too_many_arguments)] 41 + pub async fn store_tokens( 42 + db: &sqlx::AnyPool, 43 + backend: DatabaseBackend, 44 + encryption_key: Option<&[u8; 32]>, 45 + did: &str, 46 + plugin_id: &str, 47 + account_id: &str, 48 + access_token: &str, 49 + refresh_token: Option<&str>, 50 + token_type: Option<&str>, 51 + scope: Option<&str>, 52 + expires_at: Option<&str>, 53 + ) -> Result<(), TokenError> { 54 + let key = encryption_key.ok_or(TokenError::KeyNotConfigured)?; 55 + 56 + let encrypted_access = encrypt(key, access_token.as_bytes())?; 57 + let encrypted_refresh = refresh_token 58 + .map(|t| encrypt(key, t.as_bytes())) 59 + .transpose()?; 60 + 61 + let id = uuid::Uuid::new_v4().to_string(); 62 + let now = now_rfc3339(); 63 + 64 + let sql = adapt_sql( 65 + "INSERT INTO external_account_tokens (id, did, plugin_id, account_id, access_token, refresh_token, token_type, scope, expires_at, created_at, updated_at) 66 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 67 + ON CONFLICT (did, plugin_id) 68 + DO UPDATE SET account_id = excluded.account_id, access_token = excluded.access_token, refresh_token = excluded.refresh_token, token_type = excluded.token_type, scope = excluded.scope, expires_at = excluded.expires_at, updated_at = excluded.updated_at", 69 + backend, 70 + ); 71 + 72 + sqlx::query(&sql) 73 + .bind(&id) 74 + .bind(did) 75 + .bind(plugin_id) 76 + .bind(account_id) 77 + .bind(&encrypted_access) 78 + .bind(encrypted_refresh.as_deref()) 79 + .bind(token_type) 80 + .bind(scope) 81 + .bind(expires_at) 82 + .bind(&now) 83 + .bind(&now) 84 + .execute(db) 85 + .await?; 86 + 87 + Ok(()) 88 + } 89 + 90 + /// Retrieve decrypted tokens for an external account 91 + pub async fn get_tokens( 92 + db: &sqlx::AnyPool, 93 + backend: DatabaseBackend, 94 + encryption_key: Option<&[u8; 32]>, 95 + did: &str, 96 + plugin_id: &str, 97 + ) -> Result<StoredTokens, TokenError> { 98 + let key = encryption_key.ok_or(TokenError::KeyNotConfigured)?; 99 + 100 + let sql = adapt_sql( 101 + "SELECT account_id, access_token, refresh_token, token_type, scope, expires_at FROM external_account_tokens WHERE did = ? AND plugin_id = ?", 102 + backend, 103 + ); 104 + 105 + let row: Option<TokenRow> = sqlx::query_as(&sql) 106 + .bind(did) 107 + .bind(plugin_id) 108 + .fetch_optional(db) 109 + .await?; 110 + 111 + let (account_id, encrypted_access, encrypted_refresh, token_type, scope, expires_at) = 112 + row.ok_or(TokenError::NotFound)?; 113 + 114 + let access_token = String::from_utf8(decrypt(key, &encrypted_access)?) 115 + .map_err(|_| EncryptionError::DecryptionFailed)?; 116 + 117 + let refresh_token = encrypted_refresh 118 + .map(|enc| { 119 + decrypt(key, &enc).and_then(|dec| { 120 + String::from_utf8(dec).map_err(|_| EncryptionError::DecryptionFailed) 121 + }) 122 + }) 123 + .transpose()?; 124 + 125 + Ok(StoredTokens { 126 + account_id, 127 + access_token, 128 + refresh_token, 129 + token_type, 130 + scope, 131 + expires_at, 132 + }) 133 + } 134 + 135 + /// Delete tokens for an external account link 136 + pub async fn delete_tokens( 137 + db: &sqlx::AnyPool, 138 + backend: DatabaseBackend, 139 + did: &str, 140 + plugin_id: &str, 141 + ) -> Result<bool, TokenError> { 142 + let sql = adapt_sql( 143 + "DELETE FROM external_account_tokens WHERE did = ? AND plugin_id = ?", 144 + backend, 145 + ); 146 + 147 + let result = sqlx::query(&sql) 148 + .bind(did) 149 + .bind(plugin_id) 150 + .execute(db) 151 + .await?; 152 + 153 + Ok(result.rows_affected() > 0) 154 + } 155 + 156 + /// Check if an external account is linked 157 + pub async fn is_linked( 158 + db: &sqlx::AnyPool, 159 + backend: DatabaseBackend, 160 + did: &str, 161 + plugin_id: &str, 162 + ) -> Result<bool, TokenError> { 163 + let sql = adapt_sql( 164 + "SELECT 1 FROM external_account_tokens WHERE did = ? AND plugin_id = ?", 165 + backend, 166 + ); 167 + 168 + let exists: Option<(i32,)> = sqlx::query_as(&sql) 169 + .bind(did) 170 + .bind(plugin_id) 171 + .fetch_optional(db) 172 + .await?; 173 + 174 + Ok(exists.is_some()) 175 + } 176 + 177 + /// Get the external account ID for a linked account 178 + pub async fn get_account_id( 179 + db: &sqlx::AnyPool, 180 + backend: DatabaseBackend, 181 + did: &str, 182 + plugin_id: &str, 183 + ) -> Result<Option<String>, TokenError> { 184 + let sql = adapt_sql( 185 + "SELECT account_id FROM external_account_tokens WHERE did = ? AND plugin_id = ?", 186 + backend, 187 + ); 188 + 189 + let row: Option<(String,)> = sqlx::query_as(&sql) 190 + .bind(did) 191 + .bind(plugin_id) 192 + .fetch_optional(db) 193 + .await?; 194 + 195 + Ok(row.map(|(id,)| id)) 196 + } 197 + 198 + // Integration tests for token storage are in tests/e2e_external_auth.rs
+2
src/lib.rs
··· 59 59 pub oauth: Arc<HappyViewOAuthClient>, 60 60 pub cookie_key: axum_extra::extract::cookie::Key, 61 61 pub plugin_registry: Arc<plugin::PluginRegistry>, 62 + pub wasm_runtime: Arc<plugin::WasmRuntime>, 63 + pub attestation_signer: Option<Arc<plugin::attestation::AttestationSigner>>, 62 64 } 63 65 64 66 impl axum::extract::FromRef<AppState> for axum_extra::extract::cookie::Key {
+4
src/lua/atproto_api.rs
··· 289 289 b"test-secret-for-tests-only-not-production", 290 290 ), 291 291 plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), 292 + wasm_runtime: std::sync::Arc::new( 293 + crate::plugin::WasmRuntime::new().expect("wasm runtime"), 294 + ), 295 + attestation_signer: None, 292 296 } 293 297 } 294 298
+4
src/lua/db_api.rs
··· 697 697 b"test-secret-for-tests-only-not-production", 698 698 ), 699 699 plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), 700 + wasm_runtime: std::sync::Arc::new( 701 + crate::plugin::WasmRuntime::new().expect("wasm runtime"), 702 + ), 703 + attestation_signer: None, 700 704 } 701 705 } 702 706
+4
src/lua/execute.rs
··· 1031 1031 b"test-secret-for-tests-only-not-production", 1032 1032 ), 1033 1033 plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), 1034 + wasm_runtime: std::sync::Arc::new( 1035 + crate::plugin::WasmRuntime::new().expect("wasm runtime"), 1036 + ), 1037 + attestation_signer: None, 1034 1038 } 1035 1039 } 1036 1040
+4
src/lua/http_api.rs
··· 171 171 b"test-secret-for-tests-only-not-production", 172 172 ), 173 173 plugin_registry: std::sync::Arc::new(crate::plugin::PluginRegistry::new()), 174 + wasm_runtime: std::sync::Arc::new( 175 + crate::plugin::WasmRuntime::new().expect("wasm runtime"), 176 + ), 177 + attestation_signer: None, 174 178 } 175 179 } 176 180
+22
src/main.rs
··· 176 176 // Initialize plugin registry 177 177 let plugin_registry = Arc::new(happyview::plugin::PluginRegistry::new()); 178 178 179 + // Initialize WASM runtime 180 + let wasm_runtime = 181 + Arc::new(happyview::plugin::WasmRuntime::new().expect("Failed to create WASM runtime")); 182 + 183 + // Initialize attestation signer (optional) 184 + let attestation_signer = match happyview::plugin::attestation::load_from_env() { 185 + Ok(Some(signer)) => { 186 + tracing::info!("Attestation signing enabled"); 187 + Some(Arc::new(signer)) 188 + } 189 + Ok(None) => { 190 + tracing::info!("Attestation signing disabled (no ATTESTATION_PRIVATE_KEY)"); 191 + None 192 + } 193 + Err(e) => { 194 + tracing::error!(error = %e, "Failed to load attestation signer"); 195 + None 196 + } 197 + }; 198 + 179 199 // Load plugins from PLUGIN_URLS env var 180 200 if let Ok(urls) = std::env::var("PLUGIN_URLS") { 181 201 for (id, url, sha256) in happyview::plugin::loader::parse_plugin_urls(&urls) { ··· 319 339 oauth: Arc::new(oauth_client), 320 340 cookie_key, 321 341 plugin_registry, 342 + wasm_runtime, 343 + attestation_signer, 322 344 }; 323 345 324 346 // Sync initial collections to Tap on startup.
+331
src/plugin/attestation.rs
··· 1 + //! Attestation signing for plugin records. 2 + //! 3 + //! Implements the ATProtocol attestation spec: 4 + //! - Computes CID with $sig metadata for replay protection 5 + //! - Signs using ECDSA (P-256 or K-256) 6 + //! - Adds inline signatures to records 7 + 8 + use cid::Cid; 9 + use k256::ecdsa::{Signature, SigningKey, signature::Signer}; 10 + use serde_json::{Map, Value}; 11 + use sha2::{Digest, Sha256}; 12 + use std::sync::Arc; 13 + 14 + // Multihash code for SHA2-256 15 + const SHA2_256_CODE: u64 = 0x12; 16 + // DAG-CBOR codec 17 + const DAG_CBOR_CODEC: u64 = 0x71; 18 + 19 + /// Attestation signer for HappyView 20 + pub struct AttestationSigner { 21 + /// The signing key (K-256/secp256k1) 22 + signing_key: SigningKey, 23 + /// The key identifier (e.g., "did:web:happyview.example#attestation") 24 + key_id: String, 25 + /// The signature type identifier 26 + sig_type: String, 27 + } 28 + 29 + #[derive(Debug, thiserror::Error)] 30 + pub enum AttestationError { 31 + #[error("Failed to encode record: {0}")] 32 + Encoding(String), 33 + #[error("Failed to sign: {0}")] 34 + Signing(String), 35 + #[error("Invalid key: {0}")] 36 + InvalidKey(String), 37 + #[error("Record missing required field: {0}")] 38 + MissingField(String), 39 + } 40 + 41 + impl AttestationSigner { 42 + /// Create a new signer from a hex-encoded private key 43 + pub fn from_hex( 44 + private_key_hex: &str, 45 + key_id: String, 46 + sig_type: String, 47 + ) -> Result<Self, AttestationError> { 48 + let key_bytes = hex::decode(private_key_hex) 49 + .map_err(|e| AttestationError::InvalidKey(format!("invalid hex: {}", e)))?; 50 + 51 + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()) 52 + .map_err(|e| AttestationError::InvalidKey(format!("invalid key: {}", e)))?; 53 + 54 + Ok(Self { 55 + signing_key, 56 + key_id, 57 + sig_type, 58 + }) 59 + } 60 + 61 + /// Create a new signer with a test key (for testing only) 62 + #[cfg(test)] 63 + pub fn for_testing(key_id: String, sig_type: String) -> Self { 64 + // Fixed test key (32 bytes of 0x01) - DO NOT USE IN PRODUCTION 65 + let test_key_bytes = [0x01u8; 32]; 66 + let signing_key = 67 + SigningKey::from_bytes((&test_key_bytes[..]).into()).expect("valid test key"); 68 + Self { 69 + signing_key, 70 + key_id, 71 + sig_type, 72 + } 73 + } 74 + 75 + /// Get the public key in compressed format (for verification) 76 + pub fn public_key_bytes(&self) -> Vec<u8> { 77 + use k256::ecdsa::VerifyingKey; 78 + let verifying_key = VerifyingKey::from(&self.signing_key); 79 + verifying_key.to_encoded_point(true).as_bytes().to_vec() 80 + } 81 + 82 + /// Sign a record and add the signature to the signatures array. 83 + /// 84 + /// # Arguments 85 + /// * `record` - The record to sign (will be modified to add signature) 86 + /// * `repository_did` - The DID of the repository (for replay protection) 87 + /// 88 + /// # Returns 89 + /// The CID of the signed content 90 + pub fn sign_record( 91 + &self, 92 + record: &mut Value, 93 + repository_did: &str, 94 + ) -> Result<Cid, AttestationError> { 95 + let obj = record 96 + .as_object_mut() 97 + .ok_or_else(|| AttestationError::Encoding("record must be an object".into()))?; 98 + 99 + // Remove existing signatures for CID computation 100 + let existing_signatures = obj.remove("signatures"); 101 + 102 + // Inject $sig metadata for CID computation 103 + let sig_metadata = serde_json::json!({ 104 + "$type": &self.sig_type, 105 + "repository": repository_did, 106 + }); 107 + obj.insert("$sig".to_string(), sig_metadata); 108 + 109 + // Encode to CBOR (DAG-CBOR canonical form) 110 + let cbor_bytes = self.encode_dag_cbor(obj)?; 111 + 112 + // Compute CID (sha2-256, dag-cbor codec) 113 + let cid = self.compute_cid(&cbor_bytes); 114 + 115 + // Remove $sig (it's only for CID computation) 116 + obj.remove("$sig"); 117 + 118 + // Sign the CID bytes 119 + let signature = self.sign_cid(&cid)?; 120 + 121 + // Create inline signature object 122 + let inline_sig = serde_json::json!({ 123 + "$type": &self.sig_type, 124 + "key": &self.key_id, 125 + "signature": { 126 + "$bytes": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &signature) 127 + } 128 + }); 129 + 130 + // Add to signatures array 131 + let signatures = obj 132 + .entry("signatures") 133 + .or_insert_with(|| Value::Array(vec![])); 134 + 135 + if let Value::Array(arr) = signatures { 136 + // Restore any existing signatures 137 + if let Some(Value::Array(existing)) = existing_signatures { 138 + for sig in existing { 139 + arr.push(sig); 140 + } 141 + } 142 + arr.push(inline_sig); 143 + } 144 + 145 + Ok(cid) 146 + } 147 + 148 + /// Encode a JSON object to DAG-CBOR canonical form 149 + fn encode_dag_cbor(&self, obj: &Map<String, Value>) -> Result<Vec<u8>, AttestationError> { 150 + // Convert to ciborium Value and encode 151 + // DAG-CBOR requires deterministic key ordering (lexicographic) 152 + let cbor_value = json_to_cbor(&Value::Object(obj.clone())); 153 + 154 + let mut buf = Vec::new(); 155 + ciborium::into_writer(&cbor_value, &mut buf) 156 + .map_err(|e| AttestationError::Encoding(format!("CBOR encoding failed: {}", e)))?; 157 + 158 + Ok(buf) 159 + } 160 + 161 + /// Compute CID from CBOR bytes (sha2-256, dag-cbor codec) 162 + fn compute_cid(&self, cbor_bytes: &[u8]) -> Cid { 163 + // SHA2-256 hash 164 + let digest = Sha256::digest(cbor_bytes); 165 + 166 + // Create multihash: varint(code) || varint(size) || digest 167 + let mut multihash_bytes = Vec::new(); 168 + // SHA2-256 code (0x12) 169 + multihash_bytes.push(SHA2_256_CODE as u8); 170 + // Digest size (32 bytes) 171 + multihash_bytes.push(32u8); 172 + // The digest 173 + multihash_bytes.extend_from_slice(&digest); 174 + 175 + let multihash = 176 + cid::multihash::Multihash::<64>::from_bytes(&multihash_bytes).expect("valid multihash"); 177 + 178 + // CID v1 with dag-cbor codec 179 + Cid::new_v1(DAG_CBOR_CODEC, multihash) 180 + } 181 + 182 + /// Sign a CID using ECDSA with low-S normalization 183 + fn sign_cid(&self, cid: &Cid) -> Result<Vec<u8>, AttestationError> { 184 + let cid_bytes = cid.to_bytes(); 185 + 186 + // Sign using k256 ECDSA (automatically uses low-S) 187 + let signature: Signature = self.signing_key.sign(&cid_bytes); 188 + 189 + Ok(signature.to_bytes().to_vec()) 190 + } 191 + } 192 + 193 + /// Convert JSON Value to ciborium Value with deterministic ordering 194 + fn json_to_cbor(value: &Value) -> ciborium::Value { 195 + match value { 196 + Value::Null => ciborium::Value::Null, 197 + Value::Bool(b) => ciborium::Value::Bool(*b), 198 + Value::Number(n) => { 199 + if let Some(i) = n.as_i64() { 200 + ciborium::Value::Integer(i.into()) 201 + } else if let Some(u) = n.as_u64() { 202 + ciborium::Value::Integer(u.into()) 203 + } else if let Some(f) = n.as_f64() { 204 + ciborium::Value::Float(f) 205 + } else { 206 + ciborium::Value::Null 207 + } 208 + } 209 + Value::String(s) => { 210 + // Check for $bytes encoding (base64) 211 + ciborium::Value::Text(s.clone()) 212 + } 213 + Value::Array(arr) => ciborium::Value::Array(arr.iter().map(json_to_cbor).collect()), 214 + Value::Object(obj) => { 215 + // Handle special $bytes encoding for binary data 216 + if obj.len() == 1 217 + && let Some(Value::String(b64)) = obj.get("$bytes") 218 + && let Ok(bytes) = 219 + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64) 220 + { 221 + return ciborium::Value::Bytes(bytes); 222 + } 223 + 224 + // Sort keys lexicographically for deterministic encoding 225 + let mut pairs: Vec<_> = obj 226 + .iter() 227 + .map(|(k, v)| (ciborium::Value::Text(k.clone()), json_to_cbor(v))) 228 + .collect(); 229 + pairs.sort_by(|a, b| { 230 + if let (ciborium::Value::Text(ka), ciborium::Value::Text(kb)) = (&a.0, &b.0) { 231 + ka.cmp(kb) 232 + } else { 233 + std::cmp::Ordering::Equal 234 + } 235 + }); 236 + 237 + ciborium::Value::Map(pairs) 238 + } 239 + } 240 + } 241 + 242 + /// Shared attestation signer for the application 243 + pub type SharedAttestationSigner = Arc<AttestationSigner>; 244 + 245 + /// Load attestation signer from environment variables 246 + pub fn load_from_env() -> Result<Option<AttestationSigner>, AttestationError> { 247 + let private_key = match std::env::var("ATTESTATION_PRIVATE_KEY") { 248 + Ok(k) => k, 249 + Err(_) => return Ok(None), // No key configured, attestation disabled 250 + }; 251 + 252 + let key_id = std::env::var("ATTESTATION_KEY_ID") 253 + .unwrap_or_else(|_| "did:web:localhost#attestation".to_string()); 254 + 255 + let sig_type = std::env::var("ATTESTATION_SIG_TYPE") 256 + .unwrap_or_else(|_| "games.gamesgamesgamesgames.attestation".to_string()); 257 + 258 + Ok(Some(AttestationSigner::from_hex( 259 + &private_key, 260 + key_id, 261 + sig_type, 262 + )?)) 263 + } 264 + 265 + #[cfg(test)] 266 + mod tests { 267 + use super::*; 268 + 269 + #[test] 270 + fn test_sign_record() { 271 + let signer = AttestationSigner::for_testing( 272 + "did:web:test.example#signing".to_string(), 273 + "test.signature".to_string(), 274 + ); 275 + 276 + let mut record = serde_json::json!({ 277 + "$type": "games.gamesgamesgamesgames.actor.game", 278 + "game": {"platform": "steam", "externalId": "440"}, 279 + "platform": "steam", 280 + "createdAt": "2024-01-01T00:00:00Z" 281 + }); 282 + 283 + let cid = signer 284 + .sign_record(&mut record, "did:plc:testuser") 285 + .expect("signing should succeed"); 286 + 287 + // Verify signature was added 288 + let signatures = record["signatures"].as_array().expect("signatures array"); 289 + assert_eq!(signatures.len(), 1); 290 + 291 + let sig = &signatures[0]; 292 + assert_eq!(sig["$type"], "test.signature"); 293 + assert_eq!(sig["key"], "did:web:test.example#signing"); 294 + assert!(sig["signature"]["$bytes"].is_string()); 295 + 296 + // CID should be valid 297 + assert!(!cid.to_bytes().is_empty()); 298 + } 299 + 300 + #[test] 301 + fn test_deterministic_cid() { 302 + let signer = AttestationSigner::for_testing( 303 + "did:web:test.example#signing".to_string(), 304 + "test.signature".to_string(), 305 + ); 306 + 307 + // Same record should produce same CID (before signature) 308 + let record1 = serde_json::json!({ 309 + "a": 1, 310 + "b": 2, 311 + "c": {"nested": true} 312 + }); 313 + 314 + let record2 = serde_json::json!({ 315 + "c": {"nested": true}, 316 + "a": 1, 317 + "b": 2 318 + }); 319 + 320 + let mut r1 = record1.clone(); 321 + let mut r2 = record2.clone(); 322 + 323 + let cid1 = signer.sign_record(&mut r1, "did:plc:test").unwrap(); 324 + let cid2 = signer.sign_record(&mut r2, "did:plc:test").unwrap(); 325 + 326 + // Different signatures (random nonce in ECDSA) but... 327 + // Actually the CIDs should be the same since they're computed before signing 328 + // and the key ordering is normalized 329 + assert_eq!(cid1, cid2); 330 + } 331 + }
+483
src/plugin/executor.rs
··· 1 + // src/plugin/executor.rs 2 + 3 + use crate::db::DatabaseBackend; 4 + use crate::lexicon::LexiconRegistry; 5 + use crate::plugin::host::{PluginState, register_host_functions}; 6 + use crate::plugin::memory::{ 7 + PluginEnvelopeError, PluginResponse, dealloc_guest, read_from_guest, write_to_guest, 8 + }; 9 + use crate::plugin::runtime::{DEFAULT_FUEL, WasmRuntime}; 10 + use crate::plugin::{ExternalProfile, PluginInfo, PluginRegistry, SyncRecord, TokenSet}; 11 + use std::collections::HashMap; 12 + use std::sync::Arc; 13 + use thiserror::Error; 14 + use wasmtime::{Instance, Linker, Memory, Store, TypedFunc}; 15 + 16 + #[derive(Debug, Error)] 17 + pub enum ExecutionError { 18 + #[error("Plugin not found: {0}")] 19 + PluginNotFound(String), 20 + 21 + #[error("WASM instantiation failed: {0}")] 22 + Instantiation(#[source] anyhow::Error), 23 + 24 + #[error("Memory allocation failed")] 25 + MemoryAllocation, 26 + 27 + #[error("Plugin function trapped: {0}")] 28 + Trap(#[source] wasmtime::Error), 29 + 30 + #[error("Invalid response from plugin: {0}")] 31 + InvalidResponse(String), 32 + 33 + #[error("Plugin returned error: {code} - {message}")] 34 + PluginError { 35 + code: String, 36 + message: String, 37 + retryable: bool, 38 + }, 39 + 40 + #[error("Resource limit exceeded: {0}")] 41 + ResourceLimit(String), 42 + 43 + #[error("Timeout (fuel exhausted)")] 44 + Timeout, 45 + 46 + #[error("Missing export: {0}")] 47 + MissingExport(String), 48 + } 49 + 50 + impl From<PluginEnvelopeError> for ExecutionError { 51 + fn from(e: PluginEnvelopeError) -> Self { 52 + ExecutionError::PluginError { 53 + code: e.code, 54 + message: e.message, 55 + retryable: e.retryable, 56 + } 57 + } 58 + } 59 + 60 + /// Single-use wrapper around a WASM instance 61 + #[allow(dead_code)] 62 + pub struct PluginInstance { 63 + pub(crate) store: Store<PluginState>, 64 + pub(crate) instance: Instance, 65 + pub(crate) memory: Memory, 66 + pub(crate) alloc: TypedFunc<u32, u32>, 67 + pub(crate) dealloc: TypedFunc<(u32, u32), ()>, 68 + } 69 + 70 + impl PluginInstance { 71 + /// Call plugin_info() - no input required 72 + pub async fn call_plugin_info(&mut self) -> Result<PluginInfo, ExecutionError> { 73 + let func = self 74 + .instance 75 + .get_typed_func::<(), i64>(&mut self.store, "plugin_info") 76 + .map_err(|_| ExecutionError::MissingExport("plugin_info".into()))?; 77 + 78 + self.store 79 + .set_fuel(DEFAULT_FUEL) 80 + .map_err(ExecutionError::Trap)?; 81 + 82 + let packed = func 83 + .call_async(&mut self.store, ()) 84 + .await 85 + .map_err(Self::classify_error)?; 86 + 87 + // Unpack i64: upper 32 bits = ptr, lower 32 bits = len 88 + let ptr = (packed >> 32) as u32; 89 + let len = (packed & 0xFFFFFFFF) as u32; 90 + 91 + let bytes = 92 + read_from_guest(&self.store, ptr, len).map_err(|_| ExecutionError::MemoryAllocation)?; 93 + 94 + dealloc_guest(&mut self.store, ptr, len) 95 + .await 96 + .map_err(|_| ExecutionError::MemoryAllocation)?; 97 + 98 + let response: PluginResponse<PluginInfo> = serde_json::from_slice(&bytes) 99 + .map_err(|e| ExecutionError::InvalidResponse(e.to_string()))?; 100 + 101 + response.into_result().map_err(ExecutionError::from) 102 + } 103 + 104 + /// Call get_authorize_url(state, redirect_uri, config) 105 + pub async fn call_get_authorize_url( 106 + &mut self, 107 + state: &str, 108 + redirect_uri: &str, 109 + config: &serde_json::Value, 110 + ) -> Result<String, ExecutionError> { 111 + let input = serde_json::json!({ 112 + "state": state, 113 + "redirect_uri": redirect_uri, 114 + "config": config 115 + }); 116 + self.call_plugin_function("get_authorize_url", &input).await 117 + } 118 + 119 + /// Call handle_callback(code, state, config) 120 + pub async fn call_handle_callback( 121 + &mut self, 122 + code: &str, 123 + state: &str, 124 + config: &serde_json::Value, 125 + ) -> Result<TokenSet, ExecutionError> { 126 + let input = serde_json::json!({ 127 + "code": code, 128 + "state": state, 129 + "config": config 130 + }); 131 + self.call_plugin_function("handle_callback", &input).await 132 + } 133 + 134 + /// Call refresh_tokens(refresh_token, config) 135 + pub async fn call_refresh_tokens( 136 + &mut self, 137 + refresh_token: &str, 138 + config: &serde_json::Value, 139 + ) -> Result<TokenSet, ExecutionError> { 140 + let input = serde_json::json!({ 141 + "refresh_token": refresh_token, 142 + "config": config 143 + }); 144 + self.call_plugin_function("refresh_tokens", &input).await 145 + } 146 + 147 + /// Call get_profile(access_token, config) 148 + pub async fn call_get_profile( 149 + &mut self, 150 + access_token: &str, 151 + config: &serde_json::Value, 152 + ) -> Result<ExternalProfile, ExecutionError> { 153 + let input = serde_json::json!({ 154 + "access_token": access_token, 155 + "config": config 156 + }); 157 + self.call_plugin_function("get_profile", &input).await 158 + } 159 + 160 + /// Call sync_account(access_token, config) 161 + pub async fn call_sync_account( 162 + &mut self, 163 + access_token: &str, 164 + config: &serde_json::Value, 165 + ) -> Result<Vec<SyncRecord>, ExecutionError> { 166 + let input = serde_json::json!({ 167 + "access_token": access_token, 168 + "config": config 169 + }); 170 + self.call_plugin_function("sync_account", &input).await 171 + } 172 + 173 + /// Generic helper for plugin functions with input and typed output 174 + async fn call_plugin_function<T: serde::de::DeserializeOwned>( 175 + &mut self, 176 + name: &str, 177 + input: &serde_json::Value, 178 + ) -> Result<T, ExecutionError> { 179 + let input_bytes = serde_json::to_vec(input) 180 + .map_err(|e| ExecutionError::InvalidResponse(e.to_string()))?; 181 + 182 + let func = self 183 + .instance 184 + .get_typed_func::<(u32, u32), i64>(&mut self.store, name) 185 + .map_err(|_| ExecutionError::MissingExport(name.into()))?; 186 + 187 + self.store 188 + .set_fuel(DEFAULT_FUEL) 189 + .map_err(ExecutionError::Trap)?; 190 + 191 + let (input_ptr, input_len) = write_to_guest(&mut self.store, &input_bytes) 192 + .await 193 + .map_err(|_| ExecutionError::MemoryAllocation)?; 194 + 195 + let packed = func 196 + .call_async(&mut self.store, (input_ptr, input_len)) 197 + .await 198 + .map_err(Self::classify_error)?; 199 + 200 + // Unpack i64: upper 32 bits = ptr, lower 32 bits = len 201 + let ptr = (packed >> 32) as u32; 202 + let len = (packed & 0xFFFFFFFF) as u32; 203 + 204 + let bytes = 205 + read_from_guest(&self.store, ptr, len).map_err(|_| ExecutionError::MemoryAllocation)?; 206 + 207 + dealloc_guest(&mut self.store, ptr, len) 208 + .await 209 + .map_err(|_| ExecutionError::MemoryAllocation)?; 210 + 211 + let response: PluginResponse<T> = serde_json::from_slice(&bytes) 212 + .map_err(|e| ExecutionError::InvalidResponse(e.to_string()))?; 213 + 214 + response.into_result().map_err(ExecutionError::from) 215 + } 216 + 217 + /// Classify a wasmtime error as Timeout or Trap 218 + fn classify_error(e: wasmtime::Error) -> ExecutionError { 219 + if e.to_string().contains("fuel") { 220 + ExecutionError::Timeout 221 + } else { 222 + ExecutionError::Trap(e) 223 + } 224 + } 225 + } 226 + 227 + /// Factory for creating plugin instances 228 + pub struct PluginExecutor { 229 + runtime: Arc<WasmRuntime>, 230 + registry: Arc<PluginRegistry>, 231 + db: sqlx::AnyPool, 232 + db_backend: DatabaseBackend, 233 + http_client: reqwest::Client, 234 + lexicons: Arc<LexiconRegistry>, 235 + } 236 + 237 + impl PluginExecutor { 238 + pub fn new( 239 + runtime: Arc<WasmRuntime>, 240 + registry: Arc<PluginRegistry>, 241 + db: sqlx::AnyPool, 242 + db_backend: DatabaseBackend, 243 + http_client: reqwest::Client, 244 + lexicons: Arc<LexiconRegistry>, 245 + ) -> Self { 246 + Self { 247 + runtime, 248 + registry, 249 + db, 250 + db_backend, 251 + http_client, 252 + lexicons, 253 + } 254 + } 255 + 256 + /// Instantiate a plugin with the given scope 257 + pub async fn instantiate( 258 + &self, 259 + plugin_id: &str, 260 + scope: &str, 261 + secrets: HashMap<String, String>, 262 + config: serde_json::Value, 263 + ) -> Result<PluginInstance, ExecutionError> { 264 + // Get plugin from registry 265 + let plugin = self 266 + .registry 267 + .get(plugin_id) 268 + .await 269 + .ok_or_else(|| ExecutionError::PluginNotFound(plugin_id.to_string()))?; 270 + 271 + // Compile module 272 + let module = self 273 + .runtime 274 + .compile(&plugin.wasm_bytes) 275 + .map_err(ExecutionError::Instantiation)?; 276 + 277 + // Create linker with host functions 278 + let mut linker = Linker::new(self.runtime.engine()); 279 + register_host_functions(&mut linker).map_err(ExecutionError::Instantiation)?; 280 + 281 + // Create store with initial state (memory/alloc/dealloc set to None) 282 + // Note: db is Option<sqlx::AnyPool> in PluginState 283 + let state = PluginState { 284 + plugin_id: plugin_id.to_string(), 285 + scope: scope.to_string(), 286 + secrets, 287 + config, 288 + db: Some(self.db.clone()), 289 + db_backend: self.db_backend, 290 + http_client: self.http_client.clone(), 291 + lexicons: self.lexicons.clone(), 292 + usage: Default::default(), 293 + memory: None, 294 + alloc: None, 295 + dealloc: None, 296 + }; 297 + 298 + let mut store = Store::new(self.runtime.engine(), state); 299 + store 300 + .set_fuel(DEFAULT_FUEL) 301 + .map_err(ExecutionError::Instantiation)?; 302 + 303 + // Instantiate module 304 + let instance = linker 305 + .instantiate_async(&mut store, &module) 306 + .await 307 + .map_err(ExecutionError::Instantiation)?; 308 + 309 + // Get memory export 310 + let memory = instance 311 + .get_memory(&mut store, "memory") 312 + .ok_or_else(|| ExecutionError::MissingExport("memory".into()))?; 313 + 314 + // Get alloc/dealloc exports 315 + let alloc = instance 316 + .get_typed_func::<u32, u32>(&mut store, "alloc") 317 + .map_err(|_| ExecutionError::MissingExport("alloc".into()))?; 318 + let dealloc = instance 319 + .get_typed_func::<(u32, u32), ()>(&mut store, "dealloc") 320 + .map_err(|_| ExecutionError::MissingExport("dealloc".into()))?; 321 + 322 + // Store memory/alloc/dealloc in state 323 + store.data_mut().memory = Some(memory); 324 + store.data_mut().alloc = Some(alloc.clone()); 325 + store.data_mut().dealloc = Some(dealloc.clone()); 326 + 327 + Ok(PluginInstance { 328 + store, 329 + instance, 330 + memory, 331 + alloc, 332 + dealloc, 333 + }) 334 + } 335 + } 336 + 337 + #[cfg(test)] 338 + mod tests { 339 + use super::*; 340 + 341 + #[test] 342 + fn test_execution_error_plugin_not_found() { 343 + let err = ExecutionError::PluginNotFound("steam".into()); 344 + assert!(err.to_string().contains("steam")); 345 + assert!(err.to_string().contains("not found")); 346 + } 347 + 348 + #[test] 349 + fn test_execution_error_timeout() { 350 + let err = ExecutionError::Timeout; 351 + assert!( 352 + err.to_string().to_lowercase().contains("timeout") || err.to_string().contains("fuel") 353 + ); 354 + } 355 + 356 + #[test] 357 + fn test_plugin_error_conversion() { 358 + let plugin_err = PluginEnvelopeError { 359 + code: "AUTH_FAILED".into(), 360 + message: "Bad token".into(), 361 + retryable: true, 362 + }; 363 + let exec_err: ExecutionError = plugin_err.into(); 364 + match exec_err { 365 + ExecutionError::PluginError { 366 + code, 367 + message, 368 + retryable, 369 + } => { 370 + assert_eq!(code, "AUTH_FAILED"); 371 + assert_eq!(message, "Bad token"); 372 + assert!(retryable); 373 + } 374 + _ => panic!("Wrong error variant"), 375 + } 376 + } 377 + 378 + #[test] 379 + fn test_all_error_variants_have_display() { 380 + let errors: Vec<ExecutionError> = vec![ 381 + ExecutionError::PluginNotFound("test".into()), 382 + ExecutionError::MemoryAllocation, 383 + ExecutionError::InvalidResponse("bad json".into()), 384 + ExecutionError::ResourceLimit("too many requests".into()), 385 + ExecutionError::Timeout, 386 + ExecutionError::MissingExport("plugin_info".into()), 387 + ]; 388 + for err in errors { 389 + assert!(!err.to_string().is_empty()); 390 + } 391 + } 392 + 393 + #[test] 394 + fn test_plugin_executor_new_signature() { 395 + // Verify PluginExecutor::new exists with expected signature (compile-time check) 396 + fn _check_signature( 397 + _runtime: std::sync::Arc<crate::plugin::WasmRuntime>, 398 + _registry: std::sync::Arc<crate::plugin::PluginRegistry>, 399 + _db: sqlx::AnyPool, 400 + _db_backend: crate::db::DatabaseBackend, 401 + _http_client: reqwest::Client, 402 + _lexicons: std::sync::Arc<crate::lexicon::LexiconRegistry>, 403 + ) -> PluginExecutor { 404 + PluginExecutor::new( 405 + _runtime, 406 + _registry, 407 + _db, 408 + _db_backend, 409 + _http_client, 410 + _lexicons, 411 + ) 412 + } 413 + } 414 + 415 + #[test] 416 + fn test_plugin_instance_struct_exists() { 417 + // Verify PluginInstance struct has expected fields (compile-time check) 418 + fn _check_fields(instance: PluginInstance) { 419 + let _ = instance.store; 420 + let _ = instance.instance; 421 + let _ = instance.memory; 422 + let _ = instance.alloc; 423 + let _ = instance.dealloc; 424 + } 425 + } 426 + 427 + #[test] 428 + fn test_plugin_instance_has_expected_methods() { 429 + // Compile-time check that methods exist with expected signatures 430 + fn _check_call_plugin_info<'a>( 431 + inst: &'a mut PluginInstance, 432 + ) -> impl std::future::Future<Output = Result<crate::plugin::PluginInfo, ExecutionError>> + 'a 433 + { 434 + inst.call_plugin_info() 435 + } 436 + 437 + fn _check_call_get_authorize_url<'a>( 438 + inst: &'a mut PluginInstance, 439 + state: &'a str, 440 + redirect_uri: &'a str, 441 + config: &'a serde_json::Value, 442 + ) -> impl std::future::Future<Output = Result<String, ExecutionError>> + 'a { 443 + inst.call_get_authorize_url(state, redirect_uri, config) 444 + } 445 + 446 + fn _check_call_handle_callback<'a>( 447 + inst: &'a mut PluginInstance, 448 + code: &'a str, 449 + state: &'a str, 450 + config: &'a serde_json::Value, 451 + ) -> impl std::future::Future<Output = Result<crate::plugin::TokenSet, ExecutionError>> + 'a 452 + { 453 + inst.call_handle_callback(code, state, config) 454 + } 455 + 456 + fn _check_call_refresh_tokens<'a>( 457 + inst: &'a mut PluginInstance, 458 + refresh_token: &'a str, 459 + config: &'a serde_json::Value, 460 + ) -> impl std::future::Future<Output = Result<crate::plugin::TokenSet, ExecutionError>> + 'a 461 + { 462 + inst.call_refresh_tokens(refresh_token, config) 463 + } 464 + 465 + fn _check_call_get_profile<'a>( 466 + inst: &'a mut PluginInstance, 467 + access_token: &'a str, 468 + config: &'a serde_json::Value, 469 + ) -> impl std::future::Future<Output = Result<crate::plugin::ExternalProfile, ExecutionError>> + 'a 470 + { 471 + inst.call_get_profile(access_token, config) 472 + } 473 + 474 + fn _check_call_sync_account<'a>( 475 + inst: &'a mut PluginInstance, 476 + access_token: &'a str, 477 + config: &'a serde_json::Value, 478 + ) -> impl std::future::Future<Output = Result<Vec<crate::plugin::SyncRecord>, ExecutionError>> + 'a 479 + { 480 + inst.call_sync_account(access_token, config) 481 + } 482 + } 483 + }
+438
src/plugin/host/bindings.rs
··· 1 + use std::collections::HashMap; 2 + use std::sync::Arc; 3 + use wasmtime::{Linker, Memory, TypedFunc}; 4 + 5 + /// State stored in wasmtime's Store during plugin execution 6 + pub struct PluginState { 7 + pub plugin_id: String, 8 + pub scope: String, 9 + pub secrets: HashMap<String, String>, 10 + pub config: serde_json::Value, 11 + pub db: Option<sqlx::AnyPool>, 12 + pub db_backend: crate::db::DatabaseBackend, 13 + pub http_client: reqwest::Client, 14 + pub lexicons: Arc<crate::lexicon::LexiconRegistry>, 15 + pub usage: super::ResourceUsage, 16 + pub memory: Option<Memory>, 17 + pub alloc: Option<TypedFunc<u32, u32>>, 18 + pub dealloc: Option<TypedFunc<(u32, u32), ()>>, 19 + } 20 + 21 + /// Check that a memory access is within bounds 22 + fn check_bounds(offset: usize, length: usize, mem_size: usize) -> Result<(usize, usize), ()> { 23 + if length == 0 { 24 + return Ok((offset, offset)); 25 + } 26 + let end = offset.checked_add(length).ok_or(())?; 27 + if end > mem_size { 28 + return Err(()); 29 + } 30 + Ok((offset, end)) 31 + } 32 + 33 + /// Register all host functions with the linker 34 + pub fn register_host_functions(linker: &mut Linker<PluginState>) -> Result<(), wasmtime::Error> { 35 + // Sync functions 36 + linker.func_wrap("env", "host_log", host_log)?; 37 + linker.func_wrap("env", "host_get_secret", host_get_secret)?; 38 + 39 + // Async functions - HTTP 40 + linker.func_wrap_async( 41 + "env", 42 + "host_http_request", 43 + |mut caller: wasmtime::Caller<'_, PluginState>, (req_ptr, req_len): (i32, i32)| { 44 + Box::new(async move { host_http_request_impl(&mut caller, req_ptr, req_len).await }) 45 + }, 46 + )?; 47 + 48 + // Async functions - KV 49 + linker.func_wrap_async( 50 + "env", 51 + "host_kv_get", 52 + |mut caller: wasmtime::Caller<'_, PluginState>, (key_ptr, key_len): (i32, i32)| { 53 + Box::new(async move { host_kv_get_impl(&mut caller, key_ptr, key_len).await }) 54 + }, 55 + )?; 56 + 57 + linker.func_wrap_async( 58 + "env", 59 + "host_kv_set", 60 + |mut caller: wasmtime::Caller<'_, PluginState>, 61 + (key_ptr, key_len, val_ptr, val_len, ttl): (i32, i32, i32, i32, i32)| { 62 + Box::new(async move { 63 + host_kv_set_impl(&mut caller, key_ptr, key_len, val_ptr, val_len, ttl).await 64 + }) 65 + }, 66 + )?; 67 + 68 + linker.func_wrap_async( 69 + "env", 70 + "host_kv_delete", 71 + |mut caller: wasmtime::Caller<'_, PluginState>, (key_ptr, key_len): (i32, i32)| { 72 + Box::new(async move { host_kv_delete_impl(&mut caller, key_ptr, key_len).await }) 73 + }, 74 + )?; 75 + 76 + // Async functions - Record lookup 77 + linker.func_wrap_async( 78 + "env", 79 + "host_lookup_record", 80 + |mut caller: wasmtime::Caller<'_, PluginState>, (req_ptr, req_len): (i32, i32)| { 81 + Box::new(async move { host_lookup_record_impl(&mut caller, req_ptr, req_len).await }) 82 + }, 83 + )?; 84 + 85 + Ok(()) 86 + } 87 + 88 + /// Read a string from guest memory 89 + fn read_guest_string( 90 + caller: &wasmtime::Caller<'_, PluginState>, 91 + ptr: i32, 92 + len: i32, 93 + ) -> Option<String> { 94 + let memory = caller.data().memory?; 95 + let mem_data = memory.data(caller); 96 + let (start, end) = check_bounds(ptr as usize, len as usize, mem_data.len()).ok()?; 97 + std::str::from_utf8(&mem_data[start..end]) 98 + .ok() 99 + .map(String::from) 100 + } 101 + 102 + /// Read raw bytes from guest memory 103 + fn read_guest_bytes( 104 + caller: &wasmtime::Caller<'_, PluginState>, 105 + ptr: i32, 106 + len: i32, 107 + ) -> Option<Vec<u8>> { 108 + let memory = caller.data().memory?; 109 + let mem_data = memory.data(caller); 110 + let (start, end) = check_bounds(ptr as usize, len as usize, mem_data.len()).ok()?; 111 + Some(mem_data[start..end].to_vec()) 112 + } 113 + 114 + /// Write response data to guest memory, returning packed (ptr << 32) | len 115 + async fn write_guest_response(caller: &mut wasmtime::Caller<'_, PluginState>, data: &[u8]) -> i64 { 116 + let memory = match caller.data().memory { 117 + Some(m) => m, 118 + None => return 0, 119 + }; 120 + let alloc = match &caller.data().alloc { 121 + Some(a) => a.clone(), 122 + None => return 0, 123 + }; 124 + 125 + let len = data.len() as u32; 126 + let ptr = match alloc.call_async(&mut *caller, len).await { 127 + Ok(p) if p != 0 => p, 128 + _ => return 0, 129 + }; 130 + 131 + let mem_data = memory.data_mut(caller); 132 + if check_bounds(ptr as usize, len as usize, mem_data.len()).is_err() { 133 + return 0; 134 + } 135 + 136 + mem_data[ptr as usize..(ptr as usize + len as usize)].copy_from_slice(data); 137 + ((ptr as i64) << 32) | (len as i64) 138 + } 139 + 140 + /// Host function: log a message from the plugin 141 + fn host_log( 142 + caller: wasmtime::Caller<'_, PluginState>, 143 + level_ptr: i32, 144 + level_len: i32, 145 + msg_ptr: i32, 146 + msg_len: i32, 147 + ) { 148 + let memory = match caller.data().memory { 149 + Some(m) => m, 150 + None => return, 151 + }; 152 + 153 + let mem_data = memory.data(&caller); 154 + let mem_size = mem_data.len(); 155 + 156 + let (level_start, level_end) = 157 + match check_bounds(level_ptr as usize, level_len as usize, mem_size) { 158 + Ok(bounds) => bounds, 159 + Err(_) => return, 160 + }; 161 + 162 + let (msg_start, msg_end) = match check_bounds(msg_ptr as usize, msg_len as usize, mem_size) { 163 + Ok(bounds) => bounds, 164 + Err(_) => return, 165 + }; 166 + 167 + let level = std::str::from_utf8(&mem_data[level_start..level_end]).unwrap_or("info"); 168 + let msg = std::str::from_utf8(&mem_data[msg_start..msg_end]).unwrap_or(""); 169 + 170 + let plugin_id = &caller.data().plugin_id; 171 + let log_level: super::LogLevel = level.parse().unwrap_or_default(); 172 + super::log(plugin_id, log_level, msg); 173 + } 174 + 175 + /// Host function: get a secret value by name 176 + /// Returns a packed i64: (ptr << 32) | len, or 0 on error 177 + fn host_get_secret( 178 + mut caller: wasmtime::Caller<'_, PluginState>, 179 + name_ptr: i32, 180 + name_len: i32, 181 + ) -> i64 { 182 + let memory = match caller.data().memory { 183 + Some(m) => m, 184 + None => return 0, 185 + }; 186 + let alloc = match &caller.data().alloc { 187 + Some(a) => a.clone(), 188 + None => return 0, 189 + }; 190 + 191 + let mem_data = memory.data(&caller); 192 + let mem_size = mem_data.len(); 193 + 194 + let (name_start, name_end) = match check_bounds(name_ptr as usize, name_len as usize, mem_size) 195 + { 196 + Ok(bounds) => bounds, 197 + Err(_) => return 0, 198 + }; 199 + 200 + let name = match std::str::from_utf8(&mem_data[name_start..name_end]) { 201 + Ok(s) => s, 202 + Err(_) => return 0, 203 + }; 204 + 205 + let value = match caller.data().secrets.get(name) { 206 + Some(v) => v.clone(), 207 + None => return 0, 208 + }; 209 + 210 + let len = value.len() as u32; 211 + let ptr = match alloc.call(&mut caller, len) { 212 + Ok(p) if p != 0 => p, 213 + _ => return 0, 214 + }; 215 + 216 + let mem_data = memory.data_mut(&mut caller); 217 + if check_bounds(ptr as usize, len as usize, mem_data.len()).is_err() { 218 + return 0; 219 + } 220 + 221 + mem_data[ptr as usize..(ptr as usize + len as usize)].copy_from_slice(value.as_bytes()); 222 + 223 + ((ptr as i64) << 32) | (len as i64) 224 + } 225 + 226 + // ============================================================================ 227 + // Async host function implementations 228 + // ============================================================================ 229 + 230 + /// Build a HostContext from PluginState, requires db to be present 231 + fn build_host_context(state: &PluginState) -> Option<super::HostContext> { 232 + let db = state.db.clone()?; 233 + Some(super::HostContext { 234 + plugin_id: state.plugin_id.clone(), 235 + scope: state.scope.clone(), 236 + secrets: state.secrets.clone(), 237 + config: state.config.clone(), 238 + db, 239 + db_backend: state.db_backend, 240 + http_client: state.http_client.clone(), 241 + lexicons: state.lexicons.clone(), 242 + }) 243 + } 244 + 245 + /// Host function: make an HTTP request 246 + async fn host_http_request_impl( 247 + caller: &mut wasmtime::Caller<'_, PluginState>, 248 + req_ptr: i32, 249 + req_len: i32, 250 + ) -> i64 { 251 + let req_bytes = match read_guest_bytes(caller, req_ptr, req_len) { 252 + Some(b) => b, 253 + None => return 0, 254 + }; 255 + 256 + let request: super::HttpRequest = match serde_json::from_slice(&req_bytes) { 257 + Ok(r) => r, 258 + Err(_) => return 0, 259 + }; 260 + 261 + let ctx = match build_host_context(caller.data()) { 262 + Some(c) => c, 263 + None => return 0, 264 + }; 265 + 266 + let result = { 267 + let usage = &mut caller.data_mut().usage; 268 + super::http_request(&ctx, usage, request).await 269 + }; 270 + 271 + let response_bytes = match result { 272 + Ok(resp) => serde_json::to_vec(&serde_json::json!({"ok": resp})).unwrap_or_default(), 273 + Err(e) => serde_json::to_vec(&serde_json::json!({ 274 + "error": {"code": "HTTP_ERROR", "message": e.to_string(), "retryable": false} 275 + })) 276 + .unwrap_or_default(), 277 + }; 278 + 279 + write_guest_response(caller, &response_bytes).await 280 + } 281 + 282 + /// Host function: get a value from KV store 283 + async fn host_kv_get_impl( 284 + caller: &mut wasmtime::Caller<'_, PluginState>, 285 + key_ptr: i32, 286 + key_len: i32, 287 + ) -> i64 { 288 + let key = match read_guest_string(caller, key_ptr, key_len) { 289 + Some(k) => k, 290 + None => return 0, 291 + }; 292 + 293 + let ctx = match build_host_context(caller.data()) { 294 + Some(c) => c, 295 + None => return 0, 296 + }; 297 + 298 + let result = super::kv_get(&ctx, &key).await; 299 + 300 + let response_bytes = match result { 301 + Ok(Some(value)) => { 302 + serde_json::to_vec(&serde_json::json!({"ok": value})).unwrap_or_default() 303 + } 304 + Ok(None) => return 0, 305 + Err(e) => serde_json::to_vec(&serde_json::json!({ 306 + "error": {"code": "KV_ERROR", "message": e.to_string(), "retryable": false} 307 + })) 308 + .unwrap_or_default(), 309 + }; 310 + 311 + write_guest_response(caller, &response_bytes).await 312 + } 313 + 314 + /// Host function: set a value in KV store 315 + async fn host_kv_set_impl( 316 + caller: &mut wasmtime::Caller<'_, PluginState>, 317 + key_ptr: i32, 318 + key_len: i32, 319 + val_ptr: i32, 320 + val_len: i32, 321 + ttl: i32, 322 + ) -> i32 { 323 + let key = match read_guest_string(caller, key_ptr, key_len) { 324 + Some(k) => k, 325 + None => return -1, 326 + }; 327 + let value = match read_guest_bytes(caller, val_ptr, val_len) { 328 + Some(v) => v, 329 + None => return -1, 330 + }; 331 + 332 + let ttl_secs = if ttl > 0 { Some(ttl as u32) } else { None }; 333 + 334 + let ctx = match build_host_context(caller.data()) { 335 + Some(c) => c, 336 + None => return -1, 337 + }; 338 + 339 + let usage = &mut caller.data_mut().usage; 340 + match super::kv_set(&ctx, usage, &key, value, ttl_secs).await { 341 + Ok(()) => 0, 342 + Err(_) => -1, 343 + } 344 + } 345 + 346 + /// Host function: delete a value from KV store 347 + async fn host_kv_delete_impl( 348 + caller: &mut wasmtime::Caller<'_, PluginState>, 349 + key_ptr: i32, 350 + key_len: i32, 351 + ) -> i32 { 352 + let key = match read_guest_string(caller, key_ptr, key_len) { 353 + Some(k) => k, 354 + None => return -1, 355 + }; 356 + 357 + let ctx = match build_host_context(caller.data()) { 358 + Some(c) => c, 359 + None => return -1, 360 + }; 361 + 362 + match super::kv_delete(&ctx, &key).await { 363 + Ok(()) => 0, 364 + Err(_) => -1, 365 + } 366 + } 367 + 368 + /// Host function: look up an AT Protocol record 369 + async fn host_lookup_record_impl( 370 + caller: &mut wasmtime::Caller<'_, PluginState>, 371 + req_ptr: i32, 372 + req_len: i32, 373 + ) -> i64 { 374 + let req_bytes = match read_guest_bytes(caller, req_ptr, req_len) { 375 + Some(b) => b, 376 + None => return 0, 377 + }; 378 + 379 + let request: super::LookupRequest = match serde_json::from_slice(&req_bytes) { 380 + Ok(r) => r, 381 + Err(_) => return 0, 382 + }; 383 + 384 + let ctx = match build_host_context(caller.data()) { 385 + Some(c) => c, 386 + None => return 0, 387 + }; 388 + 389 + let result = super::lookup_record_by_request(&ctx, request).await; 390 + 391 + let response_bytes = match result { 392 + Ok(record) => serde_json::to_vec(&serde_json::json!({"ok": record})).unwrap_or_default(), 393 + Err(e) => serde_json::to_vec(&serde_json::json!({ 394 + "error": {"code": "LOOKUP_ERROR", "message": e.to_string(), "retryable": false} 395 + })) 396 + .unwrap_or_default(), 397 + }; 398 + 399 + write_guest_response(caller, &response_bytes).await 400 + } 401 + 402 + #[cfg(test)] 403 + mod tests { 404 + use super::*; 405 + 406 + #[test] 407 + fn test_plugin_state_fields_exist() { 408 + fn _check_fields(state: &PluginState) { 409 + let _ = &state.plugin_id; 410 + let _ = &state.scope; 411 + let _ = &state.secrets; 412 + let _ = &state.config; 413 + let _ = &state.usage; 414 + let _ = &state.memory; 415 + let _ = &state.alloc; 416 + let _ = &state.dealloc; 417 + } 418 + } 419 + 420 + #[test] 421 + fn test_pack_ptr_len() { 422 + let ptr: u32 = 0x1000; 423 + let len: u32 = 0x0100; 424 + let packed: i64 = ((ptr as i64) << 32) | (len as i64); 425 + let unpacked_ptr = (packed >> 32) as u32; 426 + let unpacked_len = (packed & 0xFFFFFFFF) as u32; 427 + assert_eq!(unpacked_ptr, ptr); 428 + assert_eq!(unpacked_len, len); 429 + } 430 + 431 + #[test] 432 + fn test_bounds_check_helper() { 433 + assert!(check_bounds(0, 10, 100).is_ok()); 434 + assert!(check_bounds(90, 10, 100).is_ok()); 435 + assert!(check_bounds(91, 10, 100).is_err()); 436 + assert!(check_bounds(0, 0, 100).is_ok()); 437 + } 438 + }
+31
src/plugin/host/lookup.rs
··· 1 + use serde::Deserialize; 2 + 1 3 use super::HostContext; 2 4 use crate::db::adapt_sql; 3 5 use crate::plugin::StrongRef; ··· 8 10 Database(#[from] sqlx::Error), 9 11 #[error("Invalid external ID field path")] 10 12 InvalidFieldPath, 13 + } 14 + 15 + #[derive(Debug, Deserialize)] 16 + pub struct LookupRequest { 17 + pub collection: String, 18 + pub external_id_field: String, 19 + pub external_id_value: String, 11 20 } 12 21 13 22 /// Look up a record by external ID ··· 48 57 Ok(result.map(|(uri, cid)| StrongRef { uri, cid })) 49 58 } 50 59 60 + pub async fn lookup_record_by_request( 61 + ctx: &HostContext, 62 + request: LookupRequest, 63 + ) -> Result<Option<StrongRef>, LookupError> { 64 + lookup_record( 65 + ctx, 66 + &request.collection, 67 + &request.external_id_field, 68 + &request.external_id_value, 69 + ) 70 + .await 71 + } 72 + 51 73 #[cfg(test)] 52 74 mod tests { 53 75 use super::*; ··· 63 85 fn test_lookup_error_display() { 64 86 let err = LookupError::InvalidFieldPath; 65 87 assert_eq!(err.to_string(), "Invalid external ID field path"); 88 + } 89 + 90 + #[test] 91 + fn test_lookup_request_deserialize() { 92 + let json = r#"{"collection": "games.example.game", "external_id_field": "externalIds.steam", "external_id_value": "123"}"#; 93 + let req: LookupRequest = serde_json::from_str(json).unwrap(); 94 + assert_eq!(req.collection, "games.example.game"); 95 + assert_eq!(req.external_id_field, "externalIds.steam"); 96 + assert_eq!(req.external_id_value, "123"); 66 97 } 67 98 }
+2
src/plugin/host/mod.rs
··· 1 + mod bindings; 1 2 mod http; 2 3 mod kv; 3 4 mod logging; 4 5 mod lookup; 5 6 mod secrets; 6 7 8 + pub use bindings::{PluginState, register_host_functions}; 7 9 pub use http::*; 8 10 pub use kv::*; 9 11 pub use logging::*;
+102 -14
src/plugin/loader.rs
··· 1 + use crate::plugin::host::{PluginState, register_host_functions}; 2 + use crate::plugin::memory::PluginResponse; 3 + use crate::plugin::runtime::DEFAULT_FUEL; 1 4 use crate::plugin::{LoadedPlugin, PluginInfo, PluginSource}; 2 5 use sha2::{Digest, Sha256}; 6 + use std::collections::HashMap; 3 7 use std::path::Path; 8 + use wasmtime::{Config, Engine, Linker, Module, Store}; 4 9 5 10 const SUPPORTED_API_VERSION: &str = "1"; 6 11 ··· 84 89 85 90 /// Extract plugin info by instantiating WASM and calling plugin_info() 86 91 fn extract_plugin_info(wasm_bytes: &[u8]) -> Result<PluginInfo, LoadError> { 87 - // TODO: Full implementation with wasmtime 88 - // For now, this is a placeholder that will be filled in when we integrate wasmtime calls 92 + match tokio::runtime::Handle::try_current() { 93 + Ok(handle) => { 94 + tokio::task::block_in_place(|| handle.block_on(extract_plugin_info_async(wasm_bytes))) 95 + } 96 + Err(_) => { 97 + let rt = tokio::runtime::Runtime::new().map_err(|e| { 98 + LoadError::WasmValidation(format!("failed to create runtime: {}", e)) 99 + })?; 100 + rt.block_on(extract_plugin_info_async(wasm_bytes)) 101 + } 102 + } 103 + } 104 + 105 + /// Async implementation of plugin info extraction via WASM instantiation 106 + async fn extract_plugin_info_async(wasm_bytes: &[u8]) -> Result<PluginInfo, LoadError> { 107 + // Create async-enabled engine with fuel 108 + let mut config = Config::new(); 109 + config.async_support(true); 110 + config.consume_fuel(true); 111 + let engine = Engine::new(&config).map_err(|e| LoadError::WasmValidation(e.to_string()))?; 112 + 113 + let module = 114 + Module::new(&engine, wasm_bytes).map_err(|e| LoadError::WasmValidation(e.to_string()))?; 115 + 116 + // Create linker with host functions 117 + let mut linker = Linker::new(&engine); 118 + register_host_functions(&mut linker).map_err(|e| LoadError::WasmValidation(e.to_string()))?; 89 119 90 - // Validate it's valid WASM 91 - wasmtime::Module::validate(&wasmtime::Engine::default(), wasm_bytes) 120 + // Create minimal state - no db needed for plugin_info() 121 + let state = PluginState { 122 + plugin_id: "loading".into(), 123 + scope: "".into(), 124 + secrets: HashMap::new(), 125 + config: serde_json::Value::Null, 126 + db: None, // Not needed for plugin_info 127 + db_backend: crate::db::DatabaseBackend::Sqlite, 128 + http_client: reqwest::Client::new(), 129 + lexicons: std::sync::Arc::new(crate::lexicon::LexiconRegistry::new()), 130 + usage: Default::default(), 131 + memory: None, 132 + alloc: None, 133 + dealloc: None, 134 + }; 135 + 136 + let mut store = Store::new(&engine, state); 137 + store 138 + .set_fuel(DEFAULT_FUEL) 92 139 .map_err(|e| LoadError::WasmValidation(e.to_string()))?; 93 140 94 - // Return placeholder - real implementation calls plugin_info() export 95 - Ok(PluginInfo { 96 - id: "placeholder".into(), 97 - name: "Placeholder".into(), 98 - version: "0.0.0".into(), 99 - api_version: SUPPORTED_API_VERSION.into(), 100 - icon_url: None, 101 - required_secrets: vec![], 102 - config_schema: None, 103 - }) 141 + // Instantiate 142 + let instance = linker 143 + .instantiate_async(&mut store, &module) 144 + .await 145 + .map_err(|e| LoadError::WasmValidation(format!("instantiation failed: {}", e)))?; 146 + 147 + // Get memory and alloc/dealloc 148 + let memory = instance 149 + .get_memory(&mut store, "memory") 150 + .ok_or_else(|| LoadError::WasmValidation("missing memory export".into()))?; 151 + let alloc = instance 152 + .get_typed_func::<u32, u32>(&mut store, "alloc") 153 + .map_err(|_| LoadError::WasmValidation("missing alloc export".into()))?; 154 + let dealloc = instance 155 + .get_typed_func::<(u32, u32), ()>(&mut store, "dealloc") 156 + .map_err(|_| LoadError::WasmValidation("missing dealloc export".into()))?; 157 + 158 + // Store in state 159 + store.data_mut().memory = Some(memory); 160 + store.data_mut().alloc = Some(alloc); 161 + store.data_mut().dealloc = Some(dealloc); 162 + 163 + // Call plugin_info 164 + let func = instance 165 + .get_typed_func::<(), i64>(&mut store, "plugin_info") 166 + .map_err(|_| LoadError::WasmValidation("missing plugin_info export".into()))?; 167 + 168 + let packed = func 169 + .call_async(&mut store, ()) 170 + .await 171 + .map_err(|e| LoadError::WasmValidation(format!("plugin_info failed: {}", e)))?; 172 + 173 + // Unpack i64: upper 32 bits = ptr, lower 32 bits = len 174 + let ptr = (packed >> 32) as u32; 175 + let len = (packed & 0xFFFFFFFF) as u32; 176 + 177 + // Read result from memory 178 + let mem_data = memory.data(&store); 179 + if (ptr as usize) + (len as usize) > mem_data.len() { 180 + return Err(LoadError::WasmValidation( 181 + "plugin_info returned out of bounds pointer".into(), 182 + )); 183 + } 184 + let bytes = mem_data[ptr as usize..(ptr as usize + len as usize)].to_vec(); 185 + 186 + // Parse response 187 + let response: PluginResponse<PluginInfo> = serde_json::from_slice(&bytes)?; 188 + 189 + response 190 + .into_result() 191 + .map_err(|e| LoadError::WasmValidation(format!("plugin error: {}", e.message))) 104 192 } 105 193 106 194 fn validate_api_version(info: &PluginInfo) -> Result<(), LoadError> {
+193
src/plugin/memory.rs
··· 1 + use crate::plugin::host::PluginState; 2 + use serde::{Deserialize, Serialize}; 3 + use thiserror::Error; 4 + use wasmtime::Store; 5 + 6 + /// Error returned from a plugin via JSON envelope. 7 + /// Uses a string code for flexibility in parsing arbitrary error codes from plugins. 8 + #[derive(Debug, Clone, Serialize, Deserialize)] 9 + pub struct PluginEnvelopeError { 10 + pub code: String, 11 + pub message: String, 12 + #[serde(default)] 13 + pub retryable: bool, 14 + } 15 + 16 + impl std::fmt::Display for PluginEnvelopeError { 17 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 18 + write!(f, "{}: {}", self.code, self.message) 19 + } 20 + } 21 + 22 + impl std::error::Error for PluginEnvelopeError {} 23 + 24 + /// JSON envelope for plugin responses. 25 + /// Plugins return either `{"ok": result}` or `{"error": {...}}`. 26 + #[derive(Debug, Deserialize)] 27 + #[serde(untagged)] 28 + pub enum PluginResponse<T> { 29 + Ok { ok: T }, 30 + Error { error: PluginEnvelopeError }, 31 + } 32 + 33 + impl<T> PluginResponse<T> { 34 + pub fn into_result(self) -> Result<T, PluginEnvelopeError> { 35 + match self { 36 + PluginResponse::Ok { ok } => Ok(ok), 37 + PluginResponse::Error { error } => Err(error), 38 + } 39 + } 40 + } 41 + 42 + #[derive(Debug, Error)] 43 + pub enum MemoryError { 44 + #[error("Memory allocation failed: alloc returned 0")] 45 + AllocationFailed, 46 + #[error( 47 + "Memory access out of bounds: offset {offset} + length {length} exceeds memory size {size}" 48 + )] 49 + OutOfBounds { 50 + offset: usize, 51 + length: usize, 52 + size: usize, 53 + }, 54 + #[error("WASM trap during memory operation: {0}")] 55 + Trap(#[from] wasmtime::Error), 56 + } 57 + 58 + /// Write data to WASM guest memory by calling alloc and copying bytes. 59 + /// Returns (ptr, len) tuple on success. 60 + pub async fn write_to_guest( 61 + store: &mut Store<PluginState>, 62 + data: &[u8], 63 + ) -> Result<(u32, u32), MemoryError> { 64 + let len = data.len() as u32; 65 + if len == 0 { 66 + return Ok((0, 0)); 67 + } 68 + 69 + let alloc = store 70 + .data() 71 + .alloc 72 + .as_ref() 73 + .ok_or(MemoryError::AllocationFailed)? 74 + .clone(); 75 + let memory = store.data().memory.ok_or(MemoryError::AllocationFailed)?; 76 + 77 + let ptr = alloc.call_async(&mut *store, len).await?; 78 + if ptr == 0 { 79 + return Err(MemoryError::AllocationFailed); 80 + } 81 + 82 + let mem_size = memory.data_size(&*store); 83 + let start = ptr as usize; 84 + let end = start 85 + .checked_add(len as usize) 86 + .ok_or(MemoryError::OutOfBounds { 87 + offset: start, 88 + length: len as usize, 89 + size: mem_size, 90 + })?; 91 + 92 + if end > mem_size { 93 + return Err(MemoryError::OutOfBounds { 94 + offset: start, 95 + length: len as usize, 96 + size: mem_size, 97 + }); 98 + } 99 + 100 + memory.data_mut(&mut *store)[start..end].copy_from_slice(data); 101 + Ok((ptr, len)) 102 + } 103 + 104 + /// Read data from WASM guest memory at the given pointer and length. 105 + pub fn read_from_guest( 106 + store: &Store<PluginState>, 107 + ptr: u32, 108 + len: u32, 109 + ) -> Result<Vec<u8>, MemoryError> { 110 + if len == 0 { 111 + return Ok(Vec::new()); 112 + } 113 + 114 + let memory = store.data().memory.ok_or(MemoryError::AllocationFailed)?; 115 + let mem_size = memory.data_size(store); 116 + let start = ptr as usize; 117 + let end = start 118 + .checked_add(len as usize) 119 + .ok_or(MemoryError::OutOfBounds { 120 + offset: start, 121 + length: len as usize, 122 + size: mem_size, 123 + })?; 124 + 125 + if end > mem_size { 126 + return Err(MemoryError::OutOfBounds { 127 + offset: start, 128 + length: len as usize, 129 + size: mem_size, 130 + }); 131 + } 132 + 133 + Ok(memory.data(store)[start..end].to_vec()) 134 + } 135 + 136 + /// Deallocate guest memory by calling the dealloc function. 137 + pub async fn dealloc_guest( 138 + store: &mut Store<PluginState>, 139 + ptr: u32, 140 + len: u32, 141 + ) -> Result<(), MemoryError> { 142 + if len == 0 { 143 + return Ok(()); 144 + } 145 + 146 + let dealloc = store 147 + .data() 148 + .dealloc 149 + .as_ref() 150 + .ok_or(MemoryError::AllocationFailed)? 151 + .clone(); 152 + dealloc.call_async(&mut *store, (ptr, len)).await?; 153 + Ok(()) 154 + } 155 + 156 + #[cfg(test)] 157 + mod tests { 158 + use super::*; 159 + 160 + #[test] 161 + fn test_memory_error_display() { 162 + let err = MemoryError::AllocationFailed; 163 + assert!(err.to_string().contains("alloc")); 164 + 165 + let err = MemoryError::OutOfBounds { 166 + offset: 100, 167 + length: 50, 168 + size: 120, 169 + }; 170 + assert!(err.to_string().contains("100")); 171 + } 172 + 173 + #[test] 174 + fn test_plugin_response_ok_parses() { 175 + let json = r#"{"ok": "hello"}"#; 176 + let resp: PluginResponse<String> = serde_json::from_str(json).unwrap(); 177 + let result = resp.into_result(); 178 + assert!(result.is_ok()); 179 + assert_eq!(result.unwrap(), "hello"); 180 + } 181 + 182 + #[test] 183 + fn test_plugin_response_error_parses() { 184 + let json = 185 + r#"{"error": {"code": "AUTH_FAILED", "message": "Invalid token", "retryable": true}}"#; 186 + let resp: PluginResponse<String> = serde_json::from_str(json).unwrap(); 187 + let result = resp.into_result(); 188 + assert!(result.is_err()); 189 + let err = result.unwrap_err(); 190 + assert_eq!(err.code, "AUTH_FAILED"); 191 + assert!(err.retryable); 192 + } 193 + }
+6
src/plugin/mod.rs
··· 1 + pub mod attestation; 1 2 pub mod encryption; 3 + pub mod executor; 2 4 pub mod host; 3 5 pub mod loader; 6 + pub mod memory; 4 7 mod runtime; 8 + pub mod sync; 5 9 mod types; 6 10 11 + pub use executor::{ExecutionError, PluginExecutor, PluginInstance}; 12 + pub use memory::{MemoryError, PluginEnvelopeError, PluginResponse}; 7 13 pub use runtime::WasmRuntime; 8 14 pub use types::*; 9 15
+35
src/plugin/runtime.rs
··· 1 1 use wasmtime::*; 2 2 3 + /// Default fuel for plugin execution (≈100ms CPU time) 4 + pub const DEFAULT_FUEL: u64 = 10_000_000; 5 + 3 6 /// WASM runtime for executing plugins 4 7 pub struct WasmRuntime { 5 8 engine: Engine, ··· 9 12 pub fn new() -> Result<Self, anyhow::Error> { 10 13 let mut config = Config::new(); 11 14 config.async_support(true); 15 + config.consume_fuel(true); 12 16 13 17 let engine = Engine::new(&config)?; 14 18 ··· 17 21 18 22 pub fn engine(&self) -> &Engine { 19 23 &self.engine 24 + } 25 + 26 + /// Compile a WASM module 27 + pub fn compile(&self, wasm_bytes: &[u8]) -> Result<Module, anyhow::Error> { 28 + Module::new(&self.engine, wasm_bytes) 20 29 } 21 30 } 22 31 ··· 25 34 Self::new().expect("Failed to create WASM runtime") 26 35 } 27 36 } 37 + 38 + #[cfg(test)] 39 + mod tests { 40 + use super::*; 41 + 42 + #[test] 43 + fn test_fuel_constant_value() { 44 + // 10M fuel ≈ 100ms CPU time per spec 45 + assert_eq!(DEFAULT_FUEL, 10_000_000); 46 + } 47 + 48 + #[test] 49 + fn test_runtime_has_fuel_enabled() { 50 + let runtime = WasmRuntime::new().expect("Failed to create runtime"); 51 + // We can verify fuel is enabled by checking we can set it on a store 52 + let mut store = wasmtime::Store::new(runtime.engine(), ()); 53 + assert!(store.set_fuel(1000).is_ok()); 54 + } 55 + 56 + #[test] 57 + fn test_compile_invalid_wasm_fails() { 58 + let runtime = WasmRuntime::new().expect("Failed to create runtime"); 59 + let result = runtime.compile(b"not valid wasm"); 60 + assert!(result.is_err()); 61 + } 62 + }
+312
src/plugin/sync.rs
··· 1 + //! SyncRecord processing pipeline. 2 + //! 3 + //! Processes records returned by plugin sync_account(): 4 + //! - Signs records that have `sign: true` 5 + //! - Resolves game references 6 + //! - Prepares records for writing to PDS 7 + 8 + use super::attestation::{AttestationError, AttestationSigner}; 9 + use super::types::SyncRecord; 10 + use crate::db::{DatabaseBackend, adapt_sql}; 11 + use serde_json::Value; 12 + 13 + /// Processed record ready for storage 14 + #[derive(Debug, Clone)] 15 + pub struct ProcessedRecord { 16 + /// The collection (lexicon ID) 17 + pub collection: String, 18 + /// The processed record with signatures added 19 + pub record: Value, 20 + /// Deduplication key 21 + pub dedup_key: Option<String>, 22 + /// CID of the signed content (if signed) 23 + pub content_cid: Option<String>, 24 + } 25 + 26 + /// Error during sync record processing 27 + #[derive(Debug, thiserror::Error)] 28 + pub enum SyncError { 29 + #[error("Attestation signing failed: {0}")] 30 + Attestation(#[from] AttestationError), 31 + 32 + #[error("Game reference resolution failed: {0}")] 33 + GameResolution(String), 34 + 35 + #[error("Invalid record: {0}")] 36 + InvalidRecord(String), 37 + } 38 + 39 + /// Process a batch of SyncRecords from a plugin 40 + pub struct SyncProcessor<'a> { 41 + /// Attestation signer (optional - if None, signing is skipped) 42 + signer: Option<&'a AttestationSigner>, 43 + /// Repository DID for the user (used in $sig for replay protection) 44 + repository_did: String, 45 + } 46 + 47 + impl<'a> SyncProcessor<'a> { 48 + /// Create a new sync processor 49 + pub fn new(signer: Option<&'a AttestationSigner>, repository_did: String) -> Self { 50 + Self { 51 + signer, 52 + repository_did, 53 + } 54 + } 55 + 56 + /// Process a batch of SyncRecords 57 + pub fn process_records( 58 + &self, 59 + records: Vec<SyncRecord>, 60 + ) -> Result<Vec<ProcessedRecord>, SyncError> { 61 + let mut processed = Vec::with_capacity(records.len()); 62 + 63 + for record in records { 64 + processed.push(self.process_record(record)?); 65 + } 66 + 67 + Ok(processed) 68 + } 69 + 70 + /// Process a single SyncRecord 71 + fn process_record(&self, sync_record: SyncRecord) -> Result<ProcessedRecord, SyncError> { 72 + let mut record = sync_record.record; 73 + 74 + // Resolve game references if present 75 + self.resolve_game_ref(&mut record)?; 76 + 77 + // Sign if requested and signer is available 78 + let content_cid = if sync_record.sign { 79 + if let Some(signer) = self.signer { 80 + let cid = signer.sign_record(&mut record, &self.repository_did)?; 81 + Some(cid.to_string()) 82 + } else { 83 + tracing::warn!( 84 + collection = %sync_record.collection, 85 + "Record requested signing but no signer configured" 86 + ); 87 + None 88 + } 89 + } else { 90 + None 91 + }; 92 + 93 + Ok(ProcessedRecord { 94 + collection: sync_record.collection, 95 + record, 96 + dedup_key: sync_record.dedup_key, 97 + content_cid, 98 + }) 99 + } 100 + 101 + /// Resolve game references in a record 102 + /// 103 + /// Looks for game references like `{"platform": "steam", "externalId": "440"}` 104 + /// and attempts to resolve them to AT URIs. 105 + fn resolve_game_ref(&self, record: &mut Value) -> Result<(), SyncError> { 106 + // Look for "game" field with platform/externalId structure 107 + if let Some(obj) = record.as_object_mut() 108 + && let Some(game_ref) = obj.get("game") 109 + && let Some(game_obj) = game_ref.as_object() 110 + && game_obj.contains_key("platform") 111 + && game_obj.contains_key("externalId") 112 + && !game_obj.contains_key("uri") 113 + { 114 + // Unresolved reference - log for debugging 115 + let platform = game_obj 116 + .get("platform") 117 + .and_then(|v| v.as_str()) 118 + .unwrap_or("unknown"); 119 + let external_id = game_obj 120 + .get("externalId") 121 + .and_then(|v| v.as_str()) 122 + .unwrap_or("unknown"); 123 + 124 + tracing::debug!( 125 + platform = %platform, 126 + external_id = %external_id, 127 + "Game reference left unresolved - resolution not yet implemented" 128 + ); 129 + } 130 + 131 + Ok(()) 132 + } 133 + } 134 + 135 + /// Helper to create a sync processor with common setup 136 + pub fn create_processor<'a>( 137 + signer: Option<&'a AttestationSigner>, 138 + user_did: &str, 139 + ) -> SyncProcessor<'a> { 140 + SyncProcessor::new(signer, user_did.to_string()) 141 + } 142 + 143 + /// Resolve game references in records by looking up in the database. 144 + /// 145 + /// Looks for `game: {platform: "steam", externalId: "440"}` and converts to 146 + /// `game: {uri: "at://...", cid: "..."}` if found. 147 + pub async fn resolve_game_references( 148 + db: &sqlx::AnyPool, 149 + backend: DatabaseBackend, 150 + records: &mut [SyncRecord], 151 + ) { 152 + for record in records.iter_mut() { 153 + let Some(obj) = record.record.as_object_mut() else { 154 + continue; 155 + }; 156 + let Some(game_ref) = obj.get("game").cloned() else { 157 + continue; 158 + }; 159 + let Some(game_obj) = game_ref.as_object() else { 160 + continue; 161 + }; 162 + 163 + // Check for unresolved reference 164 + if !game_obj.contains_key("platform") 165 + || !game_obj.contains_key("externalId") 166 + || game_obj.contains_key("uri") 167 + { 168 + continue; 169 + } 170 + 171 + let platform = game_obj 172 + .get("platform") 173 + .and_then(|v| v.as_str()) 174 + .unwrap_or(""); 175 + let external_id = game_obj 176 + .get("externalId") 177 + .and_then(|v| v.as_str()) 178 + .unwrap_or(""); 179 + 180 + if let Some((uri, cid)) = 181 + lookup_game_by_external_id(db, backend, platform, external_id).await 182 + { 183 + obj.insert( 184 + "game".to_string(), 185 + serde_json::json!({ 186 + "uri": uri, 187 + "cid": cid 188 + }), 189 + ); 190 + tracing::debug!( 191 + platform = %platform, 192 + external_id = %external_id, 193 + uri = %uri, 194 + "Resolved game reference" 195 + ); 196 + } else { 197 + tracing::debug!( 198 + platform = %platform, 199 + external_id = %external_id, 200 + "Game not found in database, leaving reference unresolved" 201 + ); 202 + } 203 + } 204 + } 205 + 206 + /// Look up a game by external ID (e.g., Steam app ID). 207 + /// 208 + /// Returns (uri, cid) if found. 209 + async fn lookup_game_by_external_id( 210 + db: &sqlx::AnyPool, 211 + backend: DatabaseBackend, 212 + platform: &str, 213 + external_id: &str, 214 + ) -> Option<(String, String)> { 215 + // Build JSON path based on platform 216 + // Looking for records where: record.externalIds.<platform> = external_id 217 + let json_path = match backend { 218 + DatabaseBackend::Sqlite => { 219 + format!("json_extract(record, '$.externalIds.{}')", platform) 220 + } 221 + DatabaseBackend::Postgres => { 222 + format!("record->'externalIds'->>'{}'", platform) 223 + } 224 + }; 225 + 226 + let sql = adapt_sql( 227 + &format!( 228 + "SELECT uri, cid FROM records WHERE collection = 'games.gamesgamesgamesgames.game' AND {} = ? LIMIT 1", 229 + json_path 230 + ), 231 + backend, 232 + ); 233 + 234 + let result: Option<(String, String)> = sqlx::query_as(&sql) 235 + .bind(external_id) 236 + .fetch_optional(db) 237 + .await 238 + .ok() 239 + .flatten(); 240 + 241 + result 242 + } 243 + 244 + #[cfg(test)] 245 + mod tests { 246 + use super::*; 247 + 248 + #[test] 249 + fn test_process_unsigned_record() { 250 + let processor = SyncProcessor::new(None, "did:plc:testuser".to_string()); 251 + 252 + let records = vec![SyncRecord { 253 + collection: "test.collection".into(), 254 + record: serde_json::json!({ 255 + "$type": "test.collection", 256 + "data": "hello" 257 + }), 258 + dedup_key: Some("test:1".into()), 259 + sign: false, 260 + }]; 261 + 262 + let processed = processor.process_records(records).unwrap(); 263 + assert_eq!(processed.len(), 1); 264 + assert_eq!(processed[0].collection, "test.collection"); 265 + assert!(processed[0].content_cid.is_none()); 266 + } 267 + 268 + #[test] 269 + fn test_process_signed_record() { 270 + let signer = 271 + AttestationSigner::for_testing("did:web:test#key".into(), "test.signature".into()); 272 + let processor = SyncProcessor::new(Some(&signer), "did:plc:testuser".to_string()); 273 + 274 + let records = vec![SyncRecord { 275 + collection: "games.gamesgamesgamesgames.actor.game".into(), 276 + record: serde_json::json!({ 277 + "$type": "games.gamesgamesgamesgames.actor.game", 278 + "game": {"platform": "steam", "externalId": "440"}, 279 + "platform": "steam", 280 + "createdAt": "2024-01-01T00:00:00Z" 281 + }), 282 + dedup_key: Some("steam:game:440".into()), 283 + sign: true, 284 + }]; 285 + 286 + let processed = processor.process_records(records).unwrap(); 287 + assert_eq!(processed.len(), 1); 288 + assert!(processed[0].content_cid.is_some()); 289 + 290 + // Verify signatures array was added 291 + let signatures = processed[0].record["signatures"].as_array(); 292 + assert!(signatures.is_some()); 293 + assert_eq!(signatures.unwrap().len(), 1); 294 + } 295 + 296 + #[test] 297 + fn test_sign_requested_but_no_signer() { 298 + let processor = SyncProcessor::new(None, "did:plc:testuser".to_string()); 299 + 300 + let records = vec![SyncRecord { 301 + collection: "test.collection".into(), 302 + record: serde_json::json!({"data": "hello"}), 303 + dedup_key: None, 304 + sign: true, // Requested but no signer 305 + }]; 306 + 307 + let processed = processor.process_records(records).unwrap(); 308 + assert_eq!(processed.len(), 1); 309 + // No error, but no CID either 310 + assert!(processed[0].content_cid.is_none()); 311 + } 312 + }
+3
src/plugin/types.rs
··· 74 74 pub record: serde_json::Value, 75 75 #[serde(skip_serializing_if = "Option::is_none")] 76 76 pub dedup_key: Option<String>, 77 + /// Whether HappyView should add an attestation signature to this record 78 + #[serde(default)] 79 + pub sign: bool, 77 80 } 78 81 79 82 /// Strong reference to an AT Protocol record
+4
tests/common/app.rs
··· 131 131 b"test-secret-that-is-at-least-32-bytes-long", 132 132 ), 133 133 plugin_registry: std::sync::Arc::new(happyview::plugin::PluginRegistry::new()), 134 + wasm_runtime: std::sync::Arc::new( 135 + happyview::plugin::WasmRuntime::new().expect("wasm runtime"), 136 + ), 137 + attestation_signer: None, 134 138 }; 135 139 136 140 let router = server::router(state.clone());
+1
tests/fixtures/test_plugin/.gitignore
··· 1 + /target/
+7
tests/fixtures/test_plugin/Cargo.lock
··· 1 + # This file is automatically @generated by Cargo. 2 + # It is not intended for manual editing. 3 + version = 4 4 + 5 + [[package]] 6 + name = "test-plugin" 7 + version = "0.1.0"
+11
tests/fixtures/test_plugin/Cargo.toml
··· 1 + [package] 2 + name = "test-plugin" 3 + version = "0.1.0" 4 + edition = "2021" 5 + 6 + [lib] 7 + crate-type = ["cdylib"] 8 + 9 + [profile.release] 10 + opt-level = "s" 11 + lto = true
+105
tests/fixtures/test_plugin/src/lib.rs
··· 1 + // Only compile for WASM targets 2 + #![cfg_attr(target_arch = "wasm32", no_std)] 3 + #![allow(static_mut_refs)] 4 + 5 + #[cfg(target_arch = "wasm32")] 6 + extern crate alloc; 7 + 8 + #[cfg(target_arch = "wasm32")] 9 + use core::alloc::{GlobalAlloc, Layout}; 10 + 11 + // Simple bump allocator for WASM 12 + #[cfg(target_arch = "wasm32")] 13 + struct BumpAllocator; 14 + 15 + #[cfg(target_arch = "wasm32")] 16 + const HEAP_SIZE: usize = 65536; 17 + #[cfg(target_arch = "wasm32")] 18 + static mut HEAP: [u8; HEAP_SIZE] = [0; HEAP_SIZE]; 19 + #[cfg(target_arch = "wasm32")] 20 + static mut HEAP_POS: usize = 0; 21 + 22 + #[cfg(target_arch = "wasm32")] 23 + unsafe impl GlobalAlloc for BumpAllocator { 24 + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { 25 + let size = layout.size(); 26 + let align = layout.align(); 27 + 28 + // Align up 29 + let pos = (HEAP_POS + align - 1) & !(align - 1); 30 + if pos + size > HEAP_SIZE { 31 + return core::ptr::null_mut(); 32 + } 33 + 34 + HEAP_POS = pos + size; 35 + HEAP.as_mut_ptr().add(pos) 36 + } 37 + 38 + unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) { 39 + // No-op for bump allocator 40 + } 41 + } 42 + 43 + #[cfg(target_arch = "wasm32")] 44 + #[global_allocator] 45 + static ALLOCATOR: BumpAllocator = BumpAllocator; 46 + 47 + #[cfg(target_arch = "wasm32")] 48 + #[panic_handler] 49 + fn panic(_info: &core::panic::PanicInfo) -> ! { 50 + loop {} 51 + } 52 + 53 + // Memory exports 54 + #[no_mangle] 55 + pub extern "C" fn alloc(size: u32) -> u32 { 56 + let layout = Layout::from_size_align(size as usize, 1).unwrap(); 57 + unsafe { ALLOCATOR.alloc(layout) as u32 } 58 + } 59 + 60 + #[no_mangle] 61 + pub extern "C" fn dealloc(_ptr: u32, _size: u32) { 62 + // No-op for bump allocator 63 + } 64 + 65 + // Helper to return a string as packed i64: (ptr << 32) | len 66 + fn return_json(s: &str) -> i64 { 67 + let ptr = alloc(s.len() as u32); 68 + if ptr == 0 { 69 + return 0; 70 + } 71 + unsafe { 72 + core::ptr::copy_nonoverlapping(s.as_ptr(), ptr as *mut u8, s.len()); 73 + } 74 + ((ptr as i64) << 32) | (s.len() as i64) 75 + } 76 + 77 + #[no_mangle] 78 + pub extern "C" fn plugin_info() -> i64 { 79 + return_json(r#"{"ok":{"id":"test","name":"Test Plugin","version":"1.0.0","api_version":"1","required_secrets":[],"icon_url":null,"config_schema":null}}"#) 80 + } 81 + 82 + #[no_mangle] 83 + pub extern "C" fn get_authorize_url(_ptr: u32, _len: u32) -> i64 { 84 + return_json(r#"{"ok":"https://example.com/oauth?state=test"}"#) 85 + } 86 + 87 + #[no_mangle] 88 + pub extern "C" fn handle_callback(_ptr: u32, _len: u32) -> i64 { 89 + return_json(r#"{"ok":{"access_token":"test-token","token_type":"Bearer","expires_at":null,"refresh_token":null}}"#) 90 + } 91 + 92 + #[no_mangle] 93 + pub extern "C" fn refresh_tokens(_ptr: u32, _len: u32) -> i64 { 94 + return_json(r#"{"ok":{"access_token":"refreshed-token","token_type":"Bearer","expires_at":null,"refresh_token":null}}"#) 95 + } 96 + 97 + #[no_mangle] 98 + pub extern "C" fn get_profile(_ptr: u32, _len: u32) -> i64 { 99 + return_json(r#"{"ok":{"account_id":"12345","display_name":"Test User","profile_url":null,"avatar_url":null}}"#) 100 + } 101 + 102 + #[no_mangle] 103 + pub extern "C" fn sync_account(_ptr: u32, _len: u32) -> i64 { 104 + return_json(r#"{"ok":[]}"#) 105 + }
+4
tests/lua_atproto_api.rs
··· 85 85 oauth: std::sync::Arc::new(oauth), 86 86 cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), 87 87 plugin_registry: std::sync::Arc::new(happyview::plugin::PluginRegistry::new()), 88 + wasm_runtime: std::sync::Arc::new( 89 + happyview::plugin::WasmRuntime::new().expect("wasm runtime"), 90 + ), 91 + attestation_signer: None, 88 92 } 89 93 } 90 94
+4
tests/lua_db_api.rs
··· 88 88 oauth: std::sync::Arc::new(oauth), 89 89 cookie_key: axum_extra::extract::cookie::Key::derive_from(b"test-secret"), 90 90 plugin_registry: std::sync::Arc::new(happyview::plugin::PluginRegistry::new()), 91 + wasm_runtime: std::sync::Arc::new( 92 + happyview::plugin::WasmRuntime::new().expect("wasm runtime"), 93 + ), 94 + attestation_signer: None, 91 95 } 92 96 } 93 97
+224
tests/plugin_executor.rs
··· 1 + // tests/plugin_executor.rs 2 + 3 + use happyview::db::DatabaseBackend; 4 + use happyview::lexicon::LexiconRegistry; 5 + use happyview::plugin::{ 6 + ExecutionError, LoadedPlugin, PluginExecutor, PluginInfo, PluginRegistry, PluginSource, 7 + WasmRuntime, 8 + }; 9 + use std::collections::HashMap; 10 + use std::sync::Arc; 11 + 12 + type Secrets = HashMap<String, String>; 13 + 14 + async fn create_test_executor() -> (PluginExecutor, Arc<PluginRegistry>) { 15 + // Create in-memory database 16 + sqlx::any::install_default_drivers(); 17 + let db = sqlx::AnyPool::connect("sqlite::memory:") 18 + .await 19 + .expect("Failed to create test database"); 20 + 21 + let runtime = Arc::new(WasmRuntime::new().expect("Failed to create runtime")); 22 + let registry = Arc::new(PluginRegistry::new()); 23 + let lexicons = Arc::new(LexiconRegistry::new()); 24 + let http_client = reqwest::Client::new(); 25 + 26 + let executor = PluginExecutor::new( 27 + runtime, 28 + registry.clone(), 29 + db, 30 + DatabaseBackend::Sqlite, 31 + http_client, 32 + lexicons, 33 + ); 34 + 35 + (executor, registry) 36 + } 37 + 38 + fn load_test_plugin() -> LoadedPlugin { 39 + let wasm_bytes = std::fs::read( 40 + "tests/fixtures/test_plugin/target/wasm32-unknown-unknown/release/test_plugin.wasm", 41 + ) 42 + .expect( 43 + "Test plugin not built. Run: cd tests/fixtures/test_plugin && cargo build --target wasm32-unknown-unknown --release", 44 + ); 45 + 46 + LoadedPlugin { 47 + info: PluginInfo { 48 + id: "test".into(), 49 + name: "Test Plugin".into(), 50 + version: "1.0.0".into(), 51 + api_version: "1".into(), 52 + icon_url: None, 53 + required_secrets: vec![], 54 + config_schema: None, 55 + }, 56 + source: PluginSource::File { 57 + path: "tests/fixtures/test_plugin".into(), 58 + }, 59 + wasm_bytes, 60 + } 61 + } 62 + 63 + #[tokio::test] 64 + async fn test_plugin_info() { 65 + let (executor, registry) = create_test_executor().await; 66 + let plugin = load_test_plugin(); 67 + registry.register(plugin).await; 68 + 69 + let mut instance = executor 70 + .instantiate( 71 + "test", 72 + "user:did:plc:test", 73 + Secrets::new(), 74 + serde_json::Value::Null, 75 + ) 76 + .await 77 + .expect("Failed to instantiate"); 78 + 79 + let info = instance 80 + .call_plugin_info() 81 + .await 82 + .expect("Failed to get info"); 83 + 84 + assert_eq!(info.id, "test"); 85 + assert_eq!(info.name, "Test Plugin"); 86 + assert_eq!(info.version, "1.0.0"); 87 + } 88 + 89 + #[tokio::test] 90 + async fn test_get_authorize_url() { 91 + let (executor, registry) = create_test_executor().await; 92 + let plugin = load_test_plugin(); 93 + registry.register(plugin).await; 94 + 95 + let mut instance = executor 96 + .instantiate("test", "state:123", Secrets::new(), serde_json::Value::Null) 97 + .await 98 + .expect("Failed to instantiate"); 99 + 100 + let url = instance 101 + .call_get_authorize_url( 102 + "state123", 103 + "https://app.example/callback", 104 + &serde_json::Value::Null, 105 + ) 106 + .await 107 + .expect("Failed to get URL"); 108 + 109 + assert!(url.starts_with("https://")); 110 + } 111 + 112 + #[tokio::test] 113 + async fn test_handle_callback() { 114 + let (executor, registry) = create_test_executor().await; 115 + let plugin = load_test_plugin(); 116 + registry.register(plugin).await; 117 + 118 + let mut instance = executor 119 + .instantiate( 120 + "test", 121 + "user:did:plc:test", 122 + Secrets::new(), 123 + serde_json::Value::Null, 124 + ) 125 + .await 126 + .expect("Failed to instantiate"); 127 + 128 + let tokens = instance 129 + .call_handle_callback("code123", "state123", &serde_json::Value::Null) 130 + .await 131 + .expect("Failed to handle callback"); 132 + 133 + assert_eq!(tokens.access_token, "test-token"); 134 + assert_eq!(tokens.token_type, "Bearer"); 135 + } 136 + 137 + #[tokio::test] 138 + async fn test_refresh_tokens() { 139 + let (executor, registry) = create_test_executor().await; 140 + let plugin = load_test_plugin(); 141 + registry.register(plugin).await; 142 + 143 + let mut instance = executor 144 + .instantiate( 145 + "test", 146 + "user:did:plc:test", 147 + Secrets::new(), 148 + serde_json::Value::Null, 149 + ) 150 + .await 151 + .expect("Failed to instantiate"); 152 + 153 + let tokens = instance 154 + .call_refresh_tokens("old-refresh-token", &serde_json::Value::Null) 155 + .await 156 + .expect("Failed to refresh tokens"); 157 + 158 + assert_eq!(tokens.access_token, "refreshed-token"); 159 + } 160 + 161 + #[tokio::test] 162 + async fn test_get_profile() { 163 + let (executor, registry) = create_test_executor().await; 164 + let plugin = load_test_plugin(); 165 + registry.register(plugin).await; 166 + 167 + let mut instance = executor 168 + .instantiate( 169 + "test", 170 + "user:did:plc:test", 171 + Secrets::new(), 172 + serde_json::Value::Null, 173 + ) 174 + .await 175 + .expect("Failed to instantiate"); 176 + 177 + let profile = instance 178 + .call_get_profile("test-token", &serde_json::Value::Null) 179 + .await 180 + .expect("Failed to get profile"); 181 + 182 + assert_eq!(profile.account_id, "12345"); 183 + assert_eq!(profile.display_name, Some("Test User".into())); 184 + } 185 + 186 + #[tokio::test] 187 + async fn test_sync_account() { 188 + let (executor, registry) = create_test_executor().await; 189 + let plugin = load_test_plugin(); 190 + registry.register(plugin).await; 191 + 192 + let mut instance = executor 193 + .instantiate( 194 + "test", 195 + "user:did:plc:test", 196 + Secrets::new(), 197 + serde_json::Value::Null, 198 + ) 199 + .await 200 + .expect("Failed to instantiate"); 201 + 202 + let records = instance 203 + .call_sync_account("test-token", &serde_json::Value::Null) 204 + .await 205 + .expect("Failed to sync account"); 206 + 207 + assert!(records.is_empty()); // Test plugin returns empty array 208 + } 209 + 210 + #[tokio::test] 211 + async fn test_plugin_not_found() { 212 + let (executor, _registry) = create_test_executor().await; 213 + 214 + let result = executor 215 + .instantiate( 216 + "nonexistent", 217 + "scope", 218 + Secrets::new(), 219 + serde_json::Value::Null, 220 + ) 221 + .await; 222 + 223 + assert!(matches!(result, Err(ExecutionError::PluginNotFound(_)))); 224 + }