A better Rust ATProto crate

[jacquard-lexicon] add permission-set lexicon types

- Move AccountResource, AccountAction, RepoAction to jacquard-common
- Add LexPermissionSet, LexPermission, LexPermissionResource types
- Add PermissionSet variant to LexUserType with IntoStatic impls
- Add namespace constraint validation (PermissionSetError)
- Add codegen dispatch for permission sets (returns default)

+710 -31
+2
crates/jacquard-common/src/types.rs
··· 29 29 pub mod nsid; 30 30 /// Record key types and validation 31 31 pub mod recordkey; 32 + /// Scope action and resource enums for AT Protocol OAuth 33 + pub mod scope_primitives; 32 34 /// String types with format validation 33 35 pub mod string; 34 36 /// Timestamp Identifier (TID) types and generation
+42
crates/jacquard-common/src/types/scope_primitives.rs
··· 1 + //! Scope action and resource enums for AT Protocol OAuth. 2 + //! 3 + //! These types are used in both OAuth scope parsing and permission set 4 + //! lexicon definitions, allowing consistent validation and serialization. 5 + 6 + use serde::{Deserialize, Serialize}; 7 + 8 + /// Account resource types for AT Protocol OAuth scopes. 9 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 10 + #[serde(rename_all = "kebab-case")] 11 + pub enum AccountResource { 12 + /// Email access. 13 + Email, 14 + /// Repository access. 15 + Repo, 16 + /// Status access. 17 + Status, 18 + } 19 + 20 + /// Account action permissions for AT Protocol OAuth scopes. 21 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 22 + #[serde(rename_all = "kebab-case")] 23 + pub enum AccountAction { 24 + /// Read-only access. 25 + Read, 26 + /// Management access (includes read). 27 + Manage, 28 + } 29 + 30 + /// Repository action permissions for AT Protocol OAuth scopes. 31 + #[derive( 32 + Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, 33 + )] 34 + #[serde(rename_all = "kebab-case")] 35 + pub enum RepoAction { 36 + /// Create records. 37 + Create, 38 + /// Update records. 39 + Update, 40 + /// Delete records. 41 + Delete, 42 + }
+5
crates/jacquard-lexicon/src/codegen.rs
··· 378 378 resolved, 379 379 ) 380 380 } 381 + LexUserType::PermissionSet(_perm_set) => { 382 + // Permission sets are consumed at runtime by the permission set 383 + // resolver (Phase 5), not generated as code. 384 + Ok(GeneratedCode::default()) 385 + } 381 386 } 382 387 } 383 388 }
+1
crates/jacquard-lexicon/src/codegen/type_param.rs
··· 80 80 true 81 81 } 82 82 LexUserType::Union(_) => true, // Union enums are always generated with <S> 83 + LexUserType::PermissionSet(_) => true, // Permission sets are parameterized on S 83 84 } 84 85 } 85 86 }
+9
crates/jacquard-lexicon/src/derive_impl/doc_to_tokens.rs
··· 283 283 LexUserType::Unknown(_) => quote! { 284 284 #lex LexUserType::Unknown(#lex LexUnknown { ..Default::default() }) 285 285 }, 286 + LexUserType::PermissionSet(_) => quote! { 287 + #lex LexUserType::PermissionSet(#lex LexPermissionSet { 288 + title: None, 289 + title_lang: None, 290 + detail: None, 291 + detail_lang: None, 292 + permissions: vec![], 293 + }) 294 + }, 286 295 } 287 296 } 288 297
+649 -1
crates/jacquard-lexicon/src/lexicon.rs
··· 4 4 5 5 use jacquard_common::{ 6 6 CowStr, deps::smol_str::SmolStr, into_static::IntoStatic, types::blob::MimeType, 7 + types::did::Did, types::nsid::Nsid, 8 + types::scope_primitives::{RepoAction, AccountAction}, 7 9 }; 8 10 use serde::{Deserialize, Serialize}; 9 11 use serde_repr::{Deserialize_repr, Serialize_repr}; 10 12 use serde_with::skip_serializing_none; 11 - use std::collections::BTreeMap; 13 + use std::collections::{BTreeMap, HashMap}; 14 + use thiserror::Error; 12 15 13 16 #[derive(Debug, Serialize_repr, Deserialize_repr, PartialEq, Eq, Clone, Copy, Default)] 14 17 #[repr(u8)] ··· 394 397 pub record: LexRecordRecord<'s>, 395 398 } 396 399 400 + // permission sets 401 + 402 + /// AT Protocol permission set lexicon type. 403 + /// 404 + /// Contains a `permissions` array where each entry is a `LexPermission` 405 + /// with `"type": "permission"` and a `"resource"` discriminator carrying 406 + /// typed fields (NSIDs, DIDs, MIME types, action enums). 407 + #[skip_serializing_none] 408 + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] 409 + pub struct LexPermissionSet<'s> { 410 + #[serde(borrow)] 411 + pub title: Option<CowStr<'s>>, 412 + #[serde(default, rename = "title:lang")] 413 + pub title_lang: Option<HashMap<CowStr<'s>, CowStr<'s>>>, 414 + pub detail: Option<CowStr<'s>>, 415 + #[serde(default, rename = "detail:lang")] 416 + pub detail_lang: Option<HashMap<CowStr<'s>, CowStr<'s>>>, 417 + pub permissions: Vec<LexPermission<'s>>, 418 + } 419 + 420 + /// A permission entry within a permission set. 421 + /// 422 + /// Single-variant enum: the `"type": "permission"` JSON tag selects the 423 + /// `Permission` variant, which wraps a `LexPermissionResource` discriminated 424 + /// by the `"resource"` field. 425 + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] 426 + #[serde(tag = "type", rename_all = "kebab-case")] 427 + pub enum LexPermission<'s> { 428 + /// A permission entry. 429 + Permission { 430 + #[serde(flatten, borrow)] 431 + resource: LexPermissionResource<'s>, 432 + }, 433 + } 434 + 435 + /// Resource-specific permission data, discriminated by the `"resource"` field. 436 + #[skip_serializing_none] 437 + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] 438 + #[serde(tag = "resource", rename_all = "kebab-case")] 439 + pub enum LexPermissionResource<'s> { 440 + /// Repository resource permission. 441 + Repo { 442 + /// Collection NSIDs this permission applies to. 443 + #[serde(borrow)] 444 + collection: Vec<Nsid<CowStr<'s>>>, 445 + /// Permitted actions (create, update, delete). None = all actions. 446 + #[serde(default)] 447 + action: Option<Vec<RepoAction>>, 448 + }, 449 + /// RPC method permission. 450 + Rpc { 451 + /// Lexicon method NSIDs this permission applies to. 452 + #[serde(borrow)] 453 + lxm: Vec<Nsid<CowStr<'s>>>, 454 + /// Audience DID for inter-service auth. 455 + #[serde(borrow, default)] 456 + aud: Option<Did<CowStr<'s>>>, 457 + /// If true, inherits audience from the include scope's aud parameter. 458 + #[serde(default, rename = "inheritAud")] 459 + inherit_aud: Option<bool>, 460 + }, 461 + /// Blob resource permission. 462 + Blob { 463 + /// Accepted MIME type patterns. 464 + #[serde(borrow)] 465 + accept: Vec<MimeType<CowStr<'s>>>, 466 + /// Maximum blob size in bytes. 467 + #[serde(default)] 468 + max_size: Option<u64>, 469 + }, 470 + /// Identity resource permission. 471 + Identity { 472 + /// Identity attribute (e.g., "handle"). 473 + #[serde(borrow)] 474 + attr: CowStr<'s>, 475 + }, 476 + /// Account resource permission. 477 + Account { 478 + /// Account attribute (e.g., "email"). 479 + #[serde(borrow)] 480 + attr: CowStr<'s>, 481 + /// Permitted actions (read, manage). None = read. 482 + #[serde(default)] 483 + action: Option<Vec<AccountAction>>, 484 + }, 485 + } 486 + 487 + /// Errors from permission set validation. 488 + #[derive(Debug, Error)] 489 + pub enum PermissionSetError { 490 + #[error("permission set has empty permissions array")] 491 + EmptyPermissions, 492 + 493 + #[error("permission set {nsid} references out-of-namespace resource: {resource}")] 494 + NamespaceViolation { nsid: String, resource: String }, 495 + } 496 + 497 + impl<'s> LexPermissionSet<'s> { 498 + /// Validate the permission set against its owning NSID. 499 + /// 500 + /// Checks: 501 + /// 1. Permissions array is non-empty 502 + /// 2. All NSID-scoped resources (collection, lxm) are within the 503 + /// owning NSID's namespace (first two segments) 504 + pub fn validate(&self, owning_nsid: &str) -> Result<(), PermissionSetError> { 505 + if self.permissions.is_empty() { 506 + return Err(PermissionSetError::EmptyPermissions); 507 + } 508 + 509 + let namespace = { 510 + let mut parts = owning_nsid.splitn(3, '.'); 511 + match (parts.next(), parts.next()) { 512 + (Some(a), Some(b)) => format!("{}.{}", a, b), 513 + _ => owning_nsid.to_string(), 514 + } 515 + }; 516 + 517 + for perm in &self.permissions { 518 + let LexPermission::Permission { resource } = perm; 519 + match resource { 520 + LexPermissionResource::Repo { collection, .. } => { 521 + for col in collection { 522 + let col_str: &str = col.as_ref(); 523 + if !col_str.starts_with(&format!("{}.", namespace)) { 524 + return Err(PermissionSetError::NamespaceViolation { 525 + nsid: owning_nsid.to_string(), 526 + resource: col_str.to_string(), 527 + }); 528 + } 529 + } 530 + } 531 + LexPermissionResource::Rpc { lxm, .. } => { 532 + for l in lxm { 533 + let lxm_str: &str = l.as_ref(); 534 + if !lxm_str.starts_with(&format!("{}.", namespace)) { 535 + return Err(PermissionSetError::NamespaceViolation { 536 + nsid: owning_nsid.to_string(), 537 + resource: lxm_str.to_string(), 538 + }); 539 + } 540 + } 541 + } 542 + // Blob, Identity, Account don't have namespace-scoped NSID resources. 543 + _ => {} 544 + } 545 + } 546 + 547 + Ok(()) 548 + } 549 + } 550 + 397 551 // core 398 552 399 553 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] ··· 433 587 Unknown(LexUnknown<'s>), 434 588 // lexRefUnion 435 589 Union(LexRefUnion<'s>), 590 + // lexPermissionSet 591 + #[serde(borrow)] 592 + PermissionSet(LexPermissionSet<'s>), 436 593 } 437 594 438 595 // IntoStatic implementations for all lexicon types ··· 826 983 } 827 984 } 828 985 986 + impl IntoStatic for LexPermissionResource<'_> { 987 + type Output = LexPermissionResource<'static>; 988 + 989 + fn into_static(self) -> Self::Output { 990 + match self { 991 + LexPermissionResource::Repo { collection, action } => LexPermissionResource::Repo { 992 + collection: collection.into_iter().map(|n| n.into_static()).collect(), 993 + action, 994 + }, 995 + LexPermissionResource::Rpc { 996 + lxm, 997 + aud, 998 + inherit_aud, 999 + } => LexPermissionResource::Rpc { 1000 + lxm: lxm.into_iter().map(|n| n.into_static()).collect(), 1001 + aud: aud.map(|d| d.into_static()), 1002 + inherit_aud, 1003 + }, 1004 + LexPermissionResource::Blob { accept, max_size } => LexPermissionResource::Blob { 1005 + accept: accept.into_iter().map(|a| a.into_static()).collect(), 1006 + max_size, 1007 + }, 1008 + LexPermissionResource::Identity { attr } => LexPermissionResource::Identity { 1009 + attr: attr.into_static(), 1010 + }, 1011 + LexPermissionResource::Account { attr, action } => LexPermissionResource::Account { 1012 + attr: attr.into_static(), 1013 + action, 1014 + }, 1015 + } 1016 + } 1017 + } 1018 + 1019 + impl IntoStatic for LexPermission<'_> { 1020 + type Output = LexPermission<'static>; 1021 + 1022 + fn into_static(self) -> Self::Output { 1023 + match self { 1024 + LexPermission::Permission { resource } => LexPermission::Permission { 1025 + resource: resource.into_static(), 1026 + }, 1027 + } 1028 + } 1029 + } 1030 + 1031 + impl IntoStatic for LexPermissionSet<'_> { 1032 + type Output = LexPermissionSet<'static>; 1033 + 1034 + fn into_static(self) -> Self::Output { 1035 + LexPermissionSet { 1036 + title: self.title.into_static(), 1037 + title_lang: self.title_lang.into_static(), 1038 + detail: self.detail.into_static(), 1039 + detail_lang: self.detail_lang.into_static(), 1040 + permissions: self 1041 + .permissions 1042 + .into_iter() 1043 + .map(|p| p.into_static()) 1044 + .collect(), 1045 + } 1046 + } 1047 + } 1048 + 829 1049 impl IntoStatic for LexUserType<'_> { 830 1050 type Output = LexUserType<'static>; 831 1051 fn into_static(self) -> Self::Output { ··· 845 1065 Self::CidLink(x) => LexUserType::CidLink(x.into_static()), 846 1066 Self::Unknown(x) => LexUserType::Unknown(x.into_static()), 847 1067 Self::Union(x) => LexUserType::Union(x.into_static()), 1068 + Self::PermissionSet(x) => LexUserType::PermissionSet(x.into_static()), 848 1069 } 849 1070 } 850 1071 } ··· 874 1095 assert_eq!(doc.revision, None); 875 1096 assert_eq!(doc.description, None); 876 1097 assert_eq!(doc.defs.len(), 1); 1098 + } 1099 + 1100 + // Permission set tests for oauth-scopes-rework.AC5 1101 + 1102 + const PERMISSION_SET_SIMPLE: &str = r#" 1103 + { 1104 + "lexicon": 1, 1105 + "id": "app.bsky.authFull", 1106 + "defs": { 1107 + "main": { 1108 + "type": "permission-set", 1109 + "title": "Full Bluesky Client Access", 1110 + "detail": "Allows reading and writing to Bluesky records", 1111 + "permissions": [ 1112 + { 1113 + "type": "permission", 1114 + "resource": "repo", 1115 + "collection": ["app.bsky.feed.post"], 1116 + "action": ["create"] 1117 + } 1118 + ] 1119 + } 1120 + } 1121 + } 1122 + "#; 1123 + 1124 + const PERMISSION_SET_FULL: &str = r#" 1125 + { 1126 + "lexicon": 1, 1127 + "id": "app.bsky.authFull", 1128 + "defs": { 1129 + "main": { 1130 + "type": "permission-set", 1131 + "title": "Full Bluesky Client Access", 1132 + "title:lang": { 1133 + "es": "Acceso completo al cliente de Bluesky" 1134 + }, 1135 + "detail": "Allows reading and writing to Bluesky records and making service calls", 1136 + "detail:lang": { 1137 + "es": "Permite leer y escribir registros de Bluesky y realizar llamadas de servicio" 1138 + }, 1139 + "permissions": [ 1140 + { 1141 + "type": "permission", 1142 + "resource": "repo", 1143 + "collection": ["app.bsky.feed.post", "app.bsky.feed.like"], 1144 + "action": ["create", "update", "delete"] 1145 + }, 1146 + { 1147 + "type": "permission", 1148 + "resource": "repo", 1149 + "collection": ["app.bsky.actor.profile"], 1150 + "action": ["update"] 1151 + }, 1152 + { 1153 + "type": "permission", 1154 + "resource": "rpc", 1155 + "lxm": ["app.bsky.feed.getLikes", "app.bsky.feed.getAuthorFeed"], 1156 + "inheritAud": true 1157 + }, 1158 + { 1159 + "type": "permission", 1160 + "resource": "rpc", 1161 + "lxm": ["app.bsky.notification.listNotifications"], 1162 + "aud": "did:web:api.bsky.app" 1163 + }, 1164 + { 1165 + "type": "permission", 1166 + "resource": "identity", 1167 + "attr": "handle" 1168 + }, 1169 + { 1170 + "type": "permission", 1171 + "resource": "account", 1172 + "attr": "email", 1173 + "action": ["read"] 1174 + } 1175 + ] 1176 + } 1177 + } 1178 + } 1179 + "#; 1180 + 1181 + #[test] 1182 + fn test_permission_set_deserialize_simple() { 1183 + let doc = serde_json::from_str::<LexiconDoc>(PERMISSION_SET_SIMPLE) 1184 + .expect("failed to deserialize"); 1185 + assert_eq!(doc.id, "app.bsky.authFull"); 1186 + 1187 + let main_def = doc.defs.get("main").expect("main def exists"); 1188 + match main_def { 1189 + LexUserType::PermissionSet(pset) => { 1190 + assert_eq!(pset.title.as_ref().map(|s| s.as_ref()), Some("Full Bluesky Client Access")); 1191 + assert_eq!(pset.permissions.len(), 1); 1192 + 1193 + let perm = &pset.permissions[0]; 1194 + match perm { 1195 + LexPermission::Permission { 1196 + resource: LexPermissionResource::Repo { collection, action }, 1197 + } => { 1198 + assert_eq!(collection.len(), 1); 1199 + assert_eq!(collection[0].as_ref(), "app.bsky.feed.post"); 1200 + assert_eq!( 1201 + action.as_ref().map(|a| a.len()), 1202 + Some(1), 1203 + "has action vec" 1204 + ); 1205 + if let Some(actions) = action { 1206 + assert_eq!(actions[0], RepoAction::Create); 1207 + } 1208 + } 1209 + _ => panic!("expected Repo permission"), 1210 + } 1211 + } 1212 + _ => panic!("expected PermissionSet"), 1213 + } 1214 + } 1215 + 1216 + #[test] 1217 + fn test_permission_set_deserialize_full() { 1218 + let doc = serde_json::from_str::<LexiconDoc>(PERMISSION_SET_FULL) 1219 + .expect("failed to deserialize"); 1220 + let main_def = doc.defs.get("main").expect("main def"); 1221 + 1222 + match main_def { 1223 + LexUserType::PermissionSet(pset) => { 1224 + assert_eq!(pset.title_lang.as_ref().map(|m| m.len()), Some(1)); 1225 + assert_eq!(pset.detail_lang.as_ref().map(|m| m.len()), Some(1)); 1226 + assert_eq!(pset.permissions.len(), 6, "has 6 permission entries"); 1227 + 1228 + // Entry 1: Repo with 2 collections and 3 actions 1229 + match &pset.permissions[0] { 1230 + LexPermission::Permission { 1231 + resource: LexPermissionResource::Repo { collection, action }, 1232 + } => { 1233 + assert_eq!(collection.len(), 2); 1234 + assert_eq!( 1235 + action.as_ref().map(|a| a.len()), 1236 + Some(3), 1237 + "has 3 actions" 1238 + ); 1239 + } 1240 + _ => panic!("entry 0 should be Repo"), 1241 + } 1242 + 1243 + // Entry 3: Rpc with inherit_aud 1244 + match &pset.permissions[2] { 1245 + LexPermission::Permission { 1246 + resource: LexPermissionResource::Rpc { lxm, inherit_aud, .. }, 1247 + } => { 1248 + assert_eq!(lxm.len(), 2); 1249 + assert_eq!(*inherit_aud, Some(true)); 1250 + } 1251 + _ => panic!("entry 2 should be Rpc with inherit_aud"), 1252 + } 1253 + 1254 + // Entry 4: Rpc with explicit aud 1255 + match &pset.permissions[3] { 1256 + LexPermission::Permission { 1257 + resource: LexPermissionResource::Rpc { aud, .. }, 1258 + } => { 1259 + assert!(aud.is_some(), "has aud"); 1260 + } 1261 + _ => panic!("entry 3 should be Rpc with aud"), 1262 + } 1263 + 1264 + // Entry 5: Identity 1265 + match &pset.permissions[4] { 1266 + LexPermission::Permission { 1267 + resource: LexPermissionResource::Identity { attr }, 1268 + } => { 1269 + assert_eq!(attr.as_ref(), "handle"); 1270 + } 1271 + _ => panic!("entry 4 should be Identity"), 1272 + } 1273 + 1274 + // Entry 6: Account with action 1275 + match &pset.permissions[5] { 1276 + LexPermission::Permission { 1277 + resource: LexPermissionResource::Account { attr, action }, 1278 + } => { 1279 + assert_eq!(attr.as_ref(), "email"); 1280 + assert_eq!( 1281 + action.as_ref().map(|a| a.len()), 1282 + Some(1), 1283 + "has 1 action" 1284 + ); 1285 + if let Some(actions) = action { 1286 + assert_eq!(actions[0], AccountAction::Read); 1287 + } 1288 + } 1289 + _ => panic!("entry 5 should be Account"), 1290 + } 1291 + } 1292 + _ => panic!("expected PermissionSet"), 1293 + } 1294 + } 1295 + 1296 + #[test] 1297 + fn test_permission_set_into_static() { 1298 + let doc = serde_json::from_str::<LexiconDoc>(PERMISSION_SET_FULL) 1299 + .expect("failed to deserialize"); 1300 + let main_def = doc 1301 + .defs 1302 + .get("main") 1303 + .expect("main def") 1304 + .clone() 1305 + .into_static(); 1306 + 1307 + match main_def { 1308 + LexUserType::PermissionSet(pset) => { 1309 + assert_eq!(pset.permissions.len(), 6); 1310 + // Verify all borrowed fields are converted to 'static 1311 + assert!(pset.title.is_some()); 1312 + assert!(pset.title_lang.is_some()); 1313 + } 1314 + _ => panic!("expected PermissionSet"), 1315 + } 1316 + } 1317 + 1318 + #[test] 1319 + fn test_permission_set_namespace_violation() { 1320 + let doc = serde_json::from_str::<LexiconDoc>(PERMISSION_SET_SIMPLE) 1321 + .expect("failed to deserialize"); 1322 + let pset = match doc.defs.get("main").expect("main def") { 1323 + LexUserType::PermissionSet(p) => p, 1324 + _ => panic!("expected PermissionSet"), 1325 + }; 1326 + 1327 + // Valid: app.bsky.feed.post is in app.bsky namespace 1328 + assert!(pset.validate("app.bsky.authFull").is_ok()); 1329 + 1330 + // Invalid: com.atproto is out of namespace for app.bsky 1331 + let invalid_json = r#" 1332 + { 1333 + "lexicon": 1, 1334 + "id": "app.bsky.authFull", 1335 + "defs": { 1336 + "main": { 1337 + "type": "permission-set", 1338 + "permissions": [ 1339 + { 1340 + "type": "permission", 1341 + "resource": "repo", 1342 + "collection": ["com.atproto.repo.createRecord"], 1343 + "action": ["create"] 1344 + } 1345 + ] 1346 + } 1347 + } 1348 + } 1349 + "#; 1350 + let doc = serde_json::from_str::<LexiconDoc>(invalid_json).expect("deserialize"); 1351 + let pset = match doc.defs.get("main").expect("main def") { 1352 + LexUserType::PermissionSet(p) => p, 1353 + _ => panic!("expected PermissionSet"), 1354 + }; 1355 + let result = pset.validate("app.bsky.authFull"); 1356 + assert!(result.is_err()); 1357 + match result.unwrap_err() { 1358 + PermissionSetError::NamespaceViolation { nsid, resource } => { 1359 + assert_eq!(nsid, "app.bsky.authFull"); 1360 + assert_eq!(resource, "com.atproto.repo.createRecord"); 1361 + } 1362 + _ => panic!("expected NamespaceViolation"), 1363 + } 1364 + } 1365 + 1366 + #[test] 1367 + fn test_permission_set_empty_permissions() { 1368 + let json = r#" 1369 + { 1370 + "lexicon": 1, 1371 + "id": "app.bsky.authEmpty", 1372 + "defs": { 1373 + "main": { 1374 + "type": "permission-set", 1375 + "permissions": [] 1376 + } 1377 + } 1378 + } 1379 + "#; 1380 + let doc = serde_json::from_str::<LexiconDoc>(json).expect("deserialize"); 1381 + let pset = match doc.defs.get("main").expect("main def") { 1382 + LexUserType::PermissionSet(p) => p, 1383 + _ => panic!("expected PermissionSet"), 1384 + }; 1385 + let result = pset.validate("app.bsky.authEmpty"); 1386 + assert!(result.is_err()); 1387 + match result.unwrap_err() { 1388 + PermissionSetError::EmptyPermissions => {} 1389 + _ => panic!("expected EmptyPermissions"), 1390 + } 1391 + } 1392 + 1393 + #[test] 1394 + fn test_permission_set_serialize_roundtrip() { 1395 + let doc = serde_json::from_str::<LexiconDoc>(PERMISSION_SET_SIMPLE) 1396 + .expect("failed to deserialize"); 1397 + let orig_pset = match doc.defs.get("main").expect("main") { 1398 + LexUserType::PermissionSet(p) => p, 1399 + _ => panic!("expected PermissionSet"), 1400 + }; 1401 + 1402 + // Serialize to JSON value and back 1403 + let serialized_str = serde_json::to_string(orig_pset).expect("serialize to string"); 1404 + let deserialized_pset = 1405 + serde_json::from_str::<LexPermissionSet>(serialized_str.as_str()) 1406 + .expect("roundtrip deserialize"); 1407 + 1408 + assert_eq!(orig_pset.permissions.len(), deserialized_pset.permissions.len()); 1409 + } 1410 + 1411 + #[test] 1412 + fn test_permission_set_invalid_nsid() { 1413 + let json = r#" 1414 + { 1415 + "lexicon": 1, 1416 + "id": "app.bsky.authBad", 1417 + "defs": { 1418 + "main": { 1419 + "type": "permission-set", 1420 + "permissions": [ 1421 + { 1422 + "type": "permission", 1423 + "resource": "repo", 1424 + "collection": ["invalid..nsid"], 1425 + "action": ["create"] 1426 + } 1427 + ] 1428 + } 1429 + } 1430 + } 1431 + "#; 1432 + // Invalid NSID should fail during deserialization 1433 + let result = serde_json::from_str::<LexiconDoc>(json); 1434 + assert!(result.is_err(), "invalid NSID should fail deserialization"); 1435 + } 1436 + 1437 + #[test] 1438 + fn test_permission_set_invalid_did() { 1439 + let json = r#" 1440 + { 1441 + "lexicon": 1, 1442 + "id": "app.bsky.authBad", 1443 + "defs": { 1444 + "main": { 1445 + "type": "permission-set", 1446 + "permissions": [ 1447 + { 1448 + "type": "permission", 1449 + "resource": "rpc", 1450 + "lxm": ["app.bsky.feed.getLikes"], 1451 + "aud": "not-a-did" 1452 + } 1453 + ] 1454 + } 1455 + } 1456 + } 1457 + "#; 1458 + // Invalid DID should fail during deserialization 1459 + let result = serde_json::from_str::<LexiconDoc>(json); 1460 + assert!(result.is_err(), "invalid DID should fail deserialization"); 1461 + } 1462 + 1463 + #[test] 1464 + fn test_permission_set_title_lang() { 1465 + let doc = serde_json::from_str::<LexiconDoc>(PERMISSION_SET_FULL) 1466 + .expect("failed to deserialize"); 1467 + let pset = match doc.defs.get("main").expect("main def") { 1468 + LexUserType::PermissionSet(p) => p, 1469 + _ => panic!("expected PermissionSet"), 1470 + }; 1471 + 1472 + let title_lang = pset.title_lang.as_ref().expect("has title:lang"); 1473 + assert_eq!(title_lang.len(), 1); 1474 + let es_title = title_lang 1475 + .iter() 1476 + .find(|(k, _)| k.as_ref() == "es") 1477 + .expect("has es translation"); 1478 + assert_eq!( 1479 + es_title.1.as_ref(), 1480 + "Acceso completo al cliente de Bluesky" 1481 + ); 1482 + 1483 + // Roundtrip and verify title:lang survives 1484 + let serialized = serde_json::to_value(&pset).expect("serialize"); 1485 + assert!( 1486 + serialized.get("title:lang").is_some(), 1487 + "title:lang field preserved in JSON" 1488 + ); 1489 + } 1490 + 1491 + #[test] 1492 + fn test_permission_set_rpc_namespace_violation() { 1493 + let json = r#" 1494 + { 1495 + "lexicon": 1, 1496 + "id": "app.bsky.authBad", 1497 + "defs": { 1498 + "main": { 1499 + "type": "permission-set", 1500 + "permissions": [ 1501 + { 1502 + "type": "permission", 1503 + "resource": "rpc", 1504 + "lxm": ["com.atproto.server.createAccount"], 1505 + "inheritAud": true 1506 + } 1507 + ] 1508 + } 1509 + } 1510 + } 1511 + "#; 1512 + let doc = serde_json::from_str::<LexiconDoc>(json).expect("deserialize"); 1513 + let pset = match doc.defs.get("main").expect("main def") { 1514 + LexUserType::PermissionSet(p) => p, 1515 + _ => panic!("expected PermissionSet"), 1516 + }; 1517 + let result = pset.validate("app.bsky.authBad"); 1518 + assert!(result.is_err(), "rpc lxm out of namespace should fail"); 1519 + match result.unwrap_err() { 1520 + PermissionSetError::NamespaceViolation { resource, .. } => { 1521 + assert_eq!(resource, "com.atproto.server.createAccount"); 1522 + } 1523 + _ => panic!("expected NamespaceViolation"), 1524 + } 877 1525 } 878 1526 }
+2 -30
crates/jacquard-oauth/src/scopes.rs
··· 136 136 pub action: AccountAction, 137 137 } 138 138 139 - /// Account resource types 140 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 141 - pub enum AccountResource { 142 - /// Email access 143 - Email, 144 - /// Repository access 145 - Repo, 146 - /// Status access 147 - Status, 148 - } 149 - 150 - /// Account action permissions 151 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 152 - pub enum AccountAction { 153 - /// Read-only access 154 - Read, 155 - /// Management access (includes read) 156 - Manage, 157 - } 139 + // Re-export from common to avoid duplication and allow use in permission set types 140 + pub use jacquard_common::types::scope_primitives::{AccountAction, AccountResource, RepoAction}; 158 141 159 142 /// Identity scope attributes 160 143 #[derive(Debug, Clone, PartialEq, Eq, Hash)] ··· 394 377 RepoCollection::Nsid(nsid) => RepoCollection::Nsid(nsid.into_static()), 395 378 } 396 379 } 397 - } 398 - 399 - /// Repository actions 400 - #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 401 - pub enum RepoAction { 402 - /// Create records 403 - Create, 404 - /// Update records 405 - Update, 406 - /// Delete records 407 - Delete, 408 380 } 409 381 410 382 /// RPC scope with lexicon method and audience constraints