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: permissioned spaces implementation

Trezy 190578f0 441b34ea

+3948 -17
+19
migrations/postgres/20260429000000_create_spaces.sql
··· 1 + CREATE TABLE IF NOT EXISTS spaces ( 2 + id TEXT PRIMARY KEY, 3 + owner_did TEXT NOT NULL, 4 + type_nsid TEXT NOT NULL, 5 + skey TEXT NOT NULL, 6 + display_name TEXT, 7 + description TEXT, 8 + access_mode TEXT NOT NULL DEFAULT 'default_allow', 9 + app_allowlist TEXT, 10 + app_denylist TEXT, 11 + managing_app_did TEXT, 12 + config TEXT NOT NULL DEFAULT '{}', 13 + created_at TEXT NOT NULL, 14 + updated_at TEXT NOT NULL, 15 + UNIQUE(owner_did, type_nsid, skey) 16 + ); 17 + 18 + CREATE INDEX idx_spaces_owner_did ON spaces(owner_did); 19 + CREATE INDEX idx_spaces_type_nsid ON spaces(type_nsid);
+13
migrations/postgres/20260429000001_create_space_members.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_members ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + member_did TEXT NOT NULL, 5 + access TEXT NOT NULL DEFAULT 'read', 6 + is_delegation INTEGER NOT NULL DEFAULT 0, 7 + granted_by TEXT, 8 + created_at TEXT NOT NULL, 9 + UNIQUE(space_id, member_did) 10 + ); 11 + 12 + CREATE INDEX idx_space_members_did ON space_members(member_did); 13 + CREATE INDEX idx_space_members_space_id ON space_members(space_id);
+14
migrations/postgres/20260429000002_create_space_records.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_records ( 2 + uri TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + author_did TEXT NOT NULL, 5 + collection TEXT NOT NULL, 6 + rkey TEXT NOT NULL, 7 + record TEXT NOT NULL, 8 + cid TEXT NOT NULL, 9 + indexed_at TEXT NOT NULL 10 + ); 11 + 12 + CREATE INDEX idx_space_records_space_id ON space_records(space_id); 13 + CREATE INDEX idx_space_records_author ON space_records(author_did); 14 + CREATE INDEX idx_space_records_collection ON space_records(space_id, collection);
+10
migrations/postgres/20260429000003_create_space_credentials.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_credentials ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + issued_to TEXT NOT NULL, 5 + token_hash TEXT NOT NULL, 6 + expires_at TEXT NOT NULL, 7 + created_at TEXT NOT NULL 8 + ); 9 + 10 + CREATE INDEX idx_space_credentials_space_id ON space_credentials(space_id);
+14
migrations/postgres/20260429000004_create_space_invites.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_invites ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + token_hash TEXT NOT NULL UNIQUE, 5 + created_by TEXT NOT NULL, 6 + access TEXT NOT NULL DEFAULT 'read', 7 + max_uses INTEGER, 8 + uses INTEGER NOT NULL DEFAULT 0, 9 + expires_at TEXT, 10 + revoked INTEGER NOT NULL DEFAULT 0, 11 + created_at TEXT NOT NULL 12 + ); 13 + 14 + CREATE INDEX idx_space_invites_space_id ON space_invites(space_id);
+12
migrations/postgres/20260429000005_create_space_dids.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_dids ( 2 + id TEXT PRIMARY KEY, 3 + did TEXT NOT NULL UNIQUE, 4 + space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL, 5 + signing_key_enc BYTEA NOT NULL, 6 + rotation_key_enc BYTEA NOT NULL, 7 + created_by TEXT NOT NULL, 8 + created_at TEXT NOT NULL 9 + ); 10 + 11 + CREATE INDEX idx_space_dids_did ON space_dids(did); 12 + CREATE INDEX idx_space_dids_space_id ON space_dids(space_id);
+12
migrations/postgres/20260429000006_create_space_sync_state.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_sync_state ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + member_did TEXT NOT NULL, 5 + cursor TEXT, 6 + last_synced_at TEXT, 7 + status TEXT NOT NULL DEFAULT 'pending', 8 + error TEXT, 9 + UNIQUE(space_id, member_did) 10 + ); 11 + 12 + CREATE INDEX idx_space_sync_state_space_id ON space_sync_state(space_id);
+19
migrations/sqlite/20260429000000_create_spaces.sql
··· 1 + CREATE TABLE IF NOT EXISTS spaces ( 2 + id TEXT PRIMARY KEY, 3 + owner_did TEXT NOT NULL, 4 + type_nsid TEXT NOT NULL, 5 + skey TEXT NOT NULL, 6 + display_name TEXT, 7 + description TEXT, 8 + access_mode TEXT NOT NULL DEFAULT 'default_allow', 9 + app_allowlist TEXT, 10 + app_denylist TEXT, 11 + managing_app_did TEXT, 12 + config TEXT NOT NULL DEFAULT '{}', 13 + created_at TEXT NOT NULL, 14 + updated_at TEXT NOT NULL, 15 + UNIQUE(owner_did, type_nsid, skey) 16 + ); 17 + 18 + CREATE INDEX idx_spaces_owner_did ON spaces(owner_did); 19 + CREATE INDEX idx_spaces_type_nsid ON spaces(type_nsid);
+13
migrations/sqlite/20260429000001_create_space_members.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_members ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + member_did TEXT NOT NULL, 5 + access TEXT NOT NULL DEFAULT 'read', 6 + is_delegation INTEGER NOT NULL DEFAULT 0, 7 + granted_by TEXT, 8 + created_at TEXT NOT NULL, 9 + UNIQUE(space_id, member_did) 10 + ); 11 + 12 + CREATE INDEX idx_space_members_did ON space_members(member_did); 13 + CREATE INDEX idx_space_members_space_id ON space_members(space_id);
+14
migrations/sqlite/20260429000002_create_space_records.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_records ( 2 + uri TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + author_did TEXT NOT NULL, 5 + collection TEXT NOT NULL, 6 + rkey TEXT NOT NULL, 7 + record TEXT NOT NULL, 8 + cid TEXT NOT NULL, 9 + indexed_at TEXT NOT NULL 10 + ); 11 + 12 + CREATE INDEX idx_space_records_space_id ON space_records(space_id); 13 + CREATE INDEX idx_space_records_author ON space_records(author_did); 14 + CREATE INDEX idx_space_records_collection ON space_records(space_id, collection);
+10
migrations/sqlite/20260429000003_create_space_credentials.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_credentials ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + issued_to TEXT NOT NULL, 5 + token_hash TEXT NOT NULL, 6 + expires_at TEXT NOT NULL, 7 + created_at TEXT NOT NULL 8 + ); 9 + 10 + CREATE INDEX idx_space_credentials_space_id ON space_credentials(space_id);
+14
migrations/sqlite/20260429000004_create_space_invites.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_invites ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + token_hash TEXT NOT NULL UNIQUE, 5 + created_by TEXT NOT NULL, 6 + access TEXT NOT NULL DEFAULT 'read', 7 + max_uses INTEGER, 8 + uses INTEGER NOT NULL DEFAULT 0, 9 + expires_at TEXT, 10 + revoked INTEGER NOT NULL DEFAULT 0, 11 + created_at TEXT NOT NULL 12 + ); 13 + 14 + CREATE INDEX idx_space_invites_space_id ON space_invites(space_id);
+12
migrations/sqlite/20260429000005_create_space_dids.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_dids ( 2 + id TEXT PRIMARY KEY, 3 + did TEXT NOT NULL UNIQUE, 4 + space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL, 5 + signing_key_enc BLOB NOT NULL, 6 + rotation_key_enc BLOB NOT NULL, 7 + created_by TEXT NOT NULL, 8 + created_at TEXT NOT NULL 9 + ); 10 + 11 + CREATE INDEX idx_space_dids_did ON space_dids(did); 12 + CREATE INDEX idx_space_dids_space_id ON space_dids(space_id);
+12
migrations/sqlite/20260429000006_create_space_sync_state.sql
··· 1 + CREATE TABLE IF NOT EXISTS space_sync_state ( 2 + id TEXT PRIMARY KEY, 3 + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, 4 + member_did TEXT NOT NULL, 5 + cursor TEXT, 6 + last_synced_at TEXT, 7 + status TEXT NOT NULL DEFAULT 'pending', 8 + error TEXT, 9 + UNIQUE(space_id, member_did) 10 + ); 11 + 12 + CREATE INDEX idx_space_sync_state_space_id ON space_sync_state(space_id);
+42 -1
src/admin/permissions.rs
··· 2 2 3 3 use serde::{Deserialize, Serialize}; 4 4 5 - /// All 29 permissions in the system. 5 + /// All 37 permissions in the system. 6 6 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 7 7 pub enum Permission { 8 8 #[serde(rename = "lexicons:create")] ··· 83 83 DeadLettersRead, 84 84 #[serde(rename = "dead-letters:manage")] 85 85 DeadLettersManage, 86 + 87 + #[serde(rename = "spaces:create")] 88 + SpacesCreate, 89 + #[serde(rename = "spaces:read")] 90 + SpacesRead, 91 + #[serde(rename = "spaces:update")] 92 + SpacesUpdate, 93 + #[serde(rename = "spaces:delete")] 94 + SpacesDelete, 95 + #[serde(rename = "spaces:manage-members")] 96 + SpacesManageMembers, 97 + #[serde(rename = "spaces:manage-invites")] 98 + SpacesManageInvites, 99 + #[serde(rename = "spaces:manage-records")] 100 + SpacesManageRecords, 101 + #[serde(rename = "spaces:manage-credentials")] 102 + SpacesManageCredentials, 86 103 } 87 104 88 105 impl Permission { ··· 122 139 Self::ApiClientsDelete => "api-clients:delete", 123 140 Self::DeadLettersRead => "dead-letters:read", 124 141 Self::DeadLettersManage => "dead-letters:manage", 142 + Self::SpacesCreate => "spaces:create", 143 + Self::SpacesRead => "spaces:read", 144 + Self::SpacesUpdate => "spaces:update", 145 + Self::SpacesDelete => "spaces:delete", 146 + Self::SpacesManageMembers => "spaces:manage-members", 147 + Self::SpacesManageInvites => "spaces:manage-invites", 148 + Self::SpacesManageRecords => "spaces:manage-records", 149 + Self::SpacesManageCredentials => "spaces:manage-credentials", 125 150 } 126 151 } 127 152 ··· 161 186 Self::ApiClientsDelete, 162 187 Self::DeadLettersRead, 163 188 Self::DeadLettersManage, 189 + Self::SpacesCreate, 190 + Self::SpacesRead, 191 + Self::SpacesUpdate, 192 + Self::SpacesDelete, 193 + Self::SpacesManageMembers, 194 + Self::SpacesManageInvites, 195 + Self::SpacesManageRecords, 196 + Self::SpacesManageCredentials, 164 197 ]) 165 198 } 166 199 } ··· 215 248 perms.insert(Permission::ApiClientsCreate); 216 249 perms.insert(Permission::ApiClientsEdit); 217 250 perms.insert(Permission::ApiClientsDelete); 251 + perms.insert(Permission::SpacesCreate); 252 + perms.insert(Permission::SpacesRead); 253 + perms.insert(Permission::SpacesUpdate); 254 + perms.insert(Permission::SpacesDelete); 255 + perms.insert(Permission::SpacesManageMembers); 256 + perms.insert(Permission::SpacesManageInvites); 257 + perms.insert(Permission::SpacesManageRecords); 258 + perms.insert(Permission::SpacesManageCredentials); 218 259 perms 219 260 } 220 261 Self::FullAccess => Permission::all(),
+47
src/lexicon.rs
··· 85 85 pub index_hook: Option<String>, 86 86 /// Optional per-NSID token cost for rate limiting. 87 87 pub token_cost: Option<u32>, 88 + /// Optional space type NSID indicating this lexicon is designed for use within spaces of that type. 89 + pub space_type: Option<String>, 88 90 } 89 91 90 92 impl ParsedLexicon { ··· 127 129 let output = main_def.and_then(|m| m.get("output")).cloned(); 128 130 let record_schema = main_def.and_then(|m| m.get("record")).cloned(); 129 131 132 + let space_type = raw 133 + .get("spaceType") 134 + .and_then(|v| v.as_str()) 135 + .map(|s| s.to_string()); 136 + 130 137 Ok(Self { 131 138 id, 132 139 lexicon_type, ··· 142 149 script, 143 150 index_hook, 144 151 token_cost, 152 + space_type, 145 153 }) 146 154 } 147 155 } ··· 792 800 let reg = LexiconRegistry::new(); 793 801 let script = reg.get_index_hook("nonexistent").await; 794 802 assert!(script.is_none()); 803 + } 804 + 805 + #[test] 806 + fn parse_space_type_from_lexicon() { 807 + let raw = json!({ 808 + "lexicon": 1, 809 + "id": "com.example.forum.post", 810 + "spaceType": "com.example.forum", 811 + "defs": { 812 + "main": { 813 + "type": "record", 814 + "key": "tid", 815 + "record": { 816 + "type": "object", 817 + "properties": { 818 + "text": { "type": "string" } 819 + } 820 + } 821 + } 822 + } 823 + }); 824 + let parsed = 825 + ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None, None, None).unwrap(); 826 + assert_eq!(parsed.space_type.as_deref(), Some("com.example.forum")); 827 + } 828 + 829 + #[test] 830 + fn parse_space_type_none_by_default() { 831 + let parsed = ParsedLexicon::parse( 832 + record_lexicon_json(), 833 + 1, 834 + None, 835 + ProcedureAction::Upsert, 836 + None, 837 + None, 838 + None, 839 + ) 840 + .unwrap(); 841 + assert!(parsed.space_type.is_none()); 795 842 } 796 843 }
+1
src/lib.rs
··· 23 23 pub mod repo; 24 24 pub mod resolve; 25 25 pub mod server; 26 + pub mod spaces; 26 27 pub mod xrpc; 27 28 28 29 use auth::oauth_store::{DbSessionStore, DbStateStore};
+192
src/lua/atproto_api.rs
··· 257 257 atproto_table.set("verify_signature", verify_fn)?; 258 258 } 259 259 260 + // atproto.spaces sub-table 261 + let spaces_table = lua.create_table()?; 262 + 263 + // atproto.spaces.is_member(space_uri, did) -> boolean 264 + let state_clone = state.clone(); 265 + let is_member_fn = 266 + lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { 267 + let state = state_clone.clone(); 268 + async move { 269 + let uri = crate::spaces::SpaceUri::parse(&space_uri) 270 + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 271 + let space = crate::spaces::db::get_space_by_address( 272 + &state.db, 273 + state.db_backend, 274 + &uri.owner_did, 275 + &uri.type_nsid, 276 + &uri.skey, 277 + ) 278 + .await 279 + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; 280 + let space = match space { 281 + Some(s) => s, 282 + None => return Ok(false), 283 + }; 284 + let access = 285 + crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did) 286 + .await 287 + .map_err(|e| { 288 + mlua::Error::runtime(format!("membership check failed: {e}")) 289 + })?; 290 + Ok(access.is_some()) 291 + } 292 + })?; 293 + spaces_table.set("is_member", is_member_fn)?; 294 + 295 + // atproto.spaces.get_access(space_uri, did) -> 'read' | 'write' | nil 296 + let state_clone = state.clone(); 297 + let get_access_fn = 298 + lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { 299 + let state = state_clone.clone(); 300 + async move { 301 + let uri = crate::spaces::SpaceUri::parse(&space_uri) 302 + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 303 + let space = crate::spaces::db::get_space_by_address( 304 + &state.db, 305 + state.db_backend, 306 + &uri.owner_did, 307 + &uri.type_nsid, 308 + &uri.skey, 309 + ) 310 + .await 311 + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; 312 + let space = match space { 313 + Some(s) => s, 314 + None => return Ok(None), 315 + }; 316 + let access = 317 + crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did) 318 + .await 319 + .map_err(|e| { 320 + mlua::Error::runtime(format!("membership check failed: {e}")) 321 + })?; 322 + Ok(access.map(|a| a.as_str().to_string())) 323 + } 324 + })?; 325 + spaces_table.set("get_access", get_access_fn)?; 326 + 327 + // atproto.spaces.list_members(space_uri) -> array of { did, access } 328 + let state_clone = state.clone(); 329 + let list_members_fn = lua.create_async_function(move |lua, space_uri: String| { 330 + let state = state_clone.clone(); 331 + async move { 332 + let uri = crate::spaces::SpaceUri::parse(&space_uri) 333 + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 334 + let space = crate::spaces::db::get_space_by_address( 335 + &state.db, 336 + state.db_backend, 337 + &uri.owner_did, 338 + &uri.type_nsid, 339 + &uri.skey, 340 + ) 341 + .await 342 + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; 343 + let space = match space { 344 + Some(s) => s, 345 + None => { 346 + return Err(mlua::Error::runtime("space not found")); 347 + } 348 + }; 349 + let members = 350 + crate::spaces::members::resolve_members(&state.db, state.db_backend, &space.id) 351 + .await 352 + .map_err(|e| mlua::Error::runtime(format!("member resolution failed: {e}")))?; 353 + 354 + let result = lua.create_table()?; 355 + for (i, member) in members.iter().enumerate() { 356 + let entry = lua.create_table()?; 357 + entry.set("did", member.did.as_str())?; 358 + entry.set("access", member.access.as_str())?; 359 + result.set(i + 1, entry)?; 360 + } 361 + Ok(mlua::Value::Table(result)) 362 + } 363 + })?; 364 + spaces_table.set("list_members", list_members_fn)?; 365 + 366 + // atproto.spaces.query({ space_uri, collection, limit, cursor }) -> { records, cursor } 367 + let state_clone = state.clone(); 368 + let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { 369 + let state = state_clone.clone(); 370 + async move { 371 + let space_uri: String = opts 372 + .get("space_uri") 373 + .map_err(|_| mlua::Error::runtime("space_uri is required"))?; 374 + let collection: Option<String> = opts.get("collection").ok(); 375 + let limit: i64 = opts.get("limit").unwrap_or(50); 376 + let cursor: Option<String> = opts.get("cursor").ok(); 377 + 378 + let uri = crate::spaces::SpaceUri::parse(&space_uri) 379 + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; 380 + let space = crate::spaces::db::get_space_by_address( 381 + &state.db, 382 + state.db_backend, 383 + &uri.owner_did, 384 + &uri.type_nsid, 385 + &uri.skey, 386 + ) 387 + .await 388 + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; 389 + let space = match space { 390 + Some(s) => s, 391 + None => { 392 + return Err(mlua::Error::runtime("space not found")); 393 + } 394 + }; 395 + 396 + let records = crate::spaces::db::list_space_records( 397 + &state.db, 398 + state.db_backend, 399 + &space.id, 400 + collection.as_deref(), 401 + limit.min(100), 402 + cursor.as_deref(), 403 + ) 404 + .await 405 + .map_err(|e| mlua::Error::runtime(format!("record query failed: {e}")))?; 406 + 407 + let next_cursor = records.last().map(|r| r.indexed_at.clone()); 408 + 409 + let result = lua.create_table()?; 410 + let records_table = lua.create_table()?; 411 + for (i, record) in records.iter().enumerate() { 412 + let entry = lua.to_value(&serde_json::json!({ 413 + "uri": record.uri, 414 + "collection": record.collection, 415 + "rkey": record.rkey, 416 + "record": record.record, 417 + "cid": record.cid, 418 + "authorDid": record.author_did, 419 + }))?; 420 + records_table.set(i + 1, entry)?; 421 + } 422 + result.set("records", records_table)?; 423 + match next_cursor { 424 + Some(c) => result.set("cursor", c)?, 425 + None => result.set("cursor", mlua::Value::Nil)?, 426 + } 427 + 428 + Ok(mlua::Value::Table(result)) 429 + } 430 + })?; 431 + spaces_table.set("query", query_fn)?; 432 + 433 + atproto_table.set("spaces", spaces_table)?; 434 + 260 435 lua.globals().set("atproto", atproto_table)?; 261 436 Ok(()) 262 437 } ··· 516 691 "#; 517 692 let result: bool = lua.load(chunk).eval_async().await.unwrap(); 518 693 assert!(!result); 694 + } 695 + 696 + #[tokio::test] 697 + async fn spaces_api_is_registered() { 698 + let state = test_state_with_plc(""); 699 + let lua = mlua::Lua::new(); 700 + register_atproto_api(&lua, Arc::new(state), None).unwrap(); 701 + 702 + let chunk = r#" 703 + return type(atproto.spaces) == "table" 704 + and type(atproto.spaces.is_member) == "function" 705 + and type(atproto.spaces.get_access) == "function" 706 + and type(atproto.spaces.list_members) == "function" 707 + and type(atproto.spaces.query) == "function" 708 + "#; 709 + let result: bool = lua.load(chunk).eval_async().await.unwrap(); 710 + assert!(result); 519 711 } 520 712 }
+87
src/lua/context.rs
··· 2 2 use serde_json::Value; 3 3 use std::collections::HashMap; 4 4 5 + /// Optional space context passed to Lua scripts when the request is space-scoped. 6 + #[derive(Debug, Clone)] 7 + pub struct SpaceContext { 8 + pub space_uri: String, 9 + pub space_id: String, 10 + pub owner_did: String, 11 + pub type_nsid: String, 12 + pub skey: String, 13 + } 14 + 15 + fn set_space_context(lua: &Lua, space: Option<&SpaceContext>) -> LuaResult<()> { 16 + let globals = lua.globals(); 17 + match space { 18 + Some(ctx) => { 19 + let table = lua.create_table()?; 20 + table.set("space_uri", ctx.space_uri.as_str())?; 21 + table.set("space_id", ctx.space_id.as_str())?; 22 + table.set("owner_did", ctx.owner_did.as_str())?; 23 + table.set("type_nsid", ctx.type_nsid.as_str())?; 24 + table.set("skey", ctx.skey.as_str())?; 25 + globals.set("space", table)?; 26 + } 27 + None => { 28 + globals.set("space", mlua::Value::Nil)?; 29 + } 30 + } 31 + Ok(()) 32 + } 33 + 5 34 /// Set global context variables for a procedure script. 6 35 pub fn set_procedure_context( 7 36 lua: &Lua, ··· 10 39 params: &HashMap<String, Value>, 11 40 caller_did: &str, 12 41 collection: &str, 42 + space: Option<&SpaceContext>, 13 43 ) -> LuaResult<()> { 14 44 let globals = lua.globals(); 15 45 globals.set("method", method.to_string())?; ··· 17 47 globals.set("params", lua.to_value(params)?)?; 18 48 globals.set("caller_did", caller_did.to_string())?; 19 49 globals.set("collection", collection.to_string())?; 50 + set_space_context(lua, space)?; 20 51 Ok(()) 21 52 } 22 53 ··· 27 58 params: &HashMap<String, Value>, 28 59 collection: &str, 29 60 caller_did: Option<&str>, 61 + space: Option<&SpaceContext>, 30 62 ) -> LuaResult<()> { 31 63 let globals = lua.globals(); 32 64 globals.set("method", method.to_string())?; ··· 36 68 Some(did) => globals.set("caller_did", did.to_string())?, 37 69 None => globals.set("caller_did", mlua::Value::Nil)?, 38 70 } 71 + set_space_context(lua, space)?; 39 72 Ok(()) 40 73 } 41 74 ··· 117 150 &params, 118 151 "did:plc:test", 119 152 "com.example.thing", 153 + None, 120 154 ) 121 155 .unwrap(); 122 156 ··· 150 184 &params, 151 185 "com.example.thing", 152 186 Some("did:plc:test"), 187 + None, 153 188 ) 154 189 .unwrap(); 155 190 ··· 191 226 let globals = lua.globals(); 192 227 let env: mlua::Table = globals.get("env").unwrap(); 193 228 assert!(env.get::<mlua::Value>("anything").unwrap().is_nil()); 229 + } 230 + 231 + #[test] 232 + fn query_context_with_space() { 233 + let lua = create_sandbox().unwrap(); 234 + let params = HashMap::new(); 235 + let space = SpaceContext { 236 + space_uri: "ats://did:plc:owner/com.example.forum/main".into(), 237 + space_id: "space-123".into(), 238 + owner_did: "did:plc:owner".into(), 239 + type_nsid: "com.example.forum".into(), 240 + skey: "main".into(), 241 + }; 242 + set_query_context( 243 + &lua, 244 + "com.example.listPosts", 245 + &params, 246 + "com.example.forum.post", 247 + Some("did:plc:test"), 248 + Some(&space), 249 + ) 250 + .unwrap(); 251 + 252 + let globals = lua.globals(); 253 + let space_table: mlua::Table = globals.get("space").unwrap(); 254 + assert_eq!( 255 + space_table.get::<String>("space_uri").unwrap(), 256 + "ats://did:plc:owner/com.example.forum/main" 257 + ); 258 + assert_eq!(space_table.get::<String>("space_id").unwrap(), "space-123"); 259 + assert_eq!( 260 + space_table.get::<String>("owner_did").unwrap(), 261 + "did:plc:owner" 262 + ); 263 + } 264 + 265 + #[test] 266 + fn query_context_without_space() { 267 + let lua = create_sandbox().unwrap(); 268 + let params = HashMap::new(); 269 + set_query_context( 270 + &lua, 271 + "com.example.listThings", 272 + &params, 273 + "com.example.thing", 274 + None, 275 + None, 276 + ) 277 + .unwrap(); 278 + 279 + let globals = lua.globals(); 280 + assert!(globals.get::<mlua::Value>("space").unwrap().is_nil()); 194 281 } 195 282 196 283 #[test]
+20 -6
src/lua/execute.rs
··· 33 33 } 34 34 35 35 /// Execute a Lua script for a procedure endpoint. 36 + #[allow(clippy::too_many_arguments)] 36 37 pub async fn execute_procedure_script( 37 38 state: &AppState, 38 39 method: &str, ··· 41 42 params: &std::collections::HashMap<String, Value>, 42 43 lexicon: &ParsedLexicon, 43 44 script: &str, 45 + space_ctx: Option<&context::SpaceContext>, 44 46 ) -> Result<Response, AppError> { 45 47 let start = Instant::now(); 46 48 let backend = state.db_backend; ··· 275 277 return Err(AppError::Internal(error_message)); 276 278 } 277 279 278 - if let Err(e) = 279 - context::set_procedure_context(&lua, method, input, params, claims.did(), collection) 280 - { 280 + if let Err(e) = context::set_procedure_context( 281 + &lua, 282 + method, 283 + input, 284 + params, 285 + claims.did(), 286 + collection, 287 + space_ctx, 288 + ) { 281 289 let error_message = format!("failed to set context: {e}"); 282 290 log_event( 283 291 &state.db, ··· 503 511 lexicon: &ParsedLexicon, 504 512 script: &str, 505 513 claims: Option<&Claims>, 514 + space_ctx: Option<&context::SpaceContext>, 506 515 ) -> Result<Response, AppError> { 507 516 let start = Instant::now(); 508 517 let backend = state.db_backend; ··· 632 641 return Err(AppError::Internal(error_message)); 633 642 } 634 643 635 - if let Err(e) = 636 - context::set_query_context(&lua, method, params, collection, claims.map(|c| c.did())) 637 - { 644 + if let Err(e) = context::set_query_context( 645 + &lua, 646 + method, 647 + params, 648 + collection, 649 + claims.map(|c| c.did()), 650 + space_ctx, 651 + ) { 638 652 let error_message = format!("failed to set context: {e}"); 639 653 log_event( 640 654 &state.db,
+2
src/lua/mod.rs
··· 8 8 mod tid; 9 9 mod xrpc_api; 10 10 11 + #[allow(unused_imports)] 12 + pub(crate) use context::SpaceContext; 11 13 pub(crate) use execute::{ 12 14 HookEvent, execute_hook_script, execute_procedure_script, execute_query_script, run_hook_once, 13 15 };
+2
src/lua/xrpc_api.rs
··· 310 310 script: script.map(|s| s.to_string()), 311 311 index_hook: None, 312 312 token_cost: None, 313 + space_type: None, 313 314 } 314 315 } 315 316 ··· 329 330 script: script.map(|s| s.to_string()), 330 331 index_hook: None, 331 332 token_cost: None, 333 + space_type: None, 332 334 } 333 335 } 334 336
+19 -7
src/profile.rs
··· 27 27 28 28 #[derive(Deserialize)] 29 29 #[serde(rename_all = "camelCase")] 30 - struct DidDocument { 30 + pub struct DidDocument { 31 31 #[serde(default)] 32 - also_known_as: Vec<String>, 32 + pub also_known_as: Vec<String>, 33 33 #[serde(default)] 34 - service: Vec<DidService>, 34 + pub verification_method: Vec<DidVerificationMethod>, 35 + #[serde(default)] 36 + pub service: Vec<DidService>, 35 37 } 36 38 37 39 #[derive(Deserialize)] 38 40 #[serde(rename_all = "camelCase")] 39 - struct DidService { 40 - id: String, 41 - service_endpoint: String, 41 + pub struct DidVerificationMethod { 42 + pub id: String, 43 + #[serde(rename = "type")] 44 + pub method_type: String, 45 + #[serde(default)] 46 + pub public_key_multibase: Option<String>, 47 + } 48 + 49 + #[derive(Deserialize)] 50 + #[serde(rename_all = "camelCase")] 51 + pub struct DidService { 52 + pub id: String, 53 + pub service_endpoint: String, 42 54 } 43 55 44 56 #[derive(Deserialize)] ··· 117 129 } 118 130 119 131 /// Fetch a DID document from the PLC directory or via `did:web` resolution. 120 - async fn resolve_did_document( 132 + pub async fn resolve_did_document( 121 133 http: &reqwest::Client, 122 134 plc_url: &str, 123 135 did: &str,
+1
src/server.rs
··· 62 62 let serve_dir = ServeDir::new(&static_dir).not_found_service(spa_fallback); 63 63 64 64 let domain_routes = Router::new() 65 + .merge(crate::spaces::routes::space_routes()) 65 66 .nest("/auth", crate::auth::routes::routes()) 66 67 .nest("/external-auth", crate::external_auth::routes()) 67 68 .nest("/oauth", crate::oauth::routes::routes())
+315
src/spaces/auth.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use p256::ecdsa::SigningKey; 4 + use rand::RngCore; 5 + use sha2::{Digest, Sha256}; 6 + use uuid::Uuid; 7 + 8 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 9 + use crate::error::AppError; 10 + use crate::plugin::encryption::{decrypt, encrypt}; 11 + use crate::spaces::credential::{ 12 + DEFAULT_CREDENTIAL_TTL_SECS, SpaceCredentialClaims, sign_credential, verify_credential, 13 + }; 14 + use crate::spaces::types::{AccessMode, Space}; 15 + 16 + pub struct IssuedCredential { 17 + pub token: String, 18 + pub expires_at: String, 19 + } 20 + 21 + pub async fn issue_credential( 22 + pool: &sqlx::AnyPool, 23 + backend: DatabaseBackend, 24 + encryption_key: &[u8; 32], 25 + space: &Space, 26 + subject_did: &str, 27 + client_id: Option<&str>, 28 + ) -> Result<IssuedCredential, AppError> { 29 + check_app_access(space, client_id)?; 30 + 31 + let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; 32 + 33 + let now = std::time::SystemTime::now() 34 + .duration_since(std::time::UNIX_EPOCH) 35 + .unwrap() 36 + .as_secs(); 37 + let exp = now + DEFAULT_CREDENTIAL_TTL_SECS; 38 + 39 + let claims = SpaceCredentialClaims { 40 + iss: space.owner_did.clone(), 41 + sub: subject_did.to_string(), 42 + space: format!("{}/{}/{}", space.owner_did, space.type_nsid, space.skey), 43 + scope: "read".into(), 44 + iat: now, 45 + exp, 46 + }; 47 + 48 + let token = sign_credential(&claims, &private_jwk)?; 49 + 50 + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); 51 + store_credential_record(pool, backend, &space.id, subject_did, &token_hash, exp).await?; 52 + 53 + let expires_at = chrono::DateTime::from_timestamp(exp as i64, 0) 54 + .map(|dt| dt.to_rfc3339()) 55 + .unwrap_or_default(); 56 + 57 + Ok(IssuedCredential { token, expires_at }) 58 + } 59 + 60 + pub async fn refresh_credential( 61 + pool: &sqlx::AnyPool, 62 + backend: DatabaseBackend, 63 + encryption_key: &[u8; 32], 64 + space: &Space, 65 + current_token: &str, 66 + ) -> Result<IssuedCredential, AppError> { 67 + let public_jwk = get_public_key(pool, backend, encryption_key, space).await?; 68 + let claims = verify_credential(current_token, &public_jwk)?; 69 + 70 + issue_credential(pool, backend, encryption_key, space, &claims.sub, None).await 71 + } 72 + 73 + pub fn check_app_access(space: &Space, client_id: Option<&str>) -> Result<(), AppError> { 74 + let Some(client_id) = client_id else { 75 + return Ok(()); 76 + }; 77 + 78 + match space.access_mode { 79 + AccessMode::DefaultDeny => { 80 + if let Some(ref allowlist) = space.app_allowlist { 81 + if !allowlist.iter().any(|id| id == client_id) { 82 + return Err(AppError::Forbidden( 83 + "This app is not authorized to access this space".into(), 84 + )); 85 + } 86 + } else { 87 + return Err(AppError::Forbidden( 88 + "Space is in default_deny mode with no allowlist".into(), 89 + )); 90 + } 91 + } 92 + AccessMode::DefaultAllow => { 93 + if let Some(ref denylist) = space.app_denylist 94 + && denylist.iter().any(|id| id == client_id) 95 + { 96 + return Err(AppError::Forbidden( 97 + "This app has been denied access to this space".into(), 98 + )); 99 + } 100 + } 101 + } 102 + 103 + Ok(()) 104 + } 105 + 106 + async fn get_or_create_signing_key( 107 + pool: &sqlx::AnyPool, 108 + backend: DatabaseBackend, 109 + encryption_key: &[u8; 32], 110 + space: &Space, 111 + ) -> Result<serde_json::Value, AppError> { 112 + let sql = adapt_sql( 113 + "SELECT signing_key_enc FROM space_dids WHERE space_id = ?", 114 + backend, 115 + ); 116 + let row: Option<(Vec<u8>,)> = sqlx::query_as(&sql) 117 + .bind(&space.id) 118 + .fetch_optional(pool) 119 + .await 120 + .map_err(|e| AppError::Internal(format!("failed to look up space signing key: {e}")))?; 121 + 122 + if let Some((encrypted,)) = row { 123 + let decrypted = decrypt(encryption_key, &encrypted) 124 + .map_err(|e| AppError::Internal(format!("failed to decrypt signing key: {e}")))?; 125 + let jwk: serde_json::Value = serde_json::from_slice(&decrypted) 126 + .map_err(|e| AppError::Internal(format!("failed to parse signing key: {e}")))?; 127 + return Ok(jwk); 128 + } 129 + 130 + let keypair = generate_space_keypair()?; 131 + let key_bytes = serde_json::to_vec(&keypair.private_jwk) 132 + .map_err(|e| AppError::Internal(format!("failed to serialize signing key: {e}")))?; 133 + let encrypted_signing = encrypt(encryption_key, &key_bytes) 134 + .map_err(|e| AppError::Internal(format!("failed to encrypt signing key: {e}")))?; 135 + 136 + // Rotation key is a separate keypair for recovery 137 + let rotation_keypair = generate_space_keypair()?; 138 + let rotation_bytes = serde_json::to_vec(&rotation_keypair.private_jwk) 139 + .map_err(|e| AppError::Internal(format!("failed to serialize rotation key: {e}")))?; 140 + let encrypted_rotation = encrypt(encryption_key, &rotation_bytes) 141 + .map_err(|e| AppError::Internal(format!("failed to encrypt rotation key: {e}")))?; 142 + 143 + let now = now_rfc3339(); 144 + let insert_sql = adapt_sql( 145 + "INSERT INTO space_dids (id, did, space_id, signing_key_enc, rotation_key_enc, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 146 + backend, 147 + ); 148 + 149 + sqlx::query(&insert_sql) 150 + .bind(Uuid::new_v4().to_string()) 151 + .bind(&space.owner_did) 152 + .bind(&space.id) 153 + .bind(&encrypted_signing) 154 + .bind(&encrypted_rotation) 155 + .bind(&space.owner_did) 156 + .bind(&now) 157 + .execute(pool) 158 + .await 159 + .map_err(|e| AppError::Internal(format!("failed to store space signing key: {e}")))?; 160 + 161 + Ok(keypair.private_jwk) 162 + } 163 + 164 + async fn get_public_key( 165 + pool: &sqlx::AnyPool, 166 + backend: DatabaseBackend, 167 + encryption_key: &[u8; 32], 168 + space: &Space, 169 + ) -> Result<serde_json::Value, AppError> { 170 + let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; 171 + Ok(serde_json::json!({ 172 + "kty": "EC", 173 + "crv": "P-256", 174 + "x": private_jwk["x"], 175 + "y": private_jwk["y"], 176 + })) 177 + } 178 + 179 + struct SpaceKeypair { 180 + private_jwk: serde_json::Value, 181 + } 182 + 183 + fn generate_space_keypair() -> Result<SpaceKeypair, AppError> { 184 + let mut rng_bytes = [0u8; 32]; 185 + rand::rng().fill_bytes(&mut rng_bytes); 186 + 187 + let signing_key = SigningKey::from_bytes((&rng_bytes[..]).into()) 188 + .map_err(|e| AppError::Internal(format!("failed to generate signing key: {e}")))?; 189 + 190 + let verifying_key = signing_key.verifying_key(); 191 + let public_point = verifying_key.to_encoded_point(false); 192 + 193 + let x_bytes = public_point 194 + .x() 195 + .ok_or_else(|| AppError::Internal("missing x coordinate".into()))?; 196 + let y_bytes = public_point 197 + .y() 198 + .ok_or_else(|| AppError::Internal("missing y coordinate".into()))?; 199 + 200 + let x_b64 = URL_SAFE_NO_PAD.encode(x_bytes); 201 + let y_b64 = URL_SAFE_NO_PAD.encode(y_bytes); 202 + let d_b64 = URL_SAFE_NO_PAD.encode(rng_bytes); 203 + 204 + let private_jwk = serde_json::json!({ 205 + "kty": "EC", 206 + "crv": "P-256", 207 + "x": x_b64, 208 + "y": y_b64, 209 + "d": d_b64, 210 + }); 211 + 212 + Ok(SpaceKeypair { private_jwk }) 213 + } 214 + 215 + async fn store_credential_record( 216 + pool: &sqlx::AnyPool, 217 + backend: DatabaseBackend, 218 + space_id: &str, 219 + issued_to: &str, 220 + token_hash: &str, 221 + expires_at_epoch: u64, 222 + ) -> Result<(), AppError> { 223 + let now = now_rfc3339(); 224 + let expires_at = chrono::DateTime::from_timestamp(expires_at_epoch as i64, 0) 225 + .map(|dt| dt.to_rfc3339()) 226 + .unwrap_or_default(); 227 + 228 + let sql = adapt_sql( 229 + "INSERT INTO space_credentials (id, space_id, issued_to, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", 230 + backend, 231 + ); 232 + 233 + sqlx::query(&sql) 234 + .bind(Uuid::new_v4().to_string()) 235 + .bind(space_id) 236 + .bind(issued_to) 237 + .bind(token_hash) 238 + .bind(&expires_at) 239 + .bind(&now) 240 + .execute(pool) 241 + .await 242 + .map_err(|e| AppError::Internal(format!("failed to store credential record: {e}")))?; 243 + 244 + Ok(()) 245 + } 246 + 247 + #[cfg(test)] 248 + mod tests { 249 + use super::*; 250 + use crate::spaces::types::{AccessMode, Space, SpaceConfig}; 251 + 252 + fn test_space(access_mode: AccessMode) -> Space { 253 + Space { 254 + id: "test-space".into(), 255 + owner_did: "did:plc:owner".into(), 256 + type_nsid: "com.example.forum".into(), 257 + skey: "main".into(), 258 + display_name: None, 259 + description: None, 260 + access_mode, 261 + app_allowlist: None, 262 + app_denylist: None, 263 + managing_app_did: None, 264 + config: SpaceConfig::default(), 265 + created_at: String::new(), 266 + updated_at: String::new(), 267 + } 268 + } 269 + 270 + #[test] 271 + fn app_access_default_allow_no_lists() { 272 + let space = test_space(AccessMode::DefaultAllow); 273 + assert!(check_app_access(&space, Some("any-app")).is_ok()); 274 + } 275 + 276 + #[test] 277 + fn app_access_default_allow_denied() { 278 + let mut space = test_space(AccessMode::DefaultAllow); 279 + space.app_denylist = Some(vec!["bad-app".into()]); 280 + 281 + assert!(check_app_access(&space, Some("good-app")).is_ok()); 282 + assert!(check_app_access(&space, Some("bad-app")).is_err()); 283 + } 284 + 285 + #[test] 286 + fn app_access_default_deny_no_allowlist() { 287 + let space = test_space(AccessMode::DefaultDeny); 288 + assert!(check_app_access(&space, Some("any-app")).is_err()); 289 + } 290 + 291 + #[test] 292 + fn app_access_default_deny_allowed() { 293 + let mut space = test_space(AccessMode::DefaultDeny); 294 + space.app_allowlist = Some(vec!["good-app".into()]); 295 + 296 + assert!(check_app_access(&space, Some("good-app")).is_ok()); 297 + assert!(check_app_access(&space, Some("other-app")).is_err()); 298 + } 299 + 300 + #[test] 301 + fn app_access_no_client_id_always_passes() { 302 + let space = test_space(AccessMode::DefaultDeny); 303 + assert!(check_app_access(&space, None).is_ok()); 304 + } 305 + 306 + #[test] 307 + fn generate_keypair_produces_valid_jwk() { 308 + let kp = generate_space_keypair().unwrap(); 309 + assert_eq!(kp.private_jwk["kty"], "EC"); 310 + assert_eq!(kp.private_jwk["crv"], "P-256"); 311 + assert!(kp.private_jwk["d"].is_string()); 312 + assert!(kp.private_jwk["x"].is_string()); 313 + assert!(kp.private_jwk["y"].is_string()); 314 + } 315 + }
+284
src/spaces/credential.rs
··· 1 + use base64::Engine; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use p256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Signer, signature::Verifier}; 4 + use serde::{Deserialize, Serialize}; 5 + 6 + use crate::error::AppError; 7 + use crate::profile; 8 + 9 + pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 4 * 60 * 60; // 4 hours 10 + 11 + #[derive(Debug, Clone, Serialize, Deserialize)] 12 + pub struct SpaceCredentialClaims { 13 + pub iss: String, 14 + pub sub: String, 15 + pub space: String, 16 + pub scope: String, 17 + pub iat: u64, 18 + pub exp: u64, 19 + } 20 + 21 + pub fn sign_credential( 22 + claims: &SpaceCredentialClaims, 23 + private_jwk: &serde_json::Value, 24 + ) -> Result<String, AppError> { 25 + let d_b64 = private_jwk["d"] 26 + .as_str() 27 + .ok_or_else(|| AppError::Internal("signing key missing d parameter".into()))?; 28 + 29 + let d_bytes = URL_SAFE_NO_PAD 30 + .decode(d_b64) 31 + .map_err(|_| AppError::Internal("invalid signing key d parameter".into()))?; 32 + 33 + let signing_key = SigningKey::from_bytes((&d_bytes[..]).into()) 34 + .map_err(|e| AppError::Internal(format!("invalid signing key: {e}")))?; 35 + 36 + let header = serde_json::json!({ 37 + "alg": "ES256", 38 + "typ": "JWT", 39 + }); 40 + 41 + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); 42 + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()); 43 + 44 + let message = format!("{}.{}", header_b64, payload_b64); 45 + let signature: Signature = signing_key.sign(message.as_bytes()); 46 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 47 + 48 + Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64)) 49 + } 50 + 51 + pub fn verify_credential( 52 + token: &str, 53 + public_jwk: &serde_json::Value, 54 + ) -> Result<SpaceCredentialClaims, AppError> { 55 + let parts: Vec<&str> = token.split('.').collect(); 56 + if parts.len() != 3 { 57 + return Err(AppError::Auth("invalid credential format".into())); 58 + } 59 + 60 + let header_bytes = URL_SAFE_NO_PAD 61 + .decode(parts[0]) 62 + .map_err(|_| AppError::Auth("invalid credential header encoding".into()))?; 63 + let header: serde_json::Value = serde_json::from_slice(&header_bytes) 64 + .map_err(|_| AppError::Auth("invalid credential header".into()))?; 65 + 66 + if header["alg"].as_str() != Some("ES256") { 67 + return Err(AppError::Auth("credential alg must be ES256".into())); 68 + } 69 + 70 + let x_b64 = public_jwk["x"] 71 + .as_str() 72 + .ok_or_else(|| AppError::Auth("public key missing x".into()))?; 73 + let y_b64 = public_jwk["y"] 74 + .as_str() 75 + .ok_or_else(|| AppError::Auth("public key missing y".into()))?; 76 + 77 + let x_bytes = URL_SAFE_NO_PAD 78 + .decode(x_b64) 79 + .map_err(|_| AppError::Auth("invalid public key x".into()))?; 80 + let y_bytes = URL_SAFE_NO_PAD 81 + .decode(y_b64) 82 + .map_err(|_| AppError::Auth("invalid public key y".into()))?; 83 + 84 + let mut sec1 = Vec::with_capacity(1 + 32 + 32); 85 + sec1.push(0x04); 86 + sec1.extend_from_slice(&x_bytes); 87 + sec1.extend_from_slice(&y_bytes); 88 + 89 + let verifying_key = VerifyingKey::from_sec1_bytes(&sec1) 90 + .map_err(|_| AppError::Auth("invalid space credential public key".into()))?; 91 + 92 + let message = format!("{}.{}", parts[0], parts[1]); 93 + let sig_bytes = URL_SAFE_NO_PAD 94 + .decode(parts[2]) 95 + .map_err(|_| AppError::Auth("invalid credential signature encoding".into()))?; 96 + let signature = Signature::from_bytes(sig_bytes.as_slice().into()) 97 + .map_err(|_| AppError::Auth("invalid credential signature format".into()))?; 98 + 99 + verifying_key 100 + .verify(message.as_bytes(), &signature) 101 + .map_err(|_| AppError::Auth("credential signature verification failed".into()))?; 102 + 103 + let payload_bytes = URL_SAFE_NO_PAD 104 + .decode(parts[1]) 105 + .map_err(|_| AppError::Auth("invalid credential payload encoding".into()))?; 106 + let claims: SpaceCredentialClaims = serde_json::from_slice(&payload_bytes) 107 + .map_err(|_| AppError::Auth("invalid credential payload".into()))?; 108 + 109 + let now = std::time::SystemTime::now() 110 + .duration_since(std::time::UNIX_EPOCH) 111 + .unwrap() 112 + .as_secs(); 113 + 114 + if now > claims.exp { 115 + return Err(AppError::Auth("credential has expired".into())); 116 + } 117 + 118 + Ok(claims) 119 + } 120 + 121 + /// Convert a multibase-encoded P-256 public key (from a DID doc `publicKeyMultibase`) 122 + /// into a JWK suitable for `verify_credential`. 123 + pub fn multikey_to_p256_jwk(public_key_multibase: &str) -> Result<serde_json::Value, AppError> { 124 + let (_base, key_bytes) = multibase::decode(public_key_multibase) 125 + .map_err(|e| AppError::Auth(format!("invalid multibase encoding: {e}")))?; 126 + 127 + // P-256 multicodec prefix: varint 0x1200 → bytes [0x80, 0x24] 128 + if key_bytes.len() < 2 || key_bytes[0] != 0x80 || key_bytes[1] != 0x24 { 129 + return Err(AppError::Auth( 130 + "public key is not a P-256 multicodec key".into(), 131 + )); 132 + } 133 + 134 + let compressed = &key_bytes[2..]; 135 + let verifying_key = VerifyingKey::from_sec1_bytes(compressed) 136 + .map_err(|_| AppError::Auth("invalid P-256 public key bytes".into()))?; 137 + 138 + let point = verifying_key.to_encoded_point(false); 139 + let x = point 140 + .x() 141 + .ok_or_else(|| AppError::Auth("failed to extract x coordinate".into()))?; 142 + let y = point 143 + .y() 144 + .ok_or_else(|| AppError::Auth("failed to extract y coordinate".into()))?; 145 + 146 + Ok(serde_json::json!({ 147 + "kty": "EC", 148 + "crv": "P-256", 149 + "x": URL_SAFE_NO_PAD.encode(x), 150 + "y": URL_SAFE_NO_PAD.encode(y), 151 + })) 152 + } 153 + 154 + /// Verify a space credential JWT issued by an external space host. 155 + /// 156 + /// Resolves the issuer's DID document, extracts the `#atproto` signing key, 157 + /// and verifies the JWT signature and expiry. 158 + pub async fn verify_external_credential( 159 + token: &str, 160 + http: &reqwest::Client, 161 + plc_url: &str, 162 + ) -> Result<SpaceCredentialClaims, AppError> { 163 + // Peek at the payload to extract the issuer DID without verifying yet 164 + let parts: Vec<&str> = token.split('.').collect(); 165 + if parts.len() != 3 { 166 + return Err(AppError::Auth("invalid credential format".into())); 167 + } 168 + 169 + let payload_bytes = URL_SAFE_NO_PAD 170 + .decode(parts[1]) 171 + .map_err(|_| AppError::Auth("invalid credential payload encoding".into()))?; 172 + let peek: SpaceCredentialClaims = serde_json::from_slice(&payload_bytes) 173 + .map_err(|_| AppError::Auth("invalid credential payload".into()))?; 174 + 175 + let did_doc = profile::resolve_did_document(http, plc_url, &peek.iss).await?; 176 + 177 + let vm = did_doc 178 + .verification_method 179 + .iter() 180 + .find(|v| v.id.ends_with("#atproto")) 181 + .ok_or_else(|| AppError::Auth("issuer DID has no #atproto verification method".into()))?; 182 + 183 + let multibase = vm 184 + .public_key_multibase 185 + .as_deref() 186 + .ok_or_else(|| AppError::Auth("verification method missing publicKeyMultibase".into()))?; 187 + 188 + let jwk = multikey_to_p256_jwk(multibase)?; 189 + verify_credential(token, &jwk) 190 + } 191 + 192 + #[cfg(test)] 193 + mod tests { 194 + use super::*; 195 + use crate::oauth::keys::generate_dpop_keypair; 196 + 197 + fn make_claims() -> SpaceCredentialClaims { 198 + let now = std::time::SystemTime::now() 199 + .duration_since(std::time::UNIX_EPOCH) 200 + .unwrap() 201 + .as_secs(); 202 + SpaceCredentialClaims { 203 + iss: "did:plc:spaceowner".into(), 204 + sub: "did:plc:requester".into(), 205 + space: "did:plc:spaceowner/com.example.forum/main".into(), 206 + scope: "read".into(), 207 + iat: now, 208 + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, 209 + } 210 + } 211 + 212 + #[test] 213 + fn sign_and_verify_roundtrip() { 214 + let keypair = generate_dpop_keypair().unwrap(); 215 + let claims = make_claims(); 216 + 217 + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); 218 + let verified = verify_credential(&token, &keypair.public_jwk).unwrap(); 219 + 220 + assert_eq!(verified.iss, claims.iss); 221 + assert_eq!(verified.sub, claims.sub); 222 + assert_eq!(verified.space, claims.space); 223 + assert_eq!(verified.scope, claims.scope); 224 + assert_eq!(verified.iat, claims.iat); 225 + assert_eq!(verified.exp, claims.exp); 226 + } 227 + 228 + #[test] 229 + fn verify_rejects_tampered_payload() { 230 + let keypair = generate_dpop_keypair().unwrap(); 231 + let claims = make_claims(); 232 + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); 233 + 234 + // Tamper with the payload 235 + let parts: Vec<&str> = token.split('.').collect(); 236 + let mut payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); 237 + payload_bytes[0] ^= 0xFF; 238 + let tampered_payload = URL_SAFE_NO_PAD.encode(&payload_bytes); 239 + let tampered = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]); 240 + 241 + let result = verify_credential(&tampered, &keypair.public_jwk); 242 + assert!(result.is_err()); 243 + } 244 + 245 + #[test] 246 + fn verify_rejects_wrong_key() { 247 + let keypair1 = generate_dpop_keypair().unwrap(); 248 + let keypair2 = generate_dpop_keypair().unwrap(); 249 + let claims = make_claims(); 250 + let token = sign_credential(&claims, &keypair1.private_jwk).unwrap(); 251 + 252 + let result = verify_credential(&token, &keypair2.public_jwk); 253 + assert!(result.is_err()); 254 + } 255 + 256 + #[test] 257 + fn verify_rejects_expired() { 258 + let keypair = generate_dpop_keypair().unwrap(); 259 + let now = std::time::SystemTime::now() 260 + .duration_since(std::time::UNIX_EPOCH) 261 + .unwrap() 262 + .as_secs(); 263 + let claims = SpaceCredentialClaims { 264 + iss: "did:plc:owner".into(), 265 + sub: "did:plc:user".into(), 266 + space: "did:plc:owner/test/main".into(), 267 + scope: "read".into(), 268 + iat: now - 7200, 269 + exp: now - 3600, // expired 1 hour ago 270 + }; 271 + 272 + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); 273 + let result = verify_credential(&token, &keypair.public_jwk); 274 + assert!(result.is_err()); 275 + assert!(result.unwrap_err().to_string().contains("expired")); 276 + } 277 + 278 + #[test] 279 + fn verify_rejects_invalid_format() { 280 + let keypair = generate_dpop_keypair().unwrap(); 281 + let result = verify_credential("not-a-jwt", &keypair.public_jwk); 282 + assert!(result.is_err()); 283 + } 284 + }
+780
src/spaces/db.rs
··· 1 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 2 + use crate::error::AppError; 3 + use crate::spaces::types::*; 4 + 5 + // --------------------------------------------------------------------------- 6 + // Spaces 7 + // --------------------------------------------------------------------------- 8 + 9 + pub async fn create_space( 10 + pool: &sqlx::AnyPool, 11 + backend: DatabaseBackend, 12 + space: &Space, 13 + ) -> Result<(), AppError> { 14 + let now = now_rfc3339(); 15 + let config_json = serde_json::to_string(&space.config) 16 + .map_err(|e| AppError::Internal(format!("failed to serialize space config: {e}")))?; 17 + let allowlist_json = space 18 + .app_allowlist 19 + .as_ref() 20 + .map(|v| serde_json::to_string(v).unwrap_or_default()); 21 + let denylist_json = space 22 + .app_denylist 23 + .as_ref() 24 + .map(|v| serde_json::to_string(v).unwrap_or_default()); 25 + 26 + let sql = adapt_sql( 27 + "INSERT INTO spaces (id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 28 + backend, 29 + ); 30 + 31 + sqlx::query(&sql) 32 + .bind(&space.id) 33 + .bind(&space.owner_did) 34 + .bind(&space.type_nsid) 35 + .bind(&space.skey) 36 + .bind(&space.display_name) 37 + .bind(&space.description) 38 + .bind(space.access_mode.as_str()) 39 + .bind(&allowlist_json) 40 + .bind(&denylist_json) 41 + .bind(&space.managing_app_did) 42 + .bind(&config_json) 43 + .bind(&now) 44 + .bind(&now) 45 + .execute(pool) 46 + .await 47 + .map_err(|e| AppError::Internal(format!("failed to create space: {e}")))?; 48 + 49 + Ok(()) 50 + } 51 + 52 + pub async fn get_space( 53 + pool: &sqlx::AnyPool, 54 + backend: DatabaseBackend, 55 + id: &str, 56 + ) -> Result<Option<Space>, AppError> { 57 + let sql = adapt_sql( 58 + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE id = ?", 59 + backend, 60 + ); 61 + 62 + let row: Option<SpaceRow> = sqlx::query_as(&sql) 63 + .bind(id) 64 + .fetch_optional(pool) 65 + .await 66 + .map_err(|e| AppError::Internal(format!("failed to get space: {e}")))?; 67 + 68 + row.map(parse_space_row).transpose() 69 + } 70 + 71 + pub async fn get_space_by_address( 72 + pool: &sqlx::AnyPool, 73 + backend: DatabaseBackend, 74 + owner_did: &str, 75 + type_nsid: &str, 76 + skey: &str, 77 + ) -> Result<Option<Space>, AppError> { 78 + let sql = adapt_sql( 79 + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE owner_did = ? AND type_nsid = ? AND skey = ?", 80 + backend, 81 + ); 82 + 83 + let row: Option<SpaceRow> = sqlx::query_as(&sql) 84 + .bind(owner_did) 85 + .bind(type_nsid) 86 + .bind(skey) 87 + .fetch_optional(pool) 88 + .await 89 + .map_err(|e| AppError::Internal(format!("failed to get space: {e}")))?; 90 + 91 + row.map(parse_space_row).transpose() 92 + } 93 + 94 + pub async fn list_spaces_by_owner( 95 + pool: &sqlx::AnyPool, 96 + backend: DatabaseBackend, 97 + owner_did: &str, 98 + ) -> Result<Vec<Space>, AppError> { 99 + let sql = adapt_sql( 100 + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE owner_did = ? ORDER BY created_at DESC", 101 + backend, 102 + ); 103 + 104 + let rows: Vec<SpaceRow> = sqlx::query_as(&sql) 105 + .bind(owner_did) 106 + .fetch_all(pool) 107 + .await 108 + .map_err(|e| AppError::Internal(format!("failed to list spaces: {e}")))?; 109 + 110 + rows.into_iter().map(parse_space_row).collect() 111 + } 112 + 113 + pub async fn update_space( 114 + pool: &sqlx::AnyPool, 115 + backend: DatabaseBackend, 116 + space: &Space, 117 + ) -> Result<bool, AppError> { 118 + let now = now_rfc3339(); 119 + let config_json = serde_json::to_string(&space.config) 120 + .map_err(|e| AppError::Internal(format!("failed to serialize space config: {e}")))?; 121 + let allowlist_json = space 122 + .app_allowlist 123 + .as_ref() 124 + .map(|v| serde_json::to_string(v).unwrap_or_default()); 125 + let denylist_json = space 126 + .app_denylist 127 + .as_ref() 128 + .map(|v| serde_json::to_string(v).unwrap_or_default()); 129 + 130 + let sql = adapt_sql( 131 + "UPDATE spaces SET display_name = ?, description = ?, access_mode = ?, app_allowlist = ?, app_denylist = ?, managing_app_did = ?, config = ?, updated_at = ? WHERE id = ?", 132 + backend, 133 + ); 134 + 135 + let result = sqlx::query(&sql) 136 + .bind(&space.display_name) 137 + .bind(&space.description) 138 + .bind(space.access_mode.as_str()) 139 + .bind(&allowlist_json) 140 + .bind(&denylist_json) 141 + .bind(&space.managing_app_did) 142 + .bind(&config_json) 143 + .bind(&now) 144 + .bind(&space.id) 145 + .execute(pool) 146 + .await 147 + .map_err(|e| AppError::Internal(format!("failed to update space: {e}")))?; 148 + 149 + Ok(result.rows_affected() > 0) 150 + } 151 + 152 + pub async fn delete_space( 153 + pool: &sqlx::AnyPool, 154 + backend: DatabaseBackend, 155 + id: &str, 156 + ) -> Result<bool, AppError> { 157 + let sql = adapt_sql("DELETE FROM spaces WHERE id = ?", backend); 158 + 159 + let result = sqlx::query(&sql) 160 + .bind(id) 161 + .execute(pool) 162 + .await 163 + .map_err(|e| AppError::Internal(format!("failed to delete space: {e}")))?; 164 + 165 + Ok(result.rows_affected() > 0) 166 + } 167 + 168 + type SpaceRow = ( 169 + String, 170 + String, 171 + String, 172 + String, 173 + Option<String>, 174 + Option<String>, 175 + String, 176 + Option<String>, 177 + Option<String>, 178 + Option<String>, 179 + String, 180 + String, 181 + String, 182 + ); 183 + 184 + fn parse_space_row(r: SpaceRow) -> Result<Space, AppError> { 185 + let access_mode = AccessMode::parse(&r.6) 186 + .ok_or_else(|| AppError::Internal(format!("invalid access_mode: {}", r.6)))?; 187 + let app_allowlist: Option<Vec<String>> = 188 + r.7.as_deref() 189 + .map(serde_json::from_str) 190 + .transpose() 191 + .map_err(|e| AppError::Internal(format!("invalid app_allowlist: {e}")))?; 192 + let app_denylist: Option<Vec<String>> = 193 + r.8.as_deref() 194 + .map(serde_json::from_str) 195 + .transpose() 196 + .map_err(|e| AppError::Internal(format!("invalid app_denylist: {e}")))?; 197 + let config: SpaceConfig = serde_json::from_str(&r.10) 198 + .map_err(|e| AppError::Internal(format!("invalid space config: {e}")))?; 199 + 200 + Ok(Space { 201 + id: r.0, 202 + owner_did: r.1, 203 + type_nsid: r.2, 204 + skey: r.3, 205 + display_name: r.4, 206 + description: r.5, 207 + access_mode, 208 + app_allowlist, 209 + app_denylist, 210 + managing_app_did: r.9, 211 + config, 212 + created_at: r.11, 213 + updated_at: r.12, 214 + }) 215 + } 216 + 217 + // --------------------------------------------------------------------------- 218 + // Space Members 219 + // --------------------------------------------------------------------------- 220 + 221 + pub async fn add_member( 222 + pool: &sqlx::AnyPool, 223 + backend: DatabaseBackend, 224 + member: &SpaceMember, 225 + ) -> Result<(), AppError> { 226 + let now = now_rfc3339(); 227 + let sql = adapt_sql( 228 + "INSERT INTO space_members (id, space_id, member_did, access, is_delegation, granted_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 229 + backend, 230 + ); 231 + 232 + sqlx::query(&sql) 233 + .bind(&member.id) 234 + .bind(&member.space_id) 235 + .bind(&member.member_did) 236 + .bind(member.access.as_str()) 237 + .bind(member.is_delegation as i32) 238 + .bind(&member.granted_by) 239 + .bind(&now) 240 + .execute(pool) 241 + .await 242 + .map_err(|e| AppError::Internal(format!("failed to add member: {e}")))?; 243 + 244 + Ok(()) 245 + } 246 + 247 + pub async fn remove_member( 248 + pool: &sqlx::AnyPool, 249 + backend: DatabaseBackend, 250 + space_id: &str, 251 + member_did: &str, 252 + ) -> Result<bool, AppError> { 253 + let sql = adapt_sql( 254 + "DELETE FROM space_members WHERE space_id = ? AND member_did = ?", 255 + backend, 256 + ); 257 + 258 + let result = sqlx::query(&sql) 259 + .bind(space_id) 260 + .bind(member_did) 261 + .execute(pool) 262 + .await 263 + .map_err(|e| AppError::Internal(format!("failed to remove member: {e}")))?; 264 + 265 + Ok(result.rows_affected() > 0) 266 + } 267 + 268 + pub async fn get_member( 269 + pool: &sqlx::AnyPool, 270 + backend: DatabaseBackend, 271 + space_id: &str, 272 + member_did: &str, 273 + ) -> Result<Option<SpaceMember>, AppError> { 274 + let sql = adapt_sql( 275 + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? AND member_did = ?", 276 + backend, 277 + ); 278 + 279 + let row: Option<MemberRow> = sqlx::query_as(&sql) 280 + .bind(space_id) 281 + .bind(member_did) 282 + .fetch_optional(pool) 283 + .await 284 + .map_err(|e| AppError::Internal(format!("failed to get member: {e}")))?; 285 + 286 + row.map(parse_member_row).transpose() 287 + } 288 + 289 + pub async fn list_direct_members( 290 + pool: &sqlx::AnyPool, 291 + backend: DatabaseBackend, 292 + space_id: &str, 293 + ) -> Result<Vec<SpaceMember>, AppError> { 294 + let sql = adapt_sql( 295 + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? ORDER BY created_at ASC", 296 + backend, 297 + ); 298 + 299 + let rows: Vec<MemberRow> = sqlx::query_as(&sql) 300 + .bind(space_id) 301 + .fetch_all(pool) 302 + .await 303 + .map_err(|e| AppError::Internal(format!("failed to list members: {e}")))?; 304 + 305 + rows.into_iter().map(parse_member_row).collect() 306 + } 307 + 308 + pub async fn list_spaces_for_member( 309 + pool: &sqlx::AnyPool, 310 + backend: DatabaseBackend, 311 + member_did: &str, 312 + ) -> Result<Vec<SpaceMember>, AppError> { 313 + let sql = adapt_sql( 314 + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE member_did = ? ORDER BY created_at ASC", 315 + backend, 316 + ); 317 + 318 + let rows: Vec<MemberRow> = sqlx::query_as(&sql) 319 + .bind(member_did) 320 + .fetch_all(pool) 321 + .await 322 + .map_err(|e| AppError::Internal(format!("failed to list spaces for member: {e}")))?; 323 + 324 + rows.into_iter().map(parse_member_row).collect() 325 + } 326 + 327 + type MemberRow = (String, String, String, String, i32, Option<String>, String); 328 + 329 + fn parse_member_row(r: MemberRow) -> Result<SpaceMember, AppError> { 330 + let access = SpaceAccess::parse(&r.3) 331 + .ok_or_else(|| AppError::Internal(format!("invalid access: {}", r.3)))?; 332 + 333 + Ok(SpaceMember { 334 + id: r.0, 335 + space_id: r.1, 336 + member_did: r.2, 337 + access, 338 + is_delegation: r.4 != 0, 339 + granted_by: r.5, 340 + created_at: r.6, 341 + }) 342 + } 343 + 344 + // --------------------------------------------------------------------------- 345 + // Space Records 346 + // --------------------------------------------------------------------------- 347 + 348 + pub async fn upsert_space_record( 349 + pool: &sqlx::AnyPool, 350 + backend: DatabaseBackend, 351 + record: &SpaceRecord, 352 + ) -> Result<(), AppError> { 353 + let now = now_rfc3339(); 354 + let record_json = serde_json::to_string(&record.record) 355 + .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; 356 + 357 + let sql = match backend { 358 + DatabaseBackend::Sqlite => { 359 + "INSERT OR REPLACE INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)".to_string() 360 + } 361 + DatabaseBackend::Postgres => adapt_sql( 362 + "INSERT INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at", 363 + backend, 364 + ), 365 + }; 366 + 367 + sqlx::query(&sql) 368 + .bind(&record.uri) 369 + .bind(&record.space_id) 370 + .bind(&record.author_did) 371 + .bind(&record.collection) 372 + .bind(&record.rkey) 373 + .bind(&record_json) 374 + .bind(&record.cid) 375 + .bind(&now) 376 + .execute(pool) 377 + .await 378 + .map_err(|e| AppError::Internal(format!("failed to upsert space record: {e}")))?; 379 + 380 + Ok(()) 381 + } 382 + 383 + pub async fn get_space_record( 384 + pool: &sqlx::AnyPool, 385 + backend: DatabaseBackend, 386 + uri: &str, 387 + ) -> Result<Option<SpaceRecord>, AppError> { 388 + let sql = adapt_sql( 389 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE uri = ?", 390 + backend, 391 + ); 392 + 393 + let row: Option<RecordRow> = sqlx::query_as(&sql) 394 + .bind(uri) 395 + .fetch_optional(pool) 396 + .await 397 + .map_err(|e| AppError::Internal(format!("failed to get space record: {e}")))?; 398 + 399 + row.map(parse_record_row).transpose() 400 + } 401 + 402 + pub async fn get_space_record_by_parts( 403 + pool: &sqlx::AnyPool, 404 + backend: DatabaseBackend, 405 + space_id: &str, 406 + collection: &str, 407 + rkey: &str, 408 + ) -> Result<Option<SpaceRecord>, AppError> { 409 + let sql = adapt_sql( 410 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? AND rkey = ? LIMIT 1", 411 + backend, 412 + ); 413 + 414 + let row: Option<RecordRow> = sqlx::query_as(&sql) 415 + .bind(space_id) 416 + .bind(collection) 417 + .bind(rkey) 418 + .fetch_optional(pool) 419 + .await 420 + .map_err(|e| AppError::Internal(format!("failed to get space record: {e}")))?; 421 + 422 + row.map(parse_record_row).transpose() 423 + } 424 + 425 + pub async fn list_space_records( 426 + pool: &sqlx::AnyPool, 427 + backend: DatabaseBackend, 428 + space_id: &str, 429 + collection: Option<&str>, 430 + limit: i64, 431 + cursor: Option<&str>, 432 + ) -> Result<Vec<SpaceRecord>, AppError> { 433 + let (sql, has_collection, has_cursor) = match (collection, cursor) { 434 + (Some(_), Some(_)) => ( 435 + adapt_sql( 436 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? AND indexed_at > ? ORDER BY indexed_at ASC LIMIT ?", 437 + backend, 438 + ), 439 + true, 440 + true, 441 + ), 442 + (Some(_), None) => ( 443 + adapt_sql( 444 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? ORDER BY indexed_at ASC LIMIT ?", 445 + backend, 446 + ), 447 + true, 448 + false, 449 + ), 450 + (None, Some(_)) => ( 451 + adapt_sql( 452 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND indexed_at > ? ORDER BY indexed_at ASC LIMIT ?", 453 + backend, 454 + ), 455 + false, 456 + true, 457 + ), 458 + (None, None) => ( 459 + adapt_sql( 460 + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? ORDER BY indexed_at ASC LIMIT ?", 461 + backend, 462 + ), 463 + false, 464 + false, 465 + ), 466 + }; 467 + 468 + let mut query = sqlx::query_as::<_, RecordRow>(&sql).bind(space_id); 469 + 470 + if has_collection { 471 + query = query.bind(collection.unwrap()); 472 + } 473 + if has_cursor { 474 + query = query.bind(cursor.unwrap()); 475 + } 476 + query = query.bind(limit); 477 + 478 + let rows = query 479 + .fetch_all(pool) 480 + .await 481 + .map_err(|e| AppError::Internal(format!("failed to list space records: {e}")))?; 482 + 483 + rows.into_iter().map(parse_record_row).collect() 484 + } 485 + 486 + pub async fn delete_space_record( 487 + pool: &sqlx::AnyPool, 488 + backend: DatabaseBackend, 489 + uri: &str, 490 + ) -> Result<bool, AppError> { 491 + let sql = adapt_sql("DELETE FROM space_records WHERE uri = ?", backend); 492 + 493 + let result = sqlx::query(&sql) 494 + .bind(uri) 495 + .execute(pool) 496 + .await 497 + .map_err(|e| AppError::Internal(format!("failed to delete space record: {e}")))?; 498 + 499 + Ok(result.rows_affected() > 0) 500 + } 501 + 502 + type RecordRow = ( 503 + String, 504 + String, 505 + String, 506 + String, 507 + String, 508 + String, 509 + String, 510 + String, 511 + ); 512 + 513 + fn parse_record_row(r: RecordRow) -> Result<SpaceRecord, AppError> { 514 + let record: serde_json::Value = serde_json::from_str(&r.5) 515 + .map_err(|e| AppError::Internal(format!("invalid record JSON: {e}")))?; 516 + 517 + Ok(SpaceRecord { 518 + uri: r.0, 519 + space_id: r.1, 520 + author_did: r.2, 521 + collection: r.3, 522 + rkey: r.4, 523 + record, 524 + cid: r.6, 525 + indexed_at: r.7, 526 + }) 527 + } 528 + 529 + // --------------------------------------------------------------------------- 530 + // Space Invites 531 + // --------------------------------------------------------------------------- 532 + 533 + pub async fn create_invite( 534 + pool: &sqlx::AnyPool, 535 + backend: DatabaseBackend, 536 + invite: &SpaceInvite, 537 + ) -> Result<(), AppError> { 538 + let now = now_rfc3339(); 539 + let sql = adapt_sql( 540 + "INSERT INTO space_invites (id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", 541 + backend, 542 + ); 543 + 544 + sqlx::query(&sql) 545 + .bind(&invite.id) 546 + .bind(&invite.space_id) 547 + .bind(&invite.token_hash) 548 + .bind(&invite.created_by) 549 + .bind(invite.access.as_str()) 550 + .bind(invite.max_uses) 551 + .bind(invite.uses) 552 + .bind(&invite.expires_at) 553 + .bind(invite.revoked as i32) 554 + .bind(&now) 555 + .execute(pool) 556 + .await 557 + .map_err(|e| AppError::Internal(format!("failed to create invite: {e}")))?; 558 + 559 + Ok(()) 560 + } 561 + 562 + pub async fn get_invite_by_token_hash( 563 + pool: &sqlx::AnyPool, 564 + backend: DatabaseBackend, 565 + token_hash: &str, 566 + ) -> Result<Option<SpaceInvite>, AppError> { 567 + let sql = adapt_sql( 568 + "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM space_invites WHERE token_hash = ?", 569 + backend, 570 + ); 571 + 572 + let row: Option<InviteRow> = sqlx::query_as(&sql) 573 + .bind(token_hash) 574 + .fetch_optional(pool) 575 + .await 576 + .map_err(|e| AppError::Internal(format!("failed to get invite: {e}")))?; 577 + 578 + row.map(parse_invite_row).transpose() 579 + } 580 + 581 + pub async fn increment_invite_uses( 582 + pool: &sqlx::AnyPool, 583 + backend: DatabaseBackend, 584 + invite_id: &str, 585 + ) -> Result<(), AppError> { 586 + let sql = adapt_sql( 587 + "UPDATE space_invites SET uses = uses + 1 WHERE id = ?", 588 + backend, 589 + ); 590 + 591 + sqlx::query(&sql) 592 + .bind(invite_id) 593 + .execute(pool) 594 + .await 595 + .map_err(|e| AppError::Internal(format!("failed to increment invite uses: {e}")))?; 596 + 597 + Ok(()) 598 + } 599 + 600 + pub async fn revoke_invite( 601 + pool: &sqlx::AnyPool, 602 + backend: DatabaseBackend, 603 + invite_id: &str, 604 + ) -> Result<bool, AppError> { 605 + let sql = adapt_sql("UPDATE space_invites SET revoked = 1 WHERE id = ?", backend); 606 + 607 + let result = sqlx::query(&sql) 608 + .bind(invite_id) 609 + .execute(pool) 610 + .await 611 + .map_err(|e| AppError::Internal(format!("failed to revoke invite: {e}")))?; 612 + 613 + Ok(result.rows_affected() > 0) 614 + } 615 + 616 + pub async fn list_invites( 617 + pool: &sqlx::AnyPool, 618 + backend: DatabaseBackend, 619 + space_id: &str, 620 + ) -> Result<Vec<SpaceInvite>, AppError> { 621 + let sql = adapt_sql( 622 + "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM space_invites WHERE space_id = ? ORDER BY created_at DESC", 623 + backend, 624 + ); 625 + 626 + let rows: Vec<InviteRow> = sqlx::query_as(&sql) 627 + .bind(space_id) 628 + .fetch_all(pool) 629 + .await 630 + .map_err(|e| AppError::Internal(format!("failed to list invites: {e}")))?; 631 + 632 + rows.into_iter().map(parse_invite_row).collect() 633 + } 634 + 635 + type InviteRow = ( 636 + String, 637 + String, 638 + String, 639 + String, 640 + String, 641 + Option<i64>, 642 + i64, 643 + Option<String>, 644 + i32, 645 + String, 646 + ); 647 + 648 + fn parse_invite_row(r: InviteRow) -> Result<SpaceInvite, AppError> { 649 + let access = SpaceAccess::parse(&r.4) 650 + .ok_or_else(|| AppError::Internal(format!("invalid invite access: {}", r.4)))?; 651 + 652 + Ok(SpaceInvite { 653 + id: r.0, 654 + space_id: r.1, 655 + token_hash: r.2, 656 + created_by: r.3, 657 + access, 658 + max_uses: r.5, 659 + uses: r.6, 660 + expires_at: r.7, 661 + revoked: r.8 != 0, 662 + created_at: r.9, 663 + }) 664 + } 665 + 666 + // --------------------------------------------------------------------------- 667 + // Space Sync State 668 + // --------------------------------------------------------------------------- 669 + 670 + pub async fn get_sync_state( 671 + pool: &sqlx::AnyPool, 672 + backend: DatabaseBackend, 673 + space_id: &str, 674 + member_did: &str, 675 + ) -> Result<Option<SpaceSyncState>, AppError> { 676 + let sql = adapt_sql( 677 + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE space_id = ? AND member_did = ?", 678 + backend, 679 + ); 680 + 681 + let row: Option<SyncStateRow> = sqlx::query_as(&sql) 682 + .bind(space_id) 683 + .bind(member_did) 684 + .fetch_optional(pool) 685 + .await 686 + .map_err(|e| AppError::Internal(format!("failed to get sync state: {e}")))?; 687 + 688 + row.map(parse_sync_state_row).transpose() 689 + } 690 + 691 + pub async fn upsert_sync_state( 692 + pool: &sqlx::AnyPool, 693 + backend: DatabaseBackend, 694 + state: &SpaceSyncState, 695 + ) -> Result<(), AppError> { 696 + let sql = match backend { 697 + DatabaseBackend::Sqlite => { 698 + "INSERT OR REPLACE INTO space_sync_state (id, space_id, member_did, cursor, last_synced_at, status, error) VALUES (?, ?, ?, ?, ?, ?, ?)".to_string() 699 + } 700 + DatabaseBackend::Postgres => adapt_sql( 701 + "INSERT INTO space_sync_state (id, space_id, member_did, cursor, last_synced_at, status, error) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (space_id, member_did) DO UPDATE SET cursor = EXCLUDED.cursor, last_synced_at = EXCLUDED.last_synced_at, status = EXCLUDED.status, error = EXCLUDED.error", 702 + backend, 703 + ), 704 + }; 705 + 706 + sqlx::query(&sql) 707 + .bind(&state.id) 708 + .bind(&state.space_id) 709 + .bind(&state.member_did) 710 + .bind(&state.cursor) 711 + .bind(&state.last_synced_at) 712 + .bind(state.status.as_str()) 713 + .bind(&state.error) 714 + .execute(pool) 715 + .await 716 + .map_err(|e| AppError::Internal(format!("failed to upsert sync state: {e}")))?; 717 + 718 + Ok(()) 719 + } 720 + 721 + pub async fn list_sync_states_for_space( 722 + pool: &sqlx::AnyPool, 723 + backend: DatabaseBackend, 724 + space_id: &str, 725 + ) -> Result<Vec<SpaceSyncState>, AppError> { 726 + let sql = adapt_sql( 727 + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE space_id = ? ORDER BY member_did ASC", 728 + backend, 729 + ); 730 + 731 + let rows: Vec<SyncStateRow> = sqlx::query_as(&sql) 732 + .bind(space_id) 733 + .fetch_all(pool) 734 + .await 735 + .map_err(|e| AppError::Internal(format!("failed to list sync states: {e}")))?; 736 + 737 + rows.into_iter().map(parse_sync_state_row).collect() 738 + } 739 + 740 + pub async fn list_pending_syncs( 741 + pool: &sqlx::AnyPool, 742 + backend: DatabaseBackend, 743 + ) -> Result<Vec<SpaceSyncState>, AppError> { 744 + let sql = adapt_sql( 745 + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE status = 'pending' OR status = 'error' ORDER BY last_synced_at ASC NULLS FIRST LIMIT 50", 746 + backend, 747 + ); 748 + 749 + let rows: Vec<SyncStateRow> = sqlx::query_as(&sql) 750 + .fetch_all(pool) 751 + .await 752 + .map_err(|e| AppError::Internal(format!("failed to list pending syncs: {e}")))?; 753 + 754 + rows.into_iter().map(parse_sync_state_row).collect() 755 + } 756 + 757 + type SyncStateRow = ( 758 + String, 759 + String, 760 + String, 761 + Option<String>, 762 + Option<String>, 763 + String, 764 + Option<String>, 765 + ); 766 + 767 + fn parse_sync_state_row(r: SyncStateRow) -> Result<SpaceSyncState, AppError> { 768 + let status = SyncStatus::parse(&r.5) 769 + .ok_or_else(|| AppError::Internal(format!("invalid sync status: {}", r.5)))?; 770 + 771 + Ok(SpaceSyncState { 772 + id: r.0, 773 + space_id: r.1, 774 + member_did: r.2, 775 + cursor: r.3, 776 + last_synced_at: r.4, 777 + status, 778 + error: r.6, 779 + }) 780 + }
+142
src/spaces/members.rs
··· 1 + use std::collections::{HashMap, HashSet}; 2 + 3 + use crate::db::DatabaseBackend; 4 + use crate::error::AppError; 5 + use crate::spaces::SpaceUri; 6 + use crate::spaces::db; 7 + use crate::spaces::types::{ResolvedMember, SpaceAccess, SpaceMember}; 8 + 9 + const MAX_DELEGATION_DEPTH: usize = 10; 10 + 11 + /// Resolve the full member list for a space, traversing delegation references. 12 + /// 13 + /// When a space delegates to another space (is_delegation=true), the delegated 14 + /// space's members are included in the result. If both a direct membership and 15 + /// a delegated membership exist for the same DID, the higher access level wins 16 + /// (write > read). 17 + pub async fn resolve_members( 18 + pool: &sqlx::AnyPool, 19 + backend: DatabaseBackend, 20 + space_id: &str, 21 + ) -> Result<Vec<ResolvedMember>, AppError> { 22 + let mut resolved: HashMap<String, SpaceAccess> = HashMap::new(); 23 + let mut visited: HashSet<String> = HashSet::new(); 24 + 25 + resolve_members_recursive(pool, backend, space_id, &mut resolved, &mut visited, 0).await?; 26 + 27 + let mut members: Vec<ResolvedMember> = resolved 28 + .into_iter() 29 + .map(|(did, access)| ResolvedMember { did, access }) 30 + .collect(); 31 + members.sort_by(|a, b| a.did.cmp(&b.did)); 32 + Ok(members) 33 + } 34 + 35 + /// Check if a DID is a member of a space (resolving delegations). 36 + pub async fn is_member( 37 + pool: &sqlx::AnyPool, 38 + backend: DatabaseBackend, 39 + space_id: &str, 40 + did: &str, 41 + ) -> Result<Option<SpaceAccess>, AppError> { 42 + let members = resolve_members(pool, backend, space_id).await?; 43 + Ok(members.into_iter().find(|m| m.did == did).map(|m| m.access)) 44 + } 45 + 46 + fn resolve_members_recursive<'a>( 47 + pool: &'a sqlx::AnyPool, 48 + backend: DatabaseBackend, 49 + space_id: &'a str, 50 + resolved: &'a mut HashMap<String, SpaceAccess>, 51 + visited: &'a mut HashSet<String>, 52 + depth: usize, 53 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), AppError>> + Send + 'a>> { 54 + Box::pin(async move { 55 + if depth >= MAX_DELEGATION_DEPTH { 56 + return Ok(()); 57 + } 58 + 59 + if !visited.insert(space_id.to_string()) { 60 + return Ok(()); 61 + } 62 + 63 + let direct_members = db::list_direct_members(pool, backend, space_id).await?; 64 + 65 + for member in direct_members { 66 + if member.is_delegation { 67 + let delegated_space_id = resolve_delegation_target(pool, backend, &member).await?; 68 + if let Some(target_id) = delegated_space_id { 69 + resolve_members_recursive( 70 + pool, 71 + backend, 72 + &target_id, 73 + resolved, 74 + visited, 75 + depth + 1, 76 + ) 77 + .await?; 78 + } 79 + } else { 80 + merge_access(resolved, &member.member_did, member.access); 81 + } 82 + } 83 + 84 + Ok(()) 85 + }) 86 + } 87 + 88 + /// Resolve a delegation member entry to the target space ID. 89 + /// 90 + /// Delegation entries store either an ats:// URI or a space ID directly. 91 + async fn resolve_delegation_target( 92 + pool: &sqlx::AnyPool, 93 + backend: DatabaseBackend, 94 + member: &SpaceMember, 95 + ) -> Result<Option<String>, AppError> { 96 + if member.member_did.starts_with("ats://") { 97 + let uri = SpaceUri::parse(&member.member_did)?; 98 + let space = 99 + db::get_space_by_address(pool, backend, &uri.owner_did, &uri.type_nsid, &uri.skey) 100 + .await?; 101 + Ok(space.map(|s| s.id)) 102 + } else { 103 + let space = db::get_space(pool, backend, &member.member_did).await?; 104 + Ok(space.map(|s| s.id)) 105 + } 106 + } 107 + 108 + fn merge_access(resolved: &mut HashMap<String, SpaceAccess>, did: &str, access: SpaceAccess) { 109 + let entry = resolved.entry(did.to_string()).or_insert(SpaceAccess::Read); 110 + if access.can_write() { 111 + *entry = SpaceAccess::Write; 112 + } 113 + } 114 + 115 + #[cfg(test)] 116 + mod tests { 117 + use super::*; 118 + 119 + #[test] 120 + fn merge_access_write_wins() { 121 + let mut map = HashMap::new(); 122 + merge_access(&mut map, "did:plc:user1", SpaceAccess::Read); 123 + assert_eq!(map["did:plc:user1"], SpaceAccess::Read); 124 + 125 + merge_access(&mut map, "did:plc:user1", SpaceAccess::Write); 126 + assert_eq!(map["did:plc:user1"], SpaceAccess::Write); 127 + 128 + // Write should not be downgraded to Read 129 + merge_access(&mut map, "did:plc:user1", SpaceAccess::Read); 130 + assert_eq!(map["did:plc:user1"], SpaceAccess::Write); 131 + } 132 + 133 + #[test] 134 + fn merge_access_multiple_users() { 135 + let mut map = HashMap::new(); 136 + merge_access(&mut map, "did:plc:alice", SpaceAccess::Write); 137 + merge_access(&mut map, "did:plc:bob", SpaceAccess::Read); 138 + assert_eq!(map.len(), 2); 139 + assert_eq!(map["did:plc:alice"], SpaceAccess::Write); 140 + assert_eq!(map["did:plc:bob"], SpaceAccess::Read); 141 + } 142 + }
+212
src/spaces/mod.rs
··· 1 + pub mod auth; 2 + pub mod credential; 3 + pub mod db; 4 + pub mod members; 5 + pub mod notifications; 6 + pub mod routes; 7 + pub mod sync; 8 + pub mod types; 9 + 10 + use crate::error::AppError; 11 + use std::fmt; 12 + 13 + /// A parsed `ats://` URI for addressing permissioned data. 14 + /// 15 + /// Full form: `ats://<space-owner-did>/<space-type-nsid>/<skey>/<user-did>/<collection-nsid>/<rkey>` 16 + /// Space-only form: `ats://<space-owner-did>/<space-type-nsid>/<skey>` 17 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 18 + pub struct SpaceUri { 19 + pub owner_did: String, 20 + pub type_nsid: String, 21 + pub skey: String, 22 + pub user_did: Option<String>, 23 + pub collection: Option<String>, 24 + pub rkey: Option<String>, 25 + } 26 + 27 + impl SpaceUri { 28 + pub fn parse(uri: &str) -> Result<Self, AppError> { 29 + let stripped = uri 30 + .strip_prefix("ats://") 31 + .ok_or_else(|| AppError::BadRequest("SpaceUri must start with ats://".into()))?; 32 + 33 + let parts: Vec<&str> = stripped.split('/').collect(); 34 + 35 + if parts.len() < 3 { 36 + return Err(AppError::BadRequest( 37 + "SpaceUri requires at least owner_did/type_nsid/skey".into(), 38 + )); 39 + } 40 + 41 + if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { 42 + return Err(AppError::BadRequest( 43 + "SpaceUri components must not be empty".into(), 44 + )); 45 + } 46 + 47 + let owner_did = parts[0].to_string(); 48 + let type_nsid = parts[1].to_string(); 49 + let skey = parts[2].to_string(); 50 + 51 + let (user_did, collection, rkey) = if parts.len() >= 6 { 52 + ( 53 + Some(parts[3].to_string()), 54 + Some(parts[4].to_string()), 55 + Some(parts[5].to_string()), 56 + ) 57 + } else if parts.len() == 3 { 58 + (None, None, None) 59 + } else { 60 + return Err(AppError::BadRequest( 61 + "SpaceUri must have 3 components (space) or 6 components (record)".into(), 62 + )); 63 + }; 64 + 65 + Ok(SpaceUri { 66 + owner_did, 67 + type_nsid, 68 + skey, 69 + user_did, 70 + collection, 71 + rkey, 72 + }) 73 + } 74 + 75 + pub fn space_uri(&self) -> String { 76 + format!("ats://{}/{}/{}", self.owner_did, self.type_nsid, self.skey) 77 + } 78 + 79 + pub fn is_record_uri(&self) -> bool { 80 + self.user_did.is_some() 81 + } 82 + 83 + pub fn is_space_uri(&self) -> bool { 84 + self.user_did.is_none() 85 + } 86 + } 87 + 88 + impl fmt::Display for SpaceUri { 89 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 90 + write!( 91 + f, 92 + "ats://{}/{}/{}", 93 + self.owner_did, self.type_nsid, self.skey 94 + )?; 95 + if let (Some(user), Some(col), Some(rkey)) = (&self.user_did, &self.collection, &self.rkey) 96 + { 97 + write!(f, "/{}/{}/{}", user, col, rkey)?; 98 + } 99 + Ok(()) 100 + } 101 + } 102 + 103 + #[cfg(test)] 104 + mod tests { 105 + use super::*; 106 + 107 + #[test] 108 + fn parse_space_uri() { 109 + let uri = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main").unwrap(); 110 + assert_eq!(uri.owner_did, "did:plc:abc123"); 111 + assert_eq!(uri.type_nsid, "com.example.forum"); 112 + assert_eq!(uri.skey, "main"); 113 + assert!(uri.is_space_uri()); 114 + assert!(!uri.is_record_uri()); 115 + assert_eq!(uri.user_did, None); 116 + } 117 + 118 + #[test] 119 + fn parse_record_uri() { 120 + let uri = SpaceUri::parse( 121 + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", 122 + ) 123 + .unwrap(); 124 + assert_eq!(uri.owner_did, "did:plc:abc123"); 125 + assert_eq!(uri.type_nsid, "com.example.forum"); 126 + assert_eq!(uri.skey, "main"); 127 + assert_eq!(uri.user_did.as_deref(), Some("did:plc:user1")); 128 + assert_eq!(uri.collection.as_deref(), Some("com.example.forum.post")); 129 + assert_eq!(uri.rkey.as_deref(), Some("3k2abc")); 130 + assert!(uri.is_record_uri()); 131 + assert!(!uri.is_space_uri()); 132 + } 133 + 134 + #[test] 135 + fn display_space_uri() { 136 + let uri = SpaceUri { 137 + owner_did: "did:plc:abc123".into(), 138 + type_nsid: "com.example.forum".into(), 139 + skey: "main".into(), 140 + user_did: None, 141 + collection: None, 142 + rkey: None, 143 + }; 144 + assert_eq!( 145 + uri.to_string(), 146 + "ats://did:plc:abc123/com.example.forum/main" 147 + ); 148 + } 149 + 150 + #[test] 151 + fn display_record_uri() { 152 + let uri = SpaceUri { 153 + owner_did: "did:plc:abc123".into(), 154 + type_nsid: "com.example.forum".into(), 155 + skey: "main".into(), 156 + user_did: Some("did:plc:user1".into()), 157 + collection: Some("com.example.forum.post".into()), 158 + rkey: Some("3k2abc".into()), 159 + }; 160 + assert_eq!( 161 + uri.to_string(), 162 + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc" 163 + ); 164 + } 165 + 166 + #[test] 167 + fn space_uri_extracts_space_part() { 168 + let uri = SpaceUri::parse( 169 + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", 170 + ) 171 + .unwrap(); 172 + assert_eq!( 173 + uri.space_uri(), 174 + "ats://did:plc:abc123/com.example.forum/main" 175 + ); 176 + } 177 + 178 + #[test] 179 + fn reject_at_scheme() { 180 + let result = SpaceUri::parse("at://did:plc:abc123/com.example.forum/main"); 181 + assert!(result.is_err()); 182 + } 183 + 184 + #[test] 185 + fn reject_too_few_components() { 186 + let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum"); 187 + assert!(result.is_err()); 188 + } 189 + 190 + #[test] 191 + fn reject_wrong_component_count() { 192 + let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main/did:plc:user1"); 193 + assert!(result.is_err()); 194 + } 195 + 196 + #[test] 197 + fn reject_empty_components() { 198 + let result = SpaceUri::parse("ats:///com.example.forum/main"); 199 + assert!(result.is_err()); 200 + } 201 + 202 + #[test] 203 + fn roundtrip_parse_display() { 204 + let original = "ats://did:plc:abc123/com.example.forum/main"; 205 + let uri = SpaceUri::parse(original).unwrap(); 206 + assert_eq!(uri.to_string(), original); 207 + 208 + let original_record = "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc"; 209 + let uri = SpaceUri::parse(original_record).unwrap(); 210 + assert_eq!(uri.to_string(), original_record); 211 + } 212 + }
+86
src/spaces/notifications.rs
··· 1 + use serde::Deserialize; 2 + use uuid::Uuid; 3 + 4 + use crate::db::DatabaseBackend; 5 + use crate::error::AppError; 6 + use crate::spaces::db; 7 + use crate::spaces::types::*; 8 + 9 + #[derive(Debug, Deserialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct WriteNotification { 12 + pub space_uri: String, 13 + pub author_did: String, 14 + pub collection: String, 15 + pub rkey: String, 16 + pub action: WriteAction, 17 + } 18 + 19 + #[derive(Debug, Deserialize)] 20 + #[serde(rename_all = "lowercase")] 21 + pub enum WriteAction { 22 + Create, 23 + Update, 24 + Delete, 25 + } 26 + 27 + /// Process a write notification by queuing a sync pull for the affected member. 28 + /// 29 + /// This marks the member's sync state as pending so the next sync pass picks it up. 30 + pub async fn handle_write_notification( 31 + pool: &sqlx::AnyPool, 32 + backend: DatabaseBackend, 33 + space_id: &str, 34 + notification: &WriteNotification, 35 + ) -> Result<(), AppError> { 36 + let existing = db::get_sync_state(pool, backend, space_id, &notification.author_did).await?; 37 + 38 + let state = SpaceSyncState { 39 + id: existing 40 + .map(|s| s.id) 41 + .unwrap_or_else(|| Uuid::new_v4().to_string()), 42 + space_id: space_id.to_string(), 43 + member_did: notification.author_did.clone(), 44 + cursor: None, 45 + last_synced_at: None, 46 + status: SyncStatus::Pending, 47 + error: None, 48 + }; 49 + 50 + db::upsert_sync_state(pool, backend, &state).await?; 51 + 52 + Ok(()) 53 + } 54 + 55 + #[cfg(test)] 56 + mod tests { 57 + use super::*; 58 + 59 + #[test] 60 + fn write_action_deserializes() { 61 + let action: WriteAction = serde_json::from_str("\"create\"").unwrap(); 62 + assert!(matches!(action, WriteAction::Create)); 63 + 64 + let action: WriteAction = serde_json::from_str("\"update\"").unwrap(); 65 + assert!(matches!(action, WriteAction::Update)); 66 + 67 + let action: WriteAction = serde_json::from_str("\"delete\"").unwrap(); 68 + assert!(matches!(action, WriteAction::Delete)); 69 + } 70 + 71 + #[test] 72 + fn write_notification_deserializes() { 73 + let json = r#"{ 74 + "spaceUri": "ats://did:plc:owner/com.example.forum/main", 75 + "authorDid": "did:plc:alice", 76 + "collection": "com.example.forum.post", 77 + "rkey": "3k2abc", 78 + "action": "create" 79 + }"#; 80 + 81 + let notif: WriteNotification = serde_json::from_str(json).unwrap(); 82 + assert_eq!(notif.author_did, "did:plc:alice"); 83 + assert_eq!(notif.collection, "com.example.forum.post"); 84 + assert!(matches!(notif.action, WriteAction::Create)); 85 + } 86 + }
+982
src/spaces/routes.rs
··· 1 + use axum::extract::{Query, State}; 2 + use axum::http::{HeaderMap, StatusCode}; 3 + use axum::response::{IntoResponse, Response}; 4 + use axum::routing::{get, post}; 5 + use axum::{Json, Router}; 6 + use serde::Deserialize; 7 + use sha2::{Digest, Sha256}; 8 + use uuid::Uuid; 9 + 10 + use crate::AppState; 11 + use crate::auth::XrpcClaims; 12 + use crate::db::{adapt_sql, now_rfc3339}; 13 + use crate::error::AppError; 14 + use crate::spaces::types::*; 15 + use crate::spaces::{SpaceUri, db, members}; 16 + 17 + // --------------------------------------------------------------------------- 18 + // Request / response types 19 + // --------------------------------------------------------------------------- 20 + 21 + #[derive(Deserialize)] 22 + #[serde(rename_all = "camelCase")] 23 + struct CreateSpaceInput { 24 + type_nsid: String, 25 + skey: String, 26 + display_name: Option<String>, 27 + description: Option<String>, 28 + access_mode: Option<AccessMode>, 29 + managing_app_did: Option<String>, 30 + config: Option<SpaceConfig>, 31 + } 32 + 33 + #[derive(Deserialize)] 34 + #[serde(rename_all = "camelCase")] 35 + struct SpaceUriQuery { 36 + space_uri: String, 37 + } 38 + 39 + #[derive(Deserialize)] 40 + #[serde(rename_all = "camelCase")] 41 + struct ListSpacesQuery { 42 + owner_did: Option<String>, 43 + } 44 + 45 + #[derive(Deserialize)] 46 + #[serde(rename_all = "camelCase")] 47 + struct DeleteSpaceInput { 48 + space_uri: String, 49 + } 50 + 51 + #[derive(Deserialize)] 52 + #[serde(rename_all = "camelCase")] 53 + struct UpdateSpaceInput { 54 + space_uri: String, 55 + display_name: Option<Option<String>>, 56 + description: Option<Option<String>>, 57 + access_mode: Option<AccessMode>, 58 + app_allowlist: Option<Option<Vec<String>>>, 59 + app_denylist: Option<Option<Vec<String>>>, 60 + managing_app_did: Option<Option<String>>, 61 + config: Option<SpaceConfig>, 62 + } 63 + 64 + #[derive(Deserialize)] 65 + #[serde(rename_all = "camelCase")] 66 + struct PutRecordInput { 67 + space_uri: String, 68 + collection: String, 69 + rkey: String, 70 + record: serde_json::Value, 71 + } 72 + 73 + #[derive(Deserialize)] 74 + #[serde(rename_all = "camelCase")] 75 + struct DeleteRecordInput { 76 + space_uri: String, 77 + collection: String, 78 + rkey: String, 79 + } 80 + 81 + #[derive(Deserialize)] 82 + #[serde(rename_all = "camelCase")] 83 + struct GetRecordQuery { 84 + space_uri: String, 85 + collection: String, 86 + rkey: String, 87 + } 88 + 89 + #[derive(Deserialize)] 90 + #[serde(rename_all = "camelCase")] 91 + struct ListRecordsQuery { 92 + space_uri: String, 93 + collection: Option<String>, 94 + limit: Option<i64>, 95 + cursor: Option<String>, 96 + } 97 + 98 + #[derive(Deserialize)] 99 + #[serde(rename_all = "camelCase")] 100 + struct AddMemberInput { 101 + space_uri: String, 102 + member_did: String, 103 + access: Option<SpaceAccess>, 104 + is_delegation: Option<bool>, 105 + } 106 + 107 + #[derive(Deserialize)] 108 + #[serde(rename_all = "camelCase")] 109 + struct RemoveMemberInput { 110 + space_uri: String, 111 + member_did: String, 112 + } 113 + 114 + #[derive(Deserialize)] 115 + #[serde(rename_all = "camelCase")] 116 + struct CreateInviteInput { 117 + space_uri: String, 118 + access: Option<SpaceAccess>, 119 + max_uses: Option<i64>, 120 + expires_at: Option<String>, 121 + } 122 + 123 + #[derive(Deserialize)] 124 + #[serde(rename_all = "camelCase")] 125 + struct RedeemInviteInput { 126 + token: String, 127 + } 128 + 129 + #[derive(Deserialize)] 130 + #[serde(rename_all = "camelCase")] 131 + struct RevokeInviteInput { 132 + space_uri: String, 133 + invite_id: String, 134 + } 135 + 136 + #[derive(Deserialize)] 137 + #[serde(rename_all = "camelCase")] 138 + struct GetCredentialInput { 139 + space_uri: String, 140 + } 141 + 142 + #[derive(Deserialize)] 143 + #[serde(rename_all = "camelCase")] 144 + struct RefreshCredentialInput { 145 + space_uri: String, 146 + credential: String, 147 + } 148 + 149 + #[derive(Deserialize)] 150 + #[serde(rename_all = "camelCase")] 151 + struct WriteNotificationInput { 152 + space_uri: String, 153 + author_did: String, 154 + collection: String, 155 + rkey: String, 156 + action: crate::spaces::notifications::WriteAction, 157 + } 158 + 159 + // --------------------------------------------------------------------------- 160 + // Route registration 161 + // --------------------------------------------------------------------------- 162 + 163 + const NS: &str = "dev.happyview"; 164 + 165 + pub fn space_routes() -> Router<AppState> { 166 + Router::new() 167 + // Space CRUD 168 + .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) 169 + .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) 170 + .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) 171 + .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) 172 + .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) 173 + // Records 174 + .route(&format!("/xrpc/{NS}.space.putRecord"), post(put_record)) 175 + .route( 176 + &format!("/xrpc/{NS}.space.deleteRecord"), 177 + post(delete_record), 178 + ) 179 + .route(&format!("/xrpc/{NS}.space.getRecord"), get(get_record)) 180 + .route(&format!("/xrpc/{NS}.space.listRecords"), get(list_records)) 181 + // Members 182 + .route(&format!("/xrpc/{NS}.space.listMembers"), get(list_members)) 183 + .route(&format!("/xrpc/{NS}.space.addMember"), post(add_member)) 184 + .route( 185 + &format!("/xrpc/{NS}.space.removeMember"), 186 + post(remove_member), 187 + ) 188 + // Invites 189 + .route( 190 + &format!("/xrpc/{NS}.space.invite.create"), 191 + post(create_invite), 192 + ) 193 + .route( 194 + &format!("/xrpc/{NS}.space.invite.redeem"), 195 + post(redeem_invite), 196 + ) 197 + .route( 198 + &format!("/xrpc/{NS}.space.invite.revoke"), 199 + post(revoke_invite), 200 + ) 201 + .route(&format!("/xrpc/{NS}.space.invite.list"), get(list_invites)) 202 + // Credentials 203 + .route( 204 + &format!("/xrpc/{NS}.space.getCredential"), 205 + post(get_credential), 206 + ) 207 + .route( 208 + &format!("/xrpc/{NS}.space.refreshCredential"), 209 + post(refresh_credential), 210 + ) 211 + // Notifications 212 + .route( 213 + &format!("/xrpc/{NS}.space.writeNotification"), 214 + post(write_notification), 215 + ) 216 + } 217 + 218 + // --------------------------------------------------------------------------- 219 + // Helpers 220 + // --------------------------------------------------------------------------- 221 + 222 + fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { 223 + claims 224 + .0 225 + .as_ref() 226 + .ok_or_else(|| AppError::Auth("This endpoint requires DPoP authentication".into())) 227 + } 228 + 229 + async fn resolve_space(state: &AppState, space_uri: &str) -> Result<Space, AppError> { 230 + let uri = SpaceUri::parse(space_uri)?; 231 + db::get_space_by_address( 232 + &state.db, 233 + state.db_backend, 234 + &uri.owner_did, 235 + &uri.type_nsid, 236 + &uri.skey, 237 + ) 238 + .await? 239 + .ok_or_else(|| AppError::NotFound("Space not found".into())) 240 + } 241 + 242 + async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Result<(), AppError> { 243 + if space.owner_did == did { 244 + return Ok(()); 245 + } 246 + let sql = adapt_sql("SELECT is_super FROM users WHERE did = ?", state.db_backend); 247 + let row: Option<(i32,)> = sqlx::query_as(&sql) 248 + .bind(did) 249 + .fetch_optional(&state.db) 250 + .await 251 + .map_err(|e| AppError::Internal(format!("failed to check admin status: {e}")))?; 252 + if row.is_some_and(|(is_super,)| is_super != 0) { 253 + return Ok(()); 254 + } 255 + Err(AppError::Forbidden( 256 + "Only the space owner can perform this action".into(), 257 + )) 258 + } 259 + 260 + fn extract_space_credential(headers: &HeaderMap) -> Option<String> { 261 + headers 262 + .get("x-space-credential") 263 + .and_then(|v| v.to_str().ok()) 264 + .map(|s| s.to_string()) 265 + } 266 + 267 + async fn require_membership( 268 + state: &AppState, 269 + space: &Space, 270 + did: &str, 271 + require_write: bool, 272 + space_credential: Option<&str>, 273 + ) -> Result<SpaceAccess, AppError> { 274 + if let Some(token) = space_credential { 275 + let space_uri = format!( 276 + "ats://{}/{}/{}", 277 + space.owner_did, space.type_nsid, space.skey 278 + ); 279 + match crate::spaces::credential::verify_external_credential( 280 + token, 281 + &state.http, 282 + &state.config.plc_url, 283 + ) 284 + .await 285 + { 286 + Ok(claims) if claims.space == space_uri => { 287 + let access = match claims.scope.as_str() { 288 + "write" => SpaceAccess::Write, 289 + _ => SpaceAccess::Read, 290 + }; 291 + if require_write && !access.can_write() { 292 + return Err(AppError::Forbidden( 293 + "Write access is required for this action".into(), 294 + )); 295 + } 296 + return Ok(access); 297 + } 298 + Ok(_) => { 299 + // Credential is valid but for a different space — fall through 300 + } 301 + Err(_) => { 302 + // External verification failed — fall through to local check 303 + } 304 + } 305 + } 306 + 307 + let access = members::is_member(&state.db, state.db_backend, &space.id, did) 308 + .await? 309 + .ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; 310 + if require_write && !access.can_write() { 311 + return Err(AppError::Forbidden( 312 + "Write access is required for this action".into(), 313 + )); 314 + } 315 + Ok(access) 316 + } 317 + 318 + fn content_cid(record: &serde_json::Value) -> String { 319 + let bytes = serde_json::to_vec(record).unwrap_or_default(); 320 + let hash = Sha256::digest(&bytes); 321 + format!("bafyrei{}", hex::encode(&hash[..20])) 322 + } 323 + 324 + // --------------------------------------------------------------------------- 325 + // Space CRUD handlers 326 + // --------------------------------------------------------------------------- 327 + 328 + async fn create_space( 329 + State(state): State<AppState>, 330 + xrpc_claims: XrpcClaims, 331 + Json(input): Json<CreateSpaceInput>, 332 + ) -> Result<Response, AppError> { 333 + let claims = require_auth(&xrpc_claims)?; 334 + let did = claims.did().to_string(); 335 + 336 + if input.type_nsid.is_empty() || input.skey.is_empty() { 337 + return Err(AppError::BadRequest( 338 + "type_nsid and skey are required".into(), 339 + )); 340 + } 341 + 342 + let existing = db::get_space_by_address( 343 + &state.db, 344 + state.db_backend, 345 + &did, 346 + &input.type_nsid, 347 + &input.skey, 348 + ) 349 + .await?; 350 + if existing.is_some() { 351 + return Err(AppError::Conflict( 352 + "A space with this address already exists".into(), 353 + )); 354 + } 355 + 356 + let space = Space { 357 + id: Uuid::new_v4().to_string(), 358 + owner_did: did.clone(), 359 + type_nsid: input.type_nsid, 360 + skey: input.skey, 361 + display_name: input.display_name, 362 + description: input.description, 363 + access_mode: input.access_mode.unwrap_or(AccessMode::DefaultAllow), 364 + app_allowlist: None, 365 + app_denylist: None, 366 + managing_app_did: input.managing_app_did, 367 + config: input.config.unwrap_or_default(), 368 + created_at: now_rfc3339(), 369 + updated_at: now_rfc3339(), 370 + }; 371 + 372 + db::create_space(&state.db, state.db_backend, &space).await?; 373 + 374 + // Auto-add the creator as a write member 375 + let member = SpaceMember { 376 + id: Uuid::new_v4().to_string(), 377 + space_id: space.id.clone(), 378 + member_did: did.clone(), 379 + access: SpaceAccess::Write, 380 + is_delegation: false, 381 + granted_by: Some(did), 382 + created_at: now_rfc3339(), 383 + }; 384 + db::add_member(&state.db, state.db_backend, &member).await?; 385 + 386 + let space_uri = format!( 387 + "ats://{}/{}/{}", 388 + space.owner_did, space.type_nsid, space.skey 389 + ); 390 + let body = serde_json::json!({ 391 + "spaceUri": space_uri, 392 + "space": space, 393 + }); 394 + 395 + let mut response = Json(body).into_response(); 396 + *response.status_mut() = StatusCode::CREATED; 397 + Ok(response) 398 + } 399 + 400 + async fn get_space( 401 + State(state): State<AppState>, 402 + xrpc_claims: XrpcClaims, 403 + Query(query): Query<SpaceUriQuery>, 404 + ) -> Result<Json<serde_json::Value>, AppError> { 405 + let space = resolve_space(&state, &query.space_uri).await?; 406 + 407 + // If the space's membership is not public, require auth + membership 408 + if !space.config.membership_public { 409 + let claims = require_auth(&xrpc_claims)?; 410 + let did = claims.did(); 411 + if space.owner_did != did { 412 + members::is_member(&state.db, state.db_backend, &space.id, did) 413 + .await? 414 + .ok_or_else(|| AppError::NotFound("Space not found".into()))?; 415 + } 416 + } 417 + 418 + let space_uri = format!( 419 + "ats://{}/{}/{}", 420 + space.owner_did, space.type_nsid, space.skey 421 + ); 422 + Ok(Json(serde_json::json!({ 423 + "spaceUri": space_uri, 424 + "space": space, 425 + }))) 426 + } 427 + 428 + async fn list_spaces( 429 + State(state): State<AppState>, 430 + xrpc_claims: XrpcClaims, 431 + Query(query): Query<ListSpacesQuery>, 432 + ) -> Result<Json<serde_json::Value>, AppError> { 433 + let claims = require_auth(&xrpc_claims)?; 434 + let did = claims.did().to_string(); 435 + 436 + let owner = query.owner_did.as_deref().unwrap_or(&did); 437 + let spaces = db::list_spaces_by_owner(&state.db, state.db_backend, owner).await?; 438 + 439 + let spaces_with_uris: Vec<serde_json::Value> = spaces 440 + .into_iter() 441 + .map(|s| { 442 + let uri = format!("ats://{}/{}/{}", s.owner_did, s.type_nsid, s.skey); 443 + serde_json::json!({ "spaceUri": uri, "space": s }) 444 + }) 445 + .collect(); 446 + 447 + Ok(Json(serde_json::json!({ "spaces": spaces_with_uris }))) 448 + } 449 + 450 + async fn delete_space( 451 + State(state): State<AppState>, 452 + xrpc_claims: XrpcClaims, 453 + Json(input): Json<DeleteSpaceInput>, 454 + ) -> Result<Json<serde_json::Value>, AppError> { 455 + let claims = require_auth(&xrpc_claims)?; 456 + let space = resolve_space(&state, &input.space_uri).await?; 457 + require_space_admin(&state, &space, claims.did()).await?; 458 + 459 + db::delete_space(&state.db, state.db_backend, &space.id).await?; 460 + 461 + Ok(Json(serde_json::json!({ "success": true }))) 462 + } 463 + 464 + async fn update_space( 465 + State(state): State<AppState>, 466 + xrpc_claims: XrpcClaims, 467 + Json(input): Json<UpdateSpaceInput>, 468 + ) -> Result<Json<serde_json::Value>, AppError> { 469 + let claims = require_auth(&xrpc_claims)?; 470 + let mut space = resolve_space(&state, &input.space_uri).await?; 471 + require_space_admin(&state, &space, claims.did()).await?; 472 + 473 + if let Some(name) = input.display_name { 474 + space.display_name = name; 475 + } 476 + if let Some(desc) = input.description { 477 + space.description = desc; 478 + } 479 + if let Some(mode) = input.access_mode { 480 + space.access_mode = mode; 481 + } 482 + if let Some(list) = input.app_allowlist { 483 + space.app_allowlist = list; 484 + } 485 + if let Some(list) = input.app_denylist { 486 + space.app_denylist = list; 487 + } 488 + if let Some(did) = input.managing_app_did { 489 + space.managing_app_did = did; 490 + } 491 + if let Some(config) = input.config { 492 + space.config = config; 493 + } 494 + 495 + db::update_space(&state.db, state.db_backend, &space).await?; 496 + 497 + let space_uri = format!( 498 + "ats://{}/{}/{}", 499 + space.owner_did, space.type_nsid, space.skey 500 + ); 501 + Ok(Json(serde_json::json!({ 502 + "spaceUri": space_uri, 503 + "space": space, 504 + }))) 505 + } 506 + 507 + // --------------------------------------------------------------------------- 508 + // Record handlers 509 + // --------------------------------------------------------------------------- 510 + 511 + async fn put_record( 512 + State(state): State<AppState>, 513 + xrpc_claims: XrpcClaims, 514 + headers: HeaderMap, 515 + Json(input): Json<PutRecordInput>, 516 + ) -> Result<Response, AppError> { 517 + let claims = require_auth(&xrpc_claims)?; 518 + let did = claims.did().to_string(); 519 + let space = resolve_space(&state, &input.space_uri).await?; 520 + let cred = extract_space_credential(&headers); 521 + require_membership(&state, &space, &did, true, cred.as_deref()).await?; 522 + 523 + let cid = content_cid(&input.record); 524 + let record_uri = format!( 525 + "ats://{}/{}/{}/{}/{}/{}", 526 + space.owner_did, space.type_nsid, space.skey, did, input.collection, input.rkey 527 + ); 528 + 529 + let record = SpaceRecord { 530 + uri: record_uri.clone(), 531 + space_id: space.id, 532 + author_did: did, 533 + collection: input.collection, 534 + rkey: input.rkey, 535 + record: input.record, 536 + cid: cid.clone(), 537 + indexed_at: now_rfc3339(), 538 + }; 539 + 540 + db::upsert_space_record(&state.db, state.db_backend, &record).await?; 541 + 542 + let body = serde_json::json!({ 543 + "uri": record_uri, 544 + "cid": cid, 545 + }); 546 + 547 + let mut response = Json(body).into_response(); 548 + *response.status_mut() = StatusCode::CREATED; 549 + Ok(response) 550 + } 551 + 552 + async fn delete_record( 553 + State(state): State<AppState>, 554 + xrpc_claims: XrpcClaims, 555 + Json(input): Json<DeleteRecordInput>, 556 + ) -> Result<Json<serde_json::Value>, AppError> { 557 + let claims = require_auth(&xrpc_claims)?; 558 + let did = claims.did().to_string(); 559 + let space = resolve_space(&state, &input.space_uri).await?; 560 + 561 + let record_uri = format!( 562 + "ats://{}/{}/{}/{}/{}/{}", 563 + space.owner_did, space.type_nsid, space.skey, did, input.collection, input.rkey 564 + ); 565 + 566 + let record = db::get_space_record(&state.db, state.db_backend, &record_uri).await?; 567 + match record { 568 + Some(r) if r.author_did != did => { 569 + return Err(AppError::Forbidden( 570 + "You can only delete your own records".into(), 571 + )); 572 + } 573 + None => { 574 + return Err(AppError::NotFound("Record not found".into())); 575 + } 576 + _ => {} 577 + } 578 + 579 + db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; 580 + 581 + Ok(Json(serde_json::json!({ "success": true }))) 582 + } 583 + 584 + async fn get_record( 585 + State(state): State<AppState>, 586 + xrpc_claims: XrpcClaims, 587 + headers: HeaderMap, 588 + Query(query): Query<GetRecordQuery>, 589 + ) -> Result<Json<serde_json::Value>, AppError> { 590 + let claims = require_auth(&xrpc_claims)?; 591 + let space = resolve_space(&state, &query.space_uri).await?; 592 + let cred = extract_space_credential(&headers); 593 + require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; 594 + 595 + let record = db::get_space_record_by_parts( 596 + &state.db, 597 + state.db_backend, 598 + &space.id, 599 + &query.collection, 600 + &query.rkey, 601 + ) 602 + .await? 603 + .ok_or_else(|| AppError::NotFound("Record not found".into()))?; 604 + 605 + Ok(Json(serde_json::json!({ 606 + "uri": record.uri, 607 + "space": query.space_uri, 608 + "collection": record.collection, 609 + "record": record.record, 610 + "cid": record.cid, 611 + }))) 612 + } 613 + 614 + async fn list_records( 615 + State(state): State<AppState>, 616 + xrpc_claims: XrpcClaims, 617 + headers: HeaderMap, 618 + Query(query): Query<ListRecordsQuery>, 619 + ) -> Result<Json<serde_json::Value>, AppError> { 620 + let claims = require_auth(&xrpc_claims)?; 621 + let space = resolve_space(&state, &query.space_uri).await?; 622 + let cred = extract_space_credential(&headers); 623 + require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; 624 + 625 + let limit = query.limit.unwrap_or(50).min(100); 626 + let records = db::list_space_records( 627 + &state.db, 628 + state.db_backend, 629 + &space.id, 630 + query.collection.as_deref(), 631 + limit, 632 + query.cursor.as_deref(), 633 + ) 634 + .await?; 635 + 636 + let cursor = records.last().map(|r| r.indexed_at.clone()); 637 + 638 + let records_json: Vec<serde_json::Value> = records 639 + .into_iter() 640 + .map(|r| { 641 + serde_json::json!({ 642 + "uri": r.uri, 643 + "space": query.space_uri, 644 + "collection": r.collection, 645 + "record": r.record, 646 + "cid": r.cid, 647 + }) 648 + }) 649 + .collect(); 650 + 651 + Ok(Json(serde_json::json!({ 652 + "records": records_json, 653 + "cursor": cursor, 654 + }))) 655 + } 656 + 657 + // --------------------------------------------------------------------------- 658 + // Member handlers 659 + // --------------------------------------------------------------------------- 660 + 661 + async fn list_members( 662 + State(state): State<AppState>, 663 + xrpc_claims: XrpcClaims, 664 + headers: HeaderMap, 665 + Query(query): Query<SpaceUriQuery>, 666 + ) -> Result<Json<serde_json::Value>, AppError> { 667 + let space = resolve_space(&state, &query.space_uri).await?; 668 + 669 + if !space.config.membership_public { 670 + let claims = require_auth(&xrpc_claims)?; 671 + let cred = extract_space_credential(&headers); 672 + require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; 673 + } 674 + 675 + let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?; 676 + 677 + Ok(Json(serde_json::json!({ "members": resolved }))) 678 + } 679 + 680 + async fn add_member( 681 + State(state): State<AppState>, 682 + xrpc_claims: XrpcClaims, 683 + Json(input): Json<AddMemberInput>, 684 + ) -> Result<Response, AppError> { 685 + let claims = require_auth(&xrpc_claims)?; 686 + let space = resolve_space(&state, &input.space_uri).await?; 687 + require_space_admin(&state, &space, claims.did()).await?; 688 + 689 + let existing = 690 + db::get_member(&state.db, state.db_backend, &space.id, &input.member_did).await?; 691 + if existing.is_some() { 692 + return Err(AppError::Conflict( 693 + "Member already exists in this space".into(), 694 + )); 695 + } 696 + 697 + let member = SpaceMember { 698 + id: Uuid::new_v4().to_string(), 699 + space_id: space.id, 700 + member_did: input.member_did, 701 + access: input.access.unwrap_or(SpaceAccess::Read), 702 + is_delegation: input.is_delegation.unwrap_or(false), 703 + granted_by: Some(claims.did().to_string()), 704 + created_at: now_rfc3339(), 705 + }; 706 + 707 + db::add_member(&state.db, state.db_backend, &member).await?; 708 + 709 + let mut response = Json(serde_json::json!({ "member": member })).into_response(); 710 + *response.status_mut() = StatusCode::CREATED; 711 + Ok(response) 712 + } 713 + 714 + async fn remove_member( 715 + State(state): State<AppState>, 716 + xrpc_claims: XrpcClaims, 717 + Json(input): Json<RemoveMemberInput>, 718 + ) -> Result<Json<serde_json::Value>, AppError> { 719 + let claims = require_auth(&xrpc_claims)?; 720 + let space = resolve_space(&state, &input.space_uri).await?; 721 + require_space_admin(&state, &space, claims.did()).await?; 722 + 723 + let removed = 724 + db::remove_member(&state.db, state.db_backend, &space.id, &input.member_did).await?; 725 + 726 + if !removed { 727 + return Err(AppError::NotFound("Member not found in this space".into())); 728 + } 729 + 730 + Ok(Json(serde_json::json!({ "success": true }))) 731 + } 732 + 733 + // --------------------------------------------------------------------------- 734 + // Invite handlers 735 + // --------------------------------------------------------------------------- 736 + 737 + async fn create_invite( 738 + State(state): State<AppState>, 739 + xrpc_claims: XrpcClaims, 740 + Json(input): Json<CreateInviteInput>, 741 + ) -> Result<Response, AppError> { 742 + let claims = require_auth(&xrpc_claims)?; 743 + let space = resolve_space(&state, &input.space_uri).await?; 744 + require_space_admin(&state, &space, claims.did()).await?; 745 + 746 + let mut token_bytes = [0u8; 24]; 747 + rand::Fill::fill(&mut token_bytes, &mut rand::rng()); 748 + let token = hex::encode(token_bytes); 749 + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); 750 + 751 + let invite = SpaceInvite { 752 + id: Uuid::new_v4().to_string(), 753 + space_id: space.id, 754 + token_hash, 755 + created_by: claims.did().to_string(), 756 + access: input.access.unwrap_or(SpaceAccess::Read), 757 + max_uses: input.max_uses, 758 + uses: 0, 759 + expires_at: input.expires_at, 760 + revoked: false, 761 + created_at: now_rfc3339(), 762 + }; 763 + 764 + db::create_invite(&state.db, state.db_backend, &invite).await?; 765 + 766 + let mut response = Json(serde_json::json!({ 767 + "inviteId": invite.id, 768 + "token": token, 769 + "access": invite.access, 770 + "maxUses": invite.max_uses, 771 + "expiresAt": invite.expires_at, 772 + })) 773 + .into_response(); 774 + *response.status_mut() = StatusCode::CREATED; 775 + Ok(response) 776 + } 777 + 778 + async fn redeem_invite( 779 + State(state): State<AppState>, 780 + xrpc_claims: XrpcClaims, 781 + Json(input): Json<RedeemInviteInput>, 782 + ) -> Result<Response, AppError> { 783 + let claims = require_auth(&xrpc_claims)?; 784 + let did = claims.did().to_string(); 785 + 786 + let token_hash = hex::encode(Sha256::digest(input.token.as_bytes())); 787 + let invite = db::get_invite_by_token_hash(&state.db, state.db_backend, &token_hash) 788 + .await? 789 + .ok_or_else(|| AppError::NotFound("Invalid invite token".into()))?; 790 + 791 + if invite.revoked { 792 + return Err(AppError::BadRequest("This invite has been revoked".into())); 793 + } 794 + 795 + if let Some(max) = invite.max_uses 796 + && invite.uses >= max 797 + { 798 + return Err(AppError::BadRequest( 799 + "This invite has reached its maximum uses".into(), 800 + )); 801 + } 802 + 803 + if let Some(ref expires) = invite.expires_at { 804 + let now = now_rfc3339(); 805 + if now > *expires { 806 + return Err(AppError::BadRequest("This invite has expired".into())); 807 + } 808 + } 809 + 810 + let existing = db::get_member(&state.db, state.db_backend, &invite.space_id, &did).await?; 811 + if existing.is_some() { 812 + return Err(AppError::Conflict( 813 + "You are already a member of this space".into(), 814 + )); 815 + } 816 + 817 + let member = SpaceMember { 818 + id: Uuid::new_v4().to_string(), 819 + space_id: invite.space_id.clone(), 820 + member_did: did, 821 + access: invite.access, 822 + is_delegation: false, 823 + granted_by: Some(invite.created_by.clone()), 824 + created_at: now_rfc3339(), 825 + }; 826 + 827 + db::add_member(&state.db, state.db_backend, &member).await?; 828 + db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?; 829 + 830 + let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; 831 + let space_uri = space.map(|s| format!("ats://{}/{}/{}", s.owner_did, s.type_nsid, s.skey)); 832 + 833 + let mut response = Json(serde_json::json!({ 834 + "spaceUri": space_uri, 835 + "access": member.access, 836 + })) 837 + .into_response(); 838 + *response.status_mut() = StatusCode::CREATED; 839 + Ok(response) 840 + } 841 + 842 + async fn revoke_invite( 843 + State(state): State<AppState>, 844 + xrpc_claims: XrpcClaims, 845 + Json(input): Json<RevokeInviteInput>, 846 + ) -> Result<Json<serde_json::Value>, AppError> { 847 + let claims = require_auth(&xrpc_claims)?; 848 + let space = resolve_space(&state, &input.space_uri).await?; 849 + require_space_admin(&state, &space, claims.did()).await?; 850 + 851 + let revoked = db::revoke_invite(&state.db, state.db_backend, &input.invite_id).await?; 852 + if !revoked { 853 + return Err(AppError::NotFound("Invite not found".into())); 854 + } 855 + 856 + Ok(Json(serde_json::json!({ "success": true }))) 857 + } 858 + 859 + async fn list_invites( 860 + State(state): State<AppState>, 861 + xrpc_claims: XrpcClaims, 862 + Query(query): Query<SpaceUriQuery>, 863 + ) -> Result<Json<serde_json::Value>, AppError> { 864 + let claims = require_auth(&xrpc_claims)?; 865 + let space = resolve_space(&state, &query.space_uri).await?; 866 + require_space_admin(&state, &space, claims.did()).await?; 867 + 868 + let invites = db::list_invites(&state.db, state.db_backend, &space.id).await?; 869 + 870 + let invites_json: Vec<serde_json::Value> = invites 871 + .into_iter() 872 + .map(|i| { 873 + serde_json::json!({ 874 + "id": i.id, 875 + "access": i.access, 876 + "maxUses": i.max_uses, 877 + "uses": i.uses, 878 + "expiresAt": i.expires_at, 879 + "revoked": i.revoked, 880 + "createdBy": i.created_by, 881 + "createdAt": i.created_at, 882 + }) 883 + }) 884 + .collect(); 885 + 886 + Ok(Json(serde_json::json!({ "invites": invites_json }))) 887 + } 888 + 889 + // --------------------------------------------------------------------------- 890 + // Credential handlers 891 + // --------------------------------------------------------------------------- 892 + 893 + async fn get_credential( 894 + State(state): State<AppState>, 895 + xrpc_claims: XrpcClaims, 896 + Json(input): Json<GetCredentialInput>, 897 + ) -> Result<Json<serde_json::Value>, AppError> { 898 + let claims = require_auth(&xrpc_claims)?; 899 + let did = claims.did().to_string(); 900 + let space = resolve_space(&state, &input.space_uri).await?; 901 + 902 + require_membership(&state, &space, &did, false, None).await?; 903 + 904 + let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { 905 + AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) 906 + })?; 907 + 908 + let client_id = claims.client_key().map(|k| k.to_string()); 909 + let issued = crate::spaces::auth::issue_credential( 910 + &state.db, 911 + state.db_backend, 912 + encryption_key, 913 + &space, 914 + &did, 915 + client_id.as_deref(), 916 + ) 917 + .await?; 918 + 919 + Ok(Json(serde_json::json!({ 920 + "credential": issued.token, 921 + "expiresAt": issued.expires_at, 922 + }))) 923 + } 924 + 925 + async fn refresh_credential( 926 + State(state): State<AppState>, 927 + xrpc_claims: XrpcClaims, 928 + Json(input): Json<RefreshCredentialInput>, 929 + ) -> Result<Json<serde_json::Value>, AppError> { 930 + let _claims = require_auth(&xrpc_claims)?; 931 + let space = resolve_space(&state, &input.space_uri).await?; 932 + 933 + let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { 934 + AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) 935 + })?; 936 + 937 + let issued = crate::spaces::auth::refresh_credential( 938 + &state.db, 939 + state.db_backend, 940 + encryption_key, 941 + &space, 942 + &input.credential, 943 + ) 944 + .await?; 945 + 946 + Ok(Json(serde_json::json!({ 947 + "credential": issued.token, 948 + "expiresAt": issued.expires_at, 949 + }))) 950 + } 951 + 952 + // --------------------------------------------------------------------------- 953 + // Notification handlers 954 + // --------------------------------------------------------------------------- 955 + 956 + async fn write_notification( 957 + State(state): State<AppState>, 958 + xrpc_claims: XrpcClaims, 959 + Json(input): Json<WriteNotificationInput>, 960 + ) -> Result<Json<serde_json::Value>, AppError> { 961 + let claims = require_auth(&xrpc_claims)?; 962 + let space = resolve_space(&state, &input.space_uri).await?; 963 + require_space_admin(&state, &space, claims.did()).await?; 964 + 965 + let notification = crate::spaces::notifications::WriteNotification { 966 + space_uri: input.space_uri, 967 + author_did: input.author_did, 968 + collection: input.collection, 969 + rkey: input.rkey, 970 + action: input.action, 971 + }; 972 + 973 + crate::spaces::notifications::handle_write_notification( 974 + &state.db, 975 + state.db_backend, 976 + &space.id, 977 + &notification, 978 + ) 979 + .await?; 980 + 981 + Ok(Json(serde_json::json!({ "success": true }))) 982 + }
+293
src/spaces/sync.rs
··· 1 + use uuid::Uuid; 2 + 3 + use crate::db::DatabaseBackend; 4 + use crate::db::now_rfc3339; 5 + use crate::error::AppError; 6 + use crate::profile::resolve_pds_endpoint; 7 + use crate::spaces::types::*; 8 + use crate::spaces::{db, members}; 9 + 10 + /// Sync all members of a space by pulling records from their PDSes. 11 + pub async fn sync_space( 12 + http: &reqwest::Client, 13 + pool: &sqlx::AnyPool, 14 + backend: DatabaseBackend, 15 + plc_url: &str, 16 + space_id: &str, 17 + collections: &[String], 18 + ) -> Result<SyncSpaceResult, AppError> { 19 + let resolved = members::resolve_members(pool, backend, space_id).await?; 20 + let mut results = Vec::new(); 21 + 22 + for member in &resolved { 23 + let result = sync_member( 24 + http, 25 + pool, 26 + backend, 27 + plc_url, 28 + space_id, 29 + &member.did, 30 + collections, 31 + ) 32 + .await; 33 + 34 + results.push(MemberSyncResult { 35 + did: member.did.clone(), 36 + records_synced: result.as_ref().map(|r| r.records_synced).unwrap_or(0), 37 + error: result.err().map(|e| e.to_string()), 38 + }); 39 + } 40 + 41 + let total = results.iter().map(|r| r.records_synced).sum(); 42 + 43 + Ok(SyncSpaceResult { 44 + members_processed: results.len(), 45 + total_records_synced: total, 46 + member_results: results, 47 + }) 48 + } 49 + 50 + /// Sync records from a single member's PDS for a given space. 51 + pub async fn sync_member( 52 + http: &reqwest::Client, 53 + pool: &sqlx::AnyPool, 54 + backend: DatabaseBackend, 55 + plc_url: &str, 56 + space_id: &str, 57 + member_did: &str, 58 + collections: &[String], 59 + ) -> Result<MemberSyncSummary, AppError> { 60 + let state_id = match db::get_sync_state(pool, backend, space_id, member_did).await? { 61 + Some(s) => s.id, 62 + None => { 63 + let id = Uuid::new_v4().to_string(); 64 + let initial = SpaceSyncState { 65 + id: id.clone(), 66 + space_id: space_id.to_string(), 67 + member_did: member_did.to_string(), 68 + cursor: None, 69 + last_synced_at: None, 70 + status: SyncStatus::Pending, 71 + error: None, 72 + }; 73 + db::upsert_sync_state(pool, backend, &initial).await?; 74 + id 75 + } 76 + }; 77 + 78 + // Mark as syncing 79 + let syncing_state = SpaceSyncState { 80 + id: state_id.clone(), 81 + space_id: space_id.to_string(), 82 + member_did: member_did.to_string(), 83 + cursor: None, 84 + last_synced_at: None, 85 + status: SyncStatus::Syncing, 86 + error: None, 87 + }; 88 + db::upsert_sync_state(pool, backend, &syncing_state).await?; 89 + 90 + let result = pull_member_records( 91 + http, 92 + pool, 93 + backend, 94 + plc_url, 95 + space_id, 96 + member_did, 97 + collections, 98 + ) 99 + .await; 100 + 101 + match result { 102 + Ok(summary) => { 103 + let done = SpaceSyncState { 104 + id: state_id, 105 + space_id: space_id.to_string(), 106 + member_did: member_did.to_string(), 107 + cursor: summary.cursor.clone(), 108 + last_synced_at: Some(now_rfc3339()), 109 + status: SyncStatus::Synced, 110 + error: None, 111 + }; 112 + db::upsert_sync_state(pool, backend, &done).await?; 113 + Ok(summary) 114 + } 115 + Err(e) => { 116 + let err_state = SpaceSyncState { 117 + id: state_id, 118 + space_id: space_id.to_string(), 119 + member_did: member_did.to_string(), 120 + cursor: None, 121 + last_synced_at: Some(now_rfc3339()), 122 + status: SyncStatus::Error, 123 + error: Some(e.to_string()), 124 + }; 125 + db::upsert_sync_state(pool, backend, &err_state).await?; 126 + Err(e) 127 + } 128 + } 129 + } 130 + 131 + async fn pull_member_records( 132 + http: &reqwest::Client, 133 + pool: &sqlx::AnyPool, 134 + backend: DatabaseBackend, 135 + plc_url: &str, 136 + space_id: &str, 137 + member_did: &str, 138 + collections: &[String], 139 + ) -> Result<MemberSyncSummary, AppError> { 140 + let pds_url = resolve_pds_endpoint(http, plc_url, member_did).await?; 141 + let mut total_records = 0usize; 142 + let mut last_cursor = None; 143 + 144 + for collection in collections { 145 + let mut cursor: Option<String> = None; 146 + loop { 147 + let (records, next_cursor) = fetch_records_page( 148 + http, 149 + &pds_url, 150 + member_did, 151 + collection, 152 + cursor.as_deref(), 153 + 100, 154 + ) 155 + .await?; 156 + 157 + if records.is_empty() { 158 + break; 159 + } 160 + 161 + for record in &records { 162 + let uri = record["uri"].as_str().unwrap_or(""); 163 + let rkey = extract_rkey(uri); 164 + let cid = record["cid"].as_str().unwrap_or("").to_string(); 165 + let value = record 166 + .get("value") 167 + .cloned() 168 + .unwrap_or(serde_json::Value::Null); 169 + 170 + let space_record_uri = format!("ats://{space_id}/{member_did}/{collection}/{rkey}"); 171 + 172 + let space_record = SpaceRecord { 173 + uri: space_record_uri, 174 + space_id: space_id.to_string(), 175 + author_did: member_did.to_string(), 176 + collection: collection.clone(), 177 + rkey: rkey.to_string(), 178 + record: value, 179 + cid, 180 + indexed_at: now_rfc3339(), 181 + }; 182 + 183 + db::upsert_space_record(pool, backend, &space_record).await?; 184 + total_records += 1; 185 + } 186 + 187 + last_cursor = next_cursor.clone(); 188 + cursor = next_cursor; 189 + 190 + if cursor.is_none() { 191 + break; 192 + } 193 + } 194 + } 195 + 196 + Ok(MemberSyncSummary { 197 + records_synced: total_records, 198 + cursor: last_cursor, 199 + }) 200 + } 201 + 202 + async fn fetch_records_page( 203 + http: &reqwest::Client, 204 + pds_url: &str, 205 + repo: &str, 206 + collection: &str, 207 + cursor: Option<&str>, 208 + limit: u32, 209 + ) -> Result<(Vec<serde_json::Value>, Option<String>), AppError> { 210 + let mut url = format!( 211 + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", 212 + pds_url.trim_end_matches('/'), 213 + repo, 214 + collection, 215 + limit, 216 + ); 217 + 218 + if let Some(c) = cursor { 219 + url.push_str(&format!("&cursor={c}")); 220 + } 221 + 222 + let resp = http 223 + .get(&url) 224 + .send() 225 + .await 226 + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; 227 + 228 + if !resp.status().is_success() { 229 + let status = resp.status(); 230 + return Err(AppError::Internal(format!( 231 + "PDS listRecords failed with {status} for {repo}/{collection}" 232 + ))); 233 + } 234 + 235 + let body: serde_json::Value = resp 236 + .json() 237 + .await 238 + .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; 239 + 240 + let records = body["records"].as_array().cloned().unwrap_or_default(); 241 + 242 + let next_cursor = body["cursor"].as_str().map(|s| s.to_string()); 243 + 244 + Ok((records, next_cursor)) 245 + } 246 + 247 + fn extract_rkey(uri: &str) -> &str { 248 + uri.rsplit('/').next().unwrap_or("") 249 + } 250 + 251 + // --------------------------------------------------------------------------- 252 + // Result types 253 + // --------------------------------------------------------------------------- 254 + 255 + pub struct SyncSpaceResult { 256 + pub members_processed: usize, 257 + pub total_records_synced: usize, 258 + pub member_results: Vec<MemberSyncResult>, 259 + } 260 + 261 + pub struct MemberSyncResult { 262 + pub did: String, 263 + pub records_synced: usize, 264 + pub error: Option<String>, 265 + } 266 + 267 + pub struct MemberSyncSummary { 268 + pub records_synced: usize, 269 + pub cursor: Option<String>, 270 + } 271 + 272 + #[cfg(test)] 273 + mod tests { 274 + use super::*; 275 + 276 + #[test] 277 + fn extract_rkey_from_at_uri() { 278 + assert_eq!( 279 + extract_rkey("at://did:plc:abc/app.bsky.feed.post/3k2abc"), 280 + "3k2abc" 281 + ); 282 + } 283 + 284 + #[test] 285 + fn extract_rkey_from_empty() { 286 + assert_eq!(extract_rkey(""), ""); 287 + } 288 + 289 + #[test] 290 + fn extract_rkey_no_slash() { 291 + assert_eq!(extract_rkey("singlevalue"), "singlevalue"); 292 + } 293 + }
+248
src/spaces/types.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::fmt; 3 + 4 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 5 + #[serde(rename_all = "lowercase")] 6 + pub enum SpaceAccess { 7 + Read, 8 + Write, 9 + } 10 + 11 + impl SpaceAccess { 12 + pub fn as_str(&self) -> &'static str { 13 + match self { 14 + SpaceAccess::Read => "read", 15 + SpaceAccess::Write => "write", 16 + } 17 + } 18 + 19 + pub fn parse(s: &str) -> Option<Self> { 20 + match s { 21 + "read" => Some(SpaceAccess::Read), 22 + "write" => Some(SpaceAccess::Write), 23 + _ => None, 24 + } 25 + } 26 + 27 + pub fn can_write(&self) -> bool { 28 + matches!(self, SpaceAccess::Write) 29 + } 30 + 31 + pub fn can_read(&self) -> bool { 32 + true 33 + } 34 + } 35 + 36 + impl fmt::Display for SpaceAccess { 37 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 + f.write_str(self.as_str()) 39 + } 40 + } 41 + 42 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 43 + #[serde(rename_all = "snake_case")] 44 + pub enum AccessMode { 45 + DefaultAllow, 46 + DefaultDeny, 47 + } 48 + 49 + impl AccessMode { 50 + pub fn as_str(&self) -> &'static str { 51 + match self { 52 + AccessMode::DefaultAllow => "default_allow", 53 + AccessMode::DefaultDeny => "default_deny", 54 + } 55 + } 56 + 57 + pub fn parse(s: &str) -> Option<Self> { 58 + match s { 59 + "default_allow" => Some(AccessMode::DefaultAllow), 60 + "default_deny" => Some(AccessMode::DefaultDeny), 61 + _ => None, 62 + } 63 + } 64 + } 65 + 66 + impl fmt::Display for AccessMode { 67 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 68 + f.write_str(self.as_str()) 69 + } 70 + } 71 + 72 + #[derive(Debug, Clone, Serialize, Deserialize)] 73 + pub struct Space { 74 + pub id: String, 75 + pub owner_did: String, 76 + pub type_nsid: String, 77 + pub skey: String, 78 + pub display_name: Option<String>, 79 + pub description: Option<String>, 80 + pub access_mode: AccessMode, 81 + pub app_allowlist: Option<Vec<String>>, 82 + pub app_denylist: Option<Vec<String>>, 83 + pub managing_app_did: Option<String>, 84 + pub config: SpaceConfig, 85 + pub created_at: String, 86 + pub updated_at: String, 87 + } 88 + 89 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 90 + pub struct SpaceConfig { 91 + #[serde(default)] 92 + pub membership_public: bool, 93 + #[serde(default)] 94 + pub records_public: bool, 95 + #[serde(flatten)] 96 + pub extra: serde_json::Map<String, serde_json::Value>, 97 + } 98 + 99 + #[derive(Debug, Clone, Serialize, Deserialize)] 100 + pub struct SpaceMember { 101 + pub id: String, 102 + pub space_id: String, 103 + pub member_did: String, 104 + pub access: SpaceAccess, 105 + pub is_delegation: bool, 106 + pub granted_by: Option<String>, 107 + pub created_at: String, 108 + } 109 + 110 + #[derive(Debug, Clone, Serialize, Deserialize)] 111 + pub struct ResolvedMember { 112 + pub did: String, 113 + pub access: SpaceAccess, 114 + } 115 + 116 + #[derive(Debug, Clone, Serialize, Deserialize)] 117 + pub struct SpaceRecord { 118 + pub uri: String, 119 + pub space_id: String, 120 + pub author_did: String, 121 + pub collection: String, 122 + pub rkey: String, 123 + pub record: serde_json::Value, 124 + pub cid: String, 125 + pub indexed_at: String, 126 + } 127 + 128 + #[derive(Debug, Clone, Serialize, Deserialize)] 129 + pub struct SpaceInvite { 130 + pub id: String, 131 + pub space_id: String, 132 + pub token_hash: String, 133 + pub created_by: String, 134 + pub access: SpaceAccess, 135 + pub max_uses: Option<i64>, 136 + pub uses: i64, 137 + pub expires_at: Option<String>, 138 + pub revoked: bool, 139 + pub created_at: String, 140 + } 141 + 142 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 143 + #[serde(rename_all = "lowercase")] 144 + pub enum SyncStatus { 145 + Pending, 146 + Syncing, 147 + Synced, 148 + Error, 149 + } 150 + 151 + impl SyncStatus { 152 + pub fn as_str(&self) -> &'static str { 153 + match self { 154 + SyncStatus::Pending => "pending", 155 + SyncStatus::Syncing => "syncing", 156 + SyncStatus::Synced => "synced", 157 + SyncStatus::Error => "error", 158 + } 159 + } 160 + 161 + pub fn parse(s: &str) -> Option<Self> { 162 + match s { 163 + "pending" => Some(SyncStatus::Pending), 164 + "syncing" => Some(SyncStatus::Syncing), 165 + "synced" => Some(SyncStatus::Synced), 166 + "error" => Some(SyncStatus::Error), 167 + _ => None, 168 + } 169 + } 170 + } 171 + 172 + #[derive(Debug, Clone, Serialize, Deserialize)] 173 + pub struct SpaceSyncState { 174 + pub id: String, 175 + pub space_id: String, 176 + pub member_did: String, 177 + pub cursor: Option<String>, 178 + pub last_synced_at: Option<String>, 179 + pub status: SyncStatus, 180 + pub error: Option<String>, 181 + } 182 + 183 + #[cfg(test)] 184 + mod tests { 185 + use super::*; 186 + 187 + #[test] 188 + fn space_access_roundtrip() { 189 + assert_eq!(SpaceAccess::parse("read"), Some(SpaceAccess::Read)); 190 + assert_eq!(SpaceAccess::parse("write"), Some(SpaceAccess::Write)); 191 + assert_eq!(SpaceAccess::parse("admin"), None); 192 + 193 + assert_eq!(SpaceAccess::Read.as_str(), "read"); 194 + assert_eq!(SpaceAccess::Write.as_str(), "write"); 195 + } 196 + 197 + #[test] 198 + fn space_access_permissions() { 199 + assert!(SpaceAccess::Read.can_read()); 200 + assert!(!SpaceAccess::Read.can_write()); 201 + assert!(SpaceAccess::Write.can_read()); 202 + assert!(SpaceAccess::Write.can_write()); 203 + } 204 + 205 + #[test] 206 + fn access_mode_roundtrip() { 207 + assert_eq!( 208 + AccessMode::parse("default_allow"), 209 + Some(AccessMode::DefaultAllow) 210 + ); 211 + assert_eq!( 212 + AccessMode::parse("default_deny"), 213 + Some(AccessMode::DefaultDeny) 214 + ); 215 + assert_eq!(AccessMode::parse("open"), None); 216 + } 217 + 218 + #[test] 219 + fn space_config_defaults() { 220 + let config: SpaceConfig = serde_json::from_str("{}").unwrap(); 221 + assert!(!config.membership_public); 222 + assert!(!config.records_public); 223 + } 224 + 225 + #[test] 226 + fn space_config_with_extra_fields() { 227 + let config: SpaceConfig = 228 + serde_json::from_str(r#"{"membership_public": true, "custom_field": 42}"#).unwrap(); 229 + assert!(config.membership_public); 230 + assert!(!config.records_public); 231 + assert_eq!(config.extra.get("custom_field").unwrap(), &42); 232 + } 233 + 234 + #[test] 235 + fn space_access_serialization() { 236 + let json = serde_json::to_string(&SpaceAccess::Read).unwrap(); 237 + assert_eq!(json, "\"read\""); 238 + 239 + let json = serde_json::to_string(&SpaceAccess::Write).unwrap(); 240 + assert_eq!(json, "\"write\""); 241 + 242 + let parsed: SpaceAccess = serde_json::from_str("\"read\"").unwrap(); 243 + assert_eq!(parsed, SpaceAccess::Read); 244 + 245 + let parsed: SpaceAccess = serde_json::from_str("\"write\"").unwrap(); 246 + assert_eq!(parsed, SpaceAccess::Write); 247 + } 248 + }
+1 -1
src/xrpc/procedure.rs
··· 20 20 ) -> Result<Response, AppError> { 21 21 if let Some(ref script) = lexicon.script { 22 22 return crate::lua::execute_procedure_script( 23 - state, method, claims, input, params, lexicon, script, 23 + state, method, claims, input, params, lexicon, script, None, 24 24 ) 25 25 .await; 26 26 }
+4 -2
src/xrpc/query.rs
··· 16 16 claims: Option<&Claims>, 17 17 ) -> Result<Response, AppError> { 18 18 if let Some(ref script) = lexicon.script { 19 - return crate::lua::execute_query_script(state, method, params, lexicon, script, claims) 20 - .await; 19 + return crate::lua::execute_query_script( 20 + state, method, params, lexicon, script, claims, None, 21 + ) 22 + .await; 21 23 } 22 24 23 25 // Single-record query: has a `uri` parameter