This repository has no description
0

Configure Feed

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

release: 0.15.0-alpha.2

Nick Gerakines (Jun 26, 2026, 8:44 AM EDT) c8b0a0e2 47b8c4d5

+11196 -3240
+9
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.15.0-alpha.2] - 2026-06-26 11 + ### Changed 12 + - Re-aligned permissioned-data Spaces (`atproto-space`, `atproto-pds`) to the published [0016 "Permissioned Data"](https://github.com/bluesky-social/proposals/blob/06d439e6be9004a086f392008e41acddd1a444ff/0016-permissioned-data/README.md) draft, taking the spec as the source of truth over the reference implementation: LtHash set-hash commits, the delegation-token / space-credential JWT shapes, the OAuth `space:` scope grammar, and the `com.atproto.simplespace` mint-policy / `appAccess` / `managingApp` configuration. 13 + - Unified space-declaration resolution across the OAuth consent screen and the `space:` scope gate behind a shared resolver. 14 + 15 + ### Removed 16 + - Permissioned-data member-sync machinery (`getMemberState` / `getMemberOplog` / `notifyMembership`); member-list management (`addMember` / `removeMember` / `listMembers`) is retained. 17 + 10 18 ## [0.15.0-alpha.1] - 2026-05-09 11 19 ### Added 12 20 - AT Protocol PDS + permissioned-data Spaces (alpha-ready) — new `atproto-pds` and `atproto-space` crates introducing a Personal Data Server implementation and permissioned-data Space primitives (commits, credentials, recon, set hashing, members, repo, storage). ··· 242 250 - Core DID document handling 243 251 - Cryptographic key operations for P-256 curves 244 252 253 + [0.15.0-alpha.2]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.15.0-alpha.2 245 254 [0.15.0-alpha.1]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.15.0-alpha.1 246 255 [0.14.6]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.6 247 256 [0.14.5]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.5
+39 -21
Cargo.lock
··· 170 170 171 171 [[package]] 172 172 name = "atpmcp" 173 - version = "0.15.0-alpha.1" 173 + version = "0.15.0-alpha.2" 174 174 dependencies = [ 175 175 "anyhow", 176 176 "async-trait", ··· 195 195 196 196 [[package]] 197 197 name = "atproto-attestation" 198 - version = "0.15.0-alpha.1" 198 + version = "0.15.0-alpha.2" 199 199 dependencies = [ 200 200 "anyhow", 201 201 "async-trait", ··· 221 221 222 222 [[package]] 223 223 name = "atproto-client" 224 - version = "0.15.0-alpha.1" 224 + version = "0.15.0-alpha.2" 225 225 dependencies = [ 226 226 "anyhow", 227 227 "async-trait", ··· 245 245 246 246 [[package]] 247 247 name = "atproto-dasl" 248 - version = "0.15.0-alpha.1" 248 + version = "0.15.0-alpha.2" 249 249 dependencies = [ 250 250 "anyhow", 251 251 "axum", ··· 272 272 273 273 [[package]] 274 274 name = "atproto-extras" 275 - version = "0.15.0-alpha.1" 275 + version = "0.15.0-alpha.2" 276 276 dependencies = [ 277 277 "anyhow", 278 278 "async-trait", ··· 287 287 288 288 [[package]] 289 289 name = "atproto-identity" 290 - version = "0.15.0-alpha.1" 290 + version = "0.15.0-alpha.2" 291 291 dependencies = [ 292 292 "anyhow", 293 293 "async-trait", ··· 319 319 320 320 [[package]] 321 321 name = "atproto-jetstream" 322 - version = "0.15.0-alpha.1" 322 + version = "0.15.0-alpha.2" 323 323 dependencies = [ 324 324 "anyhow", 325 325 "async-trait", ··· 341 341 342 342 [[package]] 343 343 name = "atproto-lexicon" 344 - version = "0.15.0-alpha.1" 344 + version = "0.15.0-alpha.2" 345 345 dependencies = [ 346 346 "anyhow", 347 347 "async-trait", ··· 367 367 368 368 [[package]] 369 369 name = "atproto-oauth" 370 - version = "0.15.0-alpha.1" 370 + version = "0.15.0-alpha.2" 371 371 dependencies = [ 372 372 "anyhow", 373 373 "async-trait", ··· 398 398 399 399 [[package]] 400 400 name = "atproto-oauth-aip" 401 - version = "0.15.0-alpha.1" 401 + version = "0.15.0-alpha.2" 402 402 dependencies = [ 403 403 "anyhow", 404 404 "atproto-identity", ··· 413 413 414 414 [[package]] 415 415 name = "atproto-oauth-axum" 416 - version = "0.15.0-alpha.1" 416 + version = "0.15.0-alpha.2" 417 417 dependencies = [ 418 418 "anyhow", 419 419 "async-trait", ··· 441 441 442 442 [[package]] 443 443 name = "atproto-pds" 444 - version = "0.15.0-alpha.1" 444 + version = "0.15.0-alpha.2" 445 445 dependencies = [ 446 446 "anyhow", 447 447 "argon2", 448 448 "async-trait", 449 449 "atproto-dasl", 450 450 "atproto-identity", 451 + "atproto-lexicon", 451 452 "atproto-oauth", 452 453 "atproto-record", 453 454 "atproto-repo", ··· 494 495 495 496 [[package]] 496 497 name = "atproto-record" 497 - version = "0.15.0-alpha.1" 498 + version = "0.15.0-alpha.2" 498 499 dependencies = [ 499 500 "anyhow", 500 501 "async-trait", ··· 515 516 516 517 [[package]] 517 518 name = "atproto-repo" 518 - version = "0.15.0-alpha.1" 519 + version = "0.15.0-alpha.2" 519 520 dependencies = [ 520 521 "anyhow", 521 522 "atproto-dasl", ··· 535 536 536 537 [[package]] 537 538 name = "atproto-space" 538 - version = "0.15.0-alpha.1" 539 + version = "0.15.0-alpha.2" 539 540 dependencies = [ 540 541 "anyhow", 541 542 "async-trait", ··· 543 544 "atproto-identity", 544 545 "atproto-record", 545 546 "base64", 547 + "blake3", 546 548 "chrono", 547 - "criterion", 548 549 "hkdf 0.13.0", 549 550 "hmac 0.13.0", 550 - "k256", 551 551 "proptest", 552 552 "rand 0.10.0", 553 553 "serde", ··· 561 561 562 562 [[package]] 563 563 name = "atproto-tap" 564 - version = "0.15.0-alpha.1" 564 + version = "0.15.0-alpha.2" 565 565 dependencies = [ 566 566 "atproto-client", 567 567 "atproto-identity", ··· 584 584 585 585 [[package]] 586 586 name = "atproto-xrpcs" 587 - version = "0.15.0-alpha.1" 587 + version = "0.15.0-alpha.2" 588 588 dependencies = [ 589 589 "anyhow", 590 590 "async-trait", ··· 609 609 610 610 [[package]] 611 611 name = "atproto-xrpcs-helloworld" 612 - version = "0.15.0-alpha.1" 612 + version = "0.15.0-alpha.2" 613 613 dependencies = [ 614 614 "anyhow", 615 615 "async-trait", ··· 635 635 636 636 [[package]] 637 637 name = "atpxrpc" 638 - version = "0.15.0-alpha.1" 638 + version = "0.15.0-alpha.2" 639 639 dependencies = [ 640 640 "anyhow", 641 641 "atproto-client", ··· 3106 3106 ] 3107 3107 3108 3108 [[package]] 3109 + name = "lru" 3110 + version = "0.16.3" 3111 + source = "registry+https://github.com/rust-lang/crates.io-index" 3112 + checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" 3113 + dependencies = [ 3114 + "hashbrown 0.16.1", 3115 + ] 3116 + 3117 + [[package]] 3109 3118 name = "lru-slab" 3110 3119 version = "0.1.2" 3111 3120 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4903 4912 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" 4904 4913 dependencies = [ 4905 4914 "serde", 4915 + ] 4916 + 4917 + [[package]] 4918 + name = "smallvec" 4919 + version = "2.0.0-alpha.12" 4920 + source = "registry+https://github.com/rust-lang/crates.io-index" 4921 + checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857" 4922 + dependencies = [ 4923 + "serde_core", 4906 4924 ] 4907 4925 4908 4926 [[package]]
+17 -17
Cargo.toml
··· 35 35 categories = ["command-line-utilities", "web-programming"] 36 36 37 37 [workspace.dependencies] 38 - atproto-attestation = { version = "0.15.0-alpha.1", path = "crates/atproto-attestation" } 39 - atproto-client = { version = "0.15.0-alpha.1", path = "crates/atproto-client" } 40 - atproto-dasl = { version = "0.15.0-alpha.1", path = "crates/atproto-dasl" } 41 - atproto-extras = { version = "0.15.0-alpha.1", path = "crates/atproto-extras" } 42 - atproto-identity = { version = "0.15.0-alpha.1", path = "crates/atproto-identity" } 43 - atproto-jetstream = { version = "0.15.0-alpha.1", path = "crates/atproto-jetstream" } 44 - atproto-lexicon = { version = "0.15.0-alpha.1", path = "crates/atproto-lexicon" } 45 - atproto-oauth = { version = "0.15.0-alpha.1", path = "crates/atproto-oauth" } 46 - atproto-oauth-aip = { version = "0.15.0-alpha.1", path = "crates/atproto-oauth-aip" } 47 - atproto-oauth-axum = { version = "0.15.0-alpha.1", path = "crates/atproto-oauth-axum" } 48 - atproto-pds = { version = "0.15.0-alpha.1", path = "crates/atproto-pds" } 49 - atproto-record = { version = "0.15.0-alpha.1", path = "crates/atproto-record" } 50 - atproto-repo = { version = "0.15.0-alpha.1", path = "crates/atproto-repo" } 51 - atproto-space = { version = "0.15.0-alpha.1", path = "crates/atproto-space" } 52 - atproto-tap = { version = "0.15.0-alpha.1", path = "crates/atproto-tap" } 53 - atproto-xrpcs = { version = "0.15.0-alpha.1", path = "crates/atproto-xrpcs" } 38 + atproto-attestation = { version = "0.15.0-alpha.2", path = "crates/atproto-attestation" } 39 + atproto-client = { version = "0.15.0-alpha.2", path = "crates/atproto-client" } 40 + atproto-dasl = { version = "0.15.0-alpha.2", path = "crates/atproto-dasl" } 41 + atproto-extras = { version = "0.15.0-alpha.2", path = "crates/atproto-extras" } 42 + atproto-identity = { version = "0.15.0-alpha.2", path = "crates/atproto-identity" } 43 + atproto-jetstream = { version = "0.15.0-alpha.2", path = "crates/atproto-jetstream" } 44 + atproto-lexicon = { version = "0.15.0-alpha.2", path = "crates/atproto-lexicon" } 45 + atproto-oauth = { version = "0.15.0-alpha.2", path = "crates/atproto-oauth" } 46 + atproto-oauth-aip = { version = "0.15.0-alpha.2", path = "crates/atproto-oauth-aip" } 47 + atproto-oauth-axum = { version = "0.15.0-alpha.2", path = "crates/atproto-oauth-axum" } 48 + atproto-pds = { version = "0.15.0-alpha.2", path = "crates/atproto-pds" } 49 + atproto-record = { version = "0.15.0-alpha.2", path = "crates/atproto-record" } 50 + atproto-repo = { version = "0.15.0-alpha.2", path = "crates/atproto-repo" } 51 + atproto-space = { version = "0.15.0-alpha.2", path = "crates/atproto-space" } 52 + atproto-tap = { version = "0.15.0-alpha.2", path = "crates/atproto-tap" } 53 + atproto-xrpcs = { version = "0.15.0-alpha.2", path = "crates/atproto-xrpcs" } 54 54 55 - atpxrpc = { version = "0.15.0-alpha.1", path = "crates/atpxrpc" } 55 + atpxrpc = { version = "0.15.0-alpha.2", path = "crates/atpxrpc" } 56 56 57 57 bitflags = "2" 58 58 anyhow = "1.0"
+1 -1
Dockerfile
··· 73 73 LABEL org.opencontainers.image.description="AT Protocol identity management tools" 74 74 LABEL org.opencontainers.image.authors="Nick Gerakines <nick.gerakines@gmail.com>" 75 75 LABEL org.opencontainers.image.source="https://tangled.org/ngerakines.me/atproto-crates" 76 - LABEL org.opencontainers.image.version="0.15.0-alpha.1" 76 + LABEL org.opencontainers.image.version="0.15.0-alpha.2" 77 77 LABEL org.opencontainers.image.licenses="MIT" 78 78 79 79 # Document available binaries
+11 -11
README.md
··· 55 55 56 56 ```toml 57 57 [dependencies] 58 - atproto-dasl = "0.15.0-alpha.1" 59 - atproto-identity = "0.15.0-alpha.1" 60 - atproto-attestation = "0.15.0-alpha.1" 61 - atproto-record = "0.15.0-alpha.1" 62 - atproto-repo = "0.15.0-alpha.1" 63 - atproto-lexicon = "0.15.0-alpha.1" 64 - atproto-oauth = "0.15.0-alpha.1" 65 - atproto-oauth-aip = "0.15.0-alpha.1" 66 - atproto-client = "0.15.0-alpha.1" 67 - atproto-extras = "0.15.0-alpha.1" 68 - atproto-tap = "0.15.0-alpha.1" 58 + atproto-dasl = "0.15.0-alpha.2" 59 + atproto-identity = "0.15.0-alpha.2" 60 + atproto-attestation = "0.15.0-alpha.2" 61 + atproto-record = "0.15.0-alpha.2" 62 + atproto-repo = "0.15.0-alpha.2" 63 + atproto-lexicon = "0.15.0-alpha.2" 64 + atproto-oauth = "0.15.0-alpha.2" 65 + atproto-oauth-aip = "0.15.0-alpha.2" 66 + atproto-client = "0.15.0-alpha.2" 67 + atproto-extras = "0.15.0-alpha.2" 68 + atproto-tap = "0.15.0-alpha.2" 69 69 # Add others as needed 70 70 ``` 71 71
+1 -1
crates/atpmcp/Cargo.toml
··· 1 1 [package] 2 2 name = "atpmcp" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol MCP server for DAG-CBOR CID generation" 5 5 homepage = "https://tangled.org/ngerakines.me/atproto-crates" 6 6 documentation = "https://docs.rs/atpmcp"
+1 -1
crates/atproto-attestation/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-attestation" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol attestation utilities for creating and verifying record signatures" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-client/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-client" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "HTTP client for AT Protocol services with OAuth and identity integration" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-dasl/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-dasl" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "DASL (Data-Addressed Structures & Links) implementation for AT Protocol" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-extras/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-extras" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol extras - facet parsing and rich text utilities" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-identity/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-identity" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol identity management - DID resolution, handle resolution, and cryptographic operations" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-identity/src/bin/atproto-identity-plc-audit.rs
··· 487 487 let url = format!("{}/{}/log/audit", plc_url, did); 488 488 489 489 let client = reqwest::Client::builder() 490 - .user_agent("atproto-identity-plc-audit/0.15.0-alpha.1") 490 + .user_agent("atproto-identity-plc-audit/0.15.0-alpha.2") 491 491 .timeout(std::time::Duration::from_secs(30)) 492 492 .build()?; 493 493
+1 -1
crates/atproto-identity/src/bin/atproto-identity-plc-fork-viz.rs
··· 594 594 let url = format!("{}/{}/log/audit", plc_url, did); 595 595 596 596 let client = reqwest::Client::builder() 597 - .user_agent("atproto-identity-plc-fork-viz/0.15.0-alpha.1") 597 + .user_agent("atproto-identity-plc-fork-viz/0.15.0-alpha.2") 598 598 .timeout(std::time::Duration::from_secs(30)) 599 599 .build()?; 600 600
+73
crates/atproto-identity/src/model.rs
··· 251 251 }) 252 252 } 253 253 254 + /// Returns the `publicKeyMultibase` of the Multikey verification method 255 + /// whose id ends with `#{fragment}` (e.g. `atproto` or `atproto_space`). 256 + /// 257 + /// DID documents render verification-method ids as either the absolute 258 + /// `did:plc:xxx#fragment` or the relative `#fragment`; both forms match. 259 + pub fn verification_method_multibase(&self, fragment: &str) -> Option<&str> { 260 + let suffix = format!("#{fragment}"); 261 + self.verification_method 262 + .iter() 263 + .find_map(|method| match method { 264 + VerificationMethod::Multikey { 265 + id, 266 + public_key_multibase, 267 + .. 268 + } if id.ends_with(&suffix) => Some(public_key_multibase.as_str()), 269 + _ => None, 270 + }) 271 + } 272 + 273 + /// Returns the endpoint of the service entry whose id ends with 274 + /// `#{fragment}` (e.g. `atproto_pds` or `atproto_space_host`). 275 + /// 276 + /// Service ids are rendered as either the absolute `did:plc:xxx#fragment` 277 + /// or the relative `#fragment`; both forms match. 278 + pub fn service_endpoint(&self, fragment: &str) -> Option<&str> { 279 + let suffix = format!("#{fragment}"); 280 + self.service 281 + .iter() 282 + .find(|svc| svc.id.ends_with(&suffix)) 283 + .map(|svc| svc.service_endpoint.as_str()) 284 + } 285 + 254 286 /// Extracts multibase public keys from verification methods. 255 287 /// Returns public keys from Multikey verification methods only. 256 288 pub fn did_keys(&self) -> Vec<&str> { ··· 359 391 360 392 assert_eq!(doc.service.len(), 1); 361 393 assert_eq!(doc.service[0].r#type, "CustomService"); 394 + } 395 + 396 + #[test] 397 + fn test_verification_method_and_service_lookup_by_fragment() { 398 + // A DID document exposing both the public-data entries and the 399 + // dedicated 0016 space entries (lines 87-92). 400 + let document = serde_json::from_str::<Document>( 401 + r##"{ 402 + "id":"did:plc:authority", 403 + "verificationMethod":[ 404 + {"id":"did:plc:authority#atproto","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zATPROTO"}, 405 + {"id":"#atproto_space","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zSPACE"} 406 + ], 407 + "service":[ 408 + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}, 409 + {"id":"did:plc:authority#atproto_space_host","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://host.example.com"} 410 + ] 411 + }"##, 412 + ) 413 + .expect("document parses"); 414 + 415 + // Absolute-form id and relative-form id both resolve by fragment. 416 + assert_eq!( 417 + document.verification_method_multibase("atproto"), 418 + Some("zATPROTO") 419 + ); 420 + assert_eq!( 421 + document.verification_method_multibase("atproto_space"), 422 + Some("zSPACE") 423 + ); 424 + assert_eq!(document.verification_method_multibase("missing"), None); 425 + 426 + assert_eq!( 427 + document.service_endpoint("atproto_pds"), 428 + Some("https://pds.example.com") 429 + ); 430 + assert_eq!( 431 + document.service_endpoint("atproto_space_host"), 432 + Some("https://host.example.com") 433 + ); 434 + assert_eq!(document.service_endpoint("missing"), None); 362 435 } 363 436 364 437 #[test]
+1 -1
crates/atproto-jetstream/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-jetstream" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol Jetstream event consumer library with WebSocket streaming and compression support" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-lexicon/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-lexicon" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol lexicon resolution and validation" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-lexicon/README.md
··· 27 27 28 28 ```toml 29 29 [dependencies] 30 - atproto-lexicon = "0.15.0-alpha.1" 30 + atproto-lexicon = "0.15.0-alpha.2" 31 31 ``` 32 32 33 33 ## Usage
+33
crates/atproto-lexicon/src/validation/data_errors.rs
··· 521 521 /// The expected namespace. 522 522 namespace: String, 523 523 }, 524 + 525 + /// A space definition has a `name` whose length is outside the 1..=64 range. 526 + #[error( 527 + "error-atproto-lexicon-data-validation-56 Space name length out of range: expected 1..=64, got {length}" 528 + )] 529 + SpaceNameLengthInvalid { 530 + /// The actual length of the name. 531 + length: usize, 532 + }, 533 + 534 + /// A space definition's collection NSID is invalid. 535 + #[error( 536 + "error-atproto-lexicon-data-validation-57 Space has invalid collection NSID '{nsid}': {reason}" 537 + )] 538 + SpaceInvalidCollectionNsid { 539 + /// The invalid NSID string. 540 + nsid: String, 541 + /// Description of why the NSID is invalid. 542 + reason: String, 543 + }, 544 + 545 + /// A space permission is missing the required `spaceType` field. 546 + #[error( 547 + "error-atproto-lexicon-data-validation-58 Space permission missing required 'spaceType' field" 548 + )] 549 + SpacePermissionMissingSpaceType, 550 + 551 + /// A space permission uses the `*` wildcard for `spaceType`, which is not 552 + /// allowed inside a permission set. 553 + #[error( 554 + "error-atproto-lexicon-data-validation-59 Space permission 'spaceType' must not be the '*' wildcard" 555 + )] 556 + SpacePermissionWildcardSpaceType, 524 557 }
+138 -2
crates/atproto-lexicon/src/validation/schema/mod.rs
··· 21 21 /// Permission set 22 22 #[serde(rename = "permission-set")] 23 23 PermissionSet(PermissionSetSchema), 24 + /// Space (permissioned-data space type definition) 25 + Space(SpaceSchema), 24 26 /// Boolean type 25 27 Boolean(BooleanSchema), 26 28 /// Integer type ··· 59 61 SchemaDef::Procedure(_) => "procedure", 60 62 SchemaDef::Subscription(_) => "subscription", 61 63 SchemaDef::PermissionSet(_) => "permission-set", 64 + SchemaDef::Space(_) => "space", 62 65 SchemaDef::Boolean(_) => "boolean", 63 66 SchemaDef::Integer(_) => "integer", 64 67 SchemaDef::String(_) => "string", ··· 75 78 } 76 79 } 77 80 78 - /// Check if this is a primary type (record, query, procedure, subscription) 81 + /// Check if this is a primary type (record, query, procedure, 82 + /// subscription, permission-set, space) 79 83 pub fn is_primary(&self) -> bool { 80 84 matches!( 81 85 self, ··· 84 88 | SchemaDef::Procedure(_) 85 89 | SchemaDef::Subscription(_) 86 90 | SchemaDef::PermissionSet(_) 91 + | SchemaDef::Space(_) 87 92 ) 88 93 } 89 94 ··· 156 161 } 157 162 // These types don't contain refs 158 163 SchemaDef::PermissionSet(_) 164 + | SchemaDef::Space(_) 159 165 | SchemaDef::Boolean(_) 160 166 | SchemaDef::Integer(_) 161 167 | SchemaDef::String(_) ··· 315 321 /// Whether to inherit audience for rpc resources 316 322 #[serde(rename = "inheritAud", skip_serializing_if = "Option::is_none")] 317 323 pub inherit_aud: Option<bool>, 324 + 325 + /// Space type NSID for `space` resources (the `spaceType` field). 326 + /// 327 + /// Identifies the concrete space type a permission applies to. Inside a 328 + /// permission set this must be a concrete NSID and not the `*` wildcard. 329 + #[serde(rename = "spaceType", skip_serializing_if = "Option::is_none")] 330 + pub space_type: Option<String>, 331 + 332 + /// Owner DID for `space` resources. 333 + /// 334 + /// Scopes the permission to spaces owned by a specific DID. May be the 335 + /// `*` wildcard to match any owner. 336 + #[serde(skip_serializing_if = "Option::is_none")] 337 + pub did: Option<String>, 338 + 339 + /// Record key for `space` resources. 340 + /// 341 + /// Scopes the permission to a specific space instance. May be the `*` 342 + /// wildcard to match any space key. 343 + #[serde(skip_serializing_if = "Option::is_none")] 344 + pub skey: Option<String>, 318 345 } 319 346 320 347 /// Valid resource types for permissions 321 - pub const PERMISSION_RESOURCES: &[&str] = &["repo", "rpc", "blob", "identity", "account"]; 348 + pub const PERMISSION_RESOURCES: &[&str] = &["repo", "rpc", "blob", "identity", "account", "space"]; 322 349 323 350 /// Valid actions for repo permissions 324 351 pub const REPO_ACTIONS: &[&str] = &["create", "update", "delete"]; 352 + 353 + /// Space schema - declares a permissioned-data space type. 354 + /// 355 + /// A space definition must be the `main` definition of its lexicon. The 356 + /// `name` is shown on OAuth consent screens when an application requests 357 + /// access to a space of this type, and `collections` lists the recommended 358 + /// record collections for clients. 359 + #[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] 360 + pub struct SpaceSchema { 361 + /// Description of the space type. 362 + #[serde(skip_serializing_if = "Option::is_none")] 363 + pub description: Option<String>, 364 + 365 + /// Recommended space key (`skey`) type for spaces of this type. 366 + /// 367 + /// Required. Mirrors the [record key 368 + /// types](https://atproto.com/specs/record-key#record-key-type-tid) — e.g. 369 + /// `"tid"`, `"literal:self"`, or `"any"`. A declaration missing `key` fails 370 + /// deserialization (spec line 125). 371 + pub key: String, 372 + 373 + /// Human-readable name for the space type (length 1..=64). 374 + pub name: String, 375 + 376 + /// Localization map for name (language code -> translated name). 377 + #[serde( 378 + rename = "name:lang", 379 + default, 380 + skip_serializing_if = "IndexMap::is_empty" 381 + )] 382 + pub name_lang: IndexMap<String, String>, 383 + 384 + /// Recommended record collection NSIDs for clients of this space type. 385 + /// 386 + /// Required (the field must be present), but may be an empty array. 387 + pub collections: Vec<String>, 388 + } 325 389 326 390 /// Input schema for procedures 327 391 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ··· 748 812 let json = r#"{"type": "cid-link"}"#; 749 813 let schema: SchemaDef = serde_json::from_str(json).unwrap(); 750 814 assert!(matches!(schema, SchemaDef::CidLink(_))); 815 + } 816 + 817 + #[test] 818 + fn test_space_schema_type_name_and_primary() { 819 + let space = SchemaDef::Space(SpaceSchema { 820 + name: "Example".to_string(), 821 + ..Default::default() 822 + }); 823 + assert_eq!(space.type_name(), "space"); 824 + assert!(space.is_primary()); 825 + } 826 + 827 + #[test] 828 + fn test_deserialize_space_schema() { 829 + let json = r#"{ 830 + "type": "space", 831 + "key": "tid", 832 + "name": "AtmoBoards Forum", 833 + "description": "A discussion forum", 834 + "name:lang": {"es": "Foro AtmoBoards"}, 835 + "collections": ["com.atmoboards.thread", "com.atmoboards.reply"] 836 + }"#; 837 + let schema: SchemaDef = serde_json::from_str(json).unwrap(); 838 + if let SchemaDef::Space(s) = schema { 839 + assert_eq!(s.key, "tid"); 840 + assert_eq!(s.name, "AtmoBoards Forum"); 841 + assert_eq!(s.description, Some("A discussion forum".to_string())); 842 + assert_eq!(s.name_lang.get("es"), Some(&"Foro AtmoBoards".to_string())); 843 + assert_eq!(s.collections.len(), 2); 844 + } else { 845 + panic!("Expected Space schema"); 846 + } 847 + } 848 + 849 + #[test] 850 + fn test_space_schema_round_trip() { 851 + let json = r#"{"type":"space","key":"tid","name":"Example Space","collections":["com.example.thing"]}"#; 852 + let schema: SchemaDef = serde_json::from_str(json).unwrap(); 853 + let serialized = serde_json::to_string(&schema).unwrap(); 854 + let reparsed: SchemaDef = serde_json::from_str(&serialized).unwrap(); 855 + assert_eq!(schema, reparsed); 856 + } 857 + 858 + #[test] 859 + fn test_space_schema_missing_name_fails() { 860 + let json = r#"{"type": "space", "key": "tid", "collections": []}"#; 861 + assert!(serde_json::from_str::<SchemaDef>(json).is_err()); 862 + } 863 + 864 + #[test] 865 + fn test_space_schema_missing_collections_fails() { 866 + let json = r#"{"type": "space", "key": "tid", "name": "Example Space"}"#; 867 + assert!(serde_json::from_str::<SchemaDef>(json).is_err()); 868 + } 869 + 870 + #[test] 871 + fn test_space_schema_missing_key_fails() { 872 + let json = r#"{"type": "space", "name": "Example Space", "collections": []}"#; 873 + assert!(serde_json::from_str::<SchemaDef>(json).is_err()); 874 + } 875 + 876 + #[test] 877 + fn test_space_schema_key_round_trips() { 878 + let json = r#"{"type":"space","key":"literal:self","name":"Profile","collections":[]}"#; 879 + let schema: SchemaDef = serde_json::from_str(json).unwrap(); 880 + if let SchemaDef::Space(s) = &schema { 881 + assert_eq!(s.key, "literal:self"); 882 + } else { 883 + panic!("Expected Space schema"); 884 + } 885 + let serialized = serde_json::to_string(&schema).unwrap(); 886 + assert!(serialized.contains("\"key\":\"literal:self\"")); 751 887 } 752 888 }
+244 -3
crates/atproto-lexicon/src/validation/schema_file.rs
··· 7 7 8 8 use crate::validation::data_errors::DataValidationError; 9 9 use crate::validation::schema::{ 10 - PERMISSION_RESOURCES, Permission, PermissionSetSchema, REPO_ACTIONS, SchemaDef, 10 + PERMISSION_RESOURCES, Permission, PermissionSetSchema, REPO_ACTIONS, SchemaDef, SpaceSchema, 11 11 }; 12 12 use crate::validation::syntax::validate_nsid; 13 13 ··· 86 86 validate_def_name(name)?; 87 87 } 88 88 for def in self.defs.values() { 89 - if let SchemaDef::PermissionSet(ps) = def { 90 - validate_permission_set(ps, &self.id)?; 89 + match def { 90 + SchemaDef::PermissionSet(ps) => validate_permission_set(ps, &self.id)?, 91 + SchemaDef::Space(space) => validate_space(space)?, 92 + _ => {} 91 93 } 92 94 } 93 95 Ok(()) ··· 204 206 "rpc" => { 205 207 validate_rpc_permission(permission, namespace)?; 206 208 } 209 + "space" => { 210 + validate_space_permission(permission, namespace)?; 211 + } 207 212 _ => {} 213 + } 214 + Ok(()) 215 + } 216 + 217 + /// Validate a `space` permission entry. 218 + /// 219 + /// The `collection` list may be wildcard (`*`) or list collections under a 220 + /// different namespace authority than the space and the permission set, so 221 + /// collection NSIDs are not constrained here (spec line 465, final sentence). 222 + /// 223 + /// `spaceType` must be present, must not be the `*` wildcard (a permission set 224 + /// must target a concrete space type), and must obey the permission set's 225 + /// [Namespace Authority](https://atproto.com/specs/permission#namespace-authority) 226 + /// requirement — it must fall under the set's `namespace` (spec line 465), the 227 + /// same constraint enforced for repo `collection` and rpc `lxm`. 228 + fn validate_space_permission( 229 + permission: &Permission, 230 + namespace: &str, 231 + ) -> Result<(), DataValidationError> { 232 + let space_type = permission 233 + .space_type 234 + .as_ref() 235 + .ok_or(DataValidationError::SpacePermissionMissingSpaceType)?; 236 + if space_type == "*" { 237 + return Err(DataValidationError::SpacePermissionWildcardSpaceType); 238 + } 239 + if !nsid_in_namespace(space_type, namespace) { 240 + return Err(DataValidationError::PermissionNsidOutsideNamespace { 241 + nsid: space_type.clone(), 242 + namespace: namespace.to_string(), 243 + }); 244 + } 245 + Ok(()) 246 + } 247 + 248 + /// Validate a `space` definition. 249 + /// 250 + /// Enforces that `name` has length 1..=64 and that each `collections` entry 251 + /// is a valid NSID. The `collections` array itself may be empty. 252 + fn validate_space(space: &SpaceSchema) -> Result<(), DataValidationError> { 253 + let len = space.name.len(); 254 + if !(1..=64).contains(&len) { 255 + return Err(DataValidationError::SpaceNameLengthInvalid { length: len }); 256 + } 257 + for nsid in &space.collections { 258 + if let Err(e) = validate_nsid(nsid) { 259 + return Err(DataValidationError::SpaceInvalidCollectionNsid { 260 + nsid: nsid.clone(), 261 + reason: e.to_string(), 262 + }); 263 + } 208 264 } 209 265 Ok(()) 210 266 } ··· 680 736 #[test] 681 737 fn test_extract_namespace_no_dots() { 682 738 assert_eq!(extract_namespace("nodots"), "nodots"); 739 + } 740 + 741 + #[test] 742 + fn test_parse_space_main() { 743 + let json = r#"{"lexicon": 1, "id": "com.atmoboards.forum", "defs": {"main": {"type": "space", "key": "tid", "name": "AtmoBoards Forum", "description": "A discussion forum", "name:lang": {"es": "Foro AtmoBoards", "ja": "AtmoBoards 掲示板"}, "collections": ["com.atmoboards.thread", "com.atmoboards.reply"]}}}"#; 744 + let schema = SchemaFile::parse(json).unwrap(); 745 + assert_eq!(schema.id, "com.atmoboards.forum"); 746 + if let Some(SchemaDef::Space(space)) = schema.main() { 747 + assert_eq!(space.key, "tid"); 748 + assert_eq!(space.name, "AtmoBoards Forum"); 749 + assert_eq!(space.collections.len(), 2); 750 + assert_eq!( 751 + space.name_lang.get("es"), 752 + Some(&"Foro AtmoBoards".to_string()) 753 + ); 754 + } else { 755 + panic!("Expected Space schema"); 756 + } 757 + } 758 + 759 + #[test] 760 + fn test_space_main_round_trip() { 761 + let json = r#"{"lexicon":1,"id":"com.atmoboards.forum","defs":{"main":{"type":"space","key":"tid","name":"AtmoBoards Forum","collections":["com.atmoboards.thread"]}}}"#; 762 + let schema = SchemaFile::parse(json).unwrap(); 763 + let serialized = serde_json::to_string(&schema).unwrap(); 764 + let reparsed = SchemaFile::parse(&serialized).unwrap(); 765 + assert_eq!(schema, reparsed); 766 + } 767 + 768 + #[test] 769 + fn test_space_empty_collections_allowed() { 770 + let json = r#"{"lexicon": 1, "id": "com.example.empty", "defs": {"main": {"type": "space", "key": "tid", "name": "Empty Space", "collections": []}}}"#; 771 + let schema = SchemaFile::parse(json).unwrap(); 772 + if let Some(SchemaDef::Space(space)) = schema.main() { 773 + assert!(space.collections.is_empty()); 774 + } else { 775 + panic!("Expected Space schema"); 776 + } 777 + } 778 + 779 + #[test] 780 + fn test_space_must_be_main() { 781 + let json = r#"{"lexicon": 1, "id": "com.example.space", "defs": {"demo": {"type": "space", "key": "tid", "name": "Example Space", "collections": []}}}"#; 782 + let result = SchemaFile::parse(json); 783 + assert!(result.is_err()); 784 + assert!( 785 + result 786 + .unwrap_err() 787 + .to_string() 788 + .contains("must be named 'main'") 789 + ); 790 + } 791 + 792 + #[test] 793 + fn test_space_name_empty_rejected() { 794 + let json = r#"{"lexicon": 1, "id": "com.example.space", "defs": {"main": {"type": "space", "key": "tid", "name": "", "collections": []}}}"#; 795 + let result = SchemaFile::parse(json); 796 + assert!(matches!( 797 + result, 798 + Err(DataValidationError::SpaceNameLengthInvalid { length: 0 }) 799 + )); 800 + } 801 + 802 + #[test] 803 + fn test_space_name_too_long_rejected() { 804 + let name = "a".repeat(65); 805 + let json = format!( 806 + r#"{{"lexicon": 1, "id": "com.example.space", "defs": {{"main": {{"type": "space", "key": "tid", "name": "{}", "collections": []}}}}}}"#, 807 + name 808 + ); 809 + let result = SchemaFile::parse(&json); 810 + assert!(matches!( 811 + result, 812 + Err(DataValidationError::SpaceNameLengthInvalid { length: 65 }) 813 + )); 814 + } 815 + 816 + #[test] 817 + fn test_space_name_max_length_allowed() { 818 + let name = "a".repeat(64); 819 + let json = format!( 820 + r#"{{"lexicon": 1, "id": "com.example.space", "defs": {{"main": {{"type": "space", "key": "tid", "name": "{}", "collections": []}}}}}}"#, 821 + name 822 + ); 823 + assert!(SchemaFile::parse(&json).is_ok()); 824 + } 825 + 826 + #[test] 827 + fn test_space_invalid_collection_nsid() { 828 + let json = r#"{"lexicon": 1, "id": "com.example.space", "defs": {"main": {"type": "space", "key": "tid", "name": "Example Space", "collections": ["not-a-valid-nsid"]}}}"#; 829 + let result = SchemaFile::parse(json); 830 + assert!(matches!( 831 + result, 832 + Err(DataValidationError::SpaceInvalidCollectionNsid { .. }) 833 + )); 834 + } 835 + 836 + #[test] 837 + fn test_space_missing_key_rejected() { 838 + // `key` is a required field (spec line 125); a declaration omitting it 839 + // must fail to parse. 840 + let json = r#"{"lexicon": 1, "id": "com.example.space", "defs": {"main": {"type": "space", "name": "Example Space", "collections": []}}}"#; 841 + assert!(SchemaFile::parse(json).is_err()); 842 + } 843 + 844 + #[test] 845 + fn test_space_key_round_trips_through_file() { 846 + let json = r#"{"lexicon":1,"id":"com.example.space","defs":{"main":{"type":"space","key":"any","name":"Example Space","collections":[]}}}"#; 847 + let schema = SchemaFile::parse(json).unwrap(); 848 + if let Some(SchemaDef::Space(space)) = schema.main() { 849 + assert_eq!(space.key, "any"); 850 + } else { 851 + panic!("Expected Space schema"); 852 + } 853 + } 854 + 855 + #[test] 856 + fn test_permission_set_space_resource() { 857 + let json = r#"{"lexicon": 1, "id": "com.example.lexicon.perms", "defs": {"main": {"type": "permission-set", "title": "test case", "detail": "test detail", "permissions": [{"type": "permission", "resource": "space", "spaceType": "com.example.lexicon.group", "did": "*", "skey": "*", "collection": ["com.example.calendar.event"], "action": ["read", "create"]}]}}}"#; 858 + let schema = SchemaFile::parse(json).unwrap(); 859 + if let Some(SchemaDef::PermissionSet(ps)) = schema.main() { 860 + assert_eq!(ps.permissions.len(), 1); 861 + assert_eq!(ps.permissions[0].resource, "space"); 862 + assert_eq!( 863 + ps.permissions[0].space_type, 864 + Some("com.example.lexicon.group".to_string()) 865 + ); 866 + assert_eq!(ps.permissions[0].did, Some("*".to_string())); 867 + assert_eq!(ps.permissions[0].skey, Some("*".to_string())); 868 + } else { 869 + panic!("Expected PermissionSet schema"); 870 + } 871 + } 872 + 873 + #[test] 874 + fn test_permission_set_space_resource_round_trip() { 875 + let json = r#"{"lexicon":1,"id":"com.example.lexicon.perms","defs":{"main":{"type":"permission-set","title":"test case","detail":"test detail","permissions":[{"type":"permission","resource":"space","spaceType":"com.example.lexicon.group","action":["read"]}]}}}"#; 876 + let schema = SchemaFile::parse(json).unwrap(); 877 + let serialized = serde_json::to_string(&schema).unwrap(); 878 + let reparsed = SchemaFile::parse(&serialized).unwrap(); 879 + assert_eq!(schema, reparsed); 880 + } 881 + 882 + #[test] 883 + fn test_space_permission_missing_space_type() { 884 + let json = r#"{"lexicon": 1, "id": "com.example.lexicon.perms", "defs": {"main": {"type": "permission-set", "title": "test case", "detail": "test detail", "permissions": [{"type": "permission", "resource": "space", "collection": ["com.example.calendar.event"]}]}}}"#; 885 + let result = SchemaFile::parse(json); 886 + assert!(matches!( 887 + result, 888 + Err(DataValidationError::SpacePermissionMissingSpaceType) 889 + )); 890 + } 891 + 892 + #[test] 893 + fn test_space_permission_wildcard_space_type_rejected() { 894 + let json = r#"{"lexicon": 1, "id": "com.example.lexicon.perms", "defs": {"main": {"type": "permission-set", "title": "test case", "detail": "test detail", "permissions": [{"type": "permission", "resource": "space", "spaceType": "*", "action": ["read"]}]}}}"#; 895 + let result = SchemaFile::parse(json); 896 + assert!(matches!( 897 + result, 898 + Err(DataValidationError::SpacePermissionWildcardSpaceType) 899 + )); 900 + } 901 + 902 + #[test] 903 + fn test_space_permission_space_type_outside_namespace_rejected() { 904 + // The permission set id is `com.example.lexicon.perms` (namespace 905 + // `com.example.lexicon`); a `spaceType` under a different namespace 906 + // authority must be rejected (spec line 465, Namespace Authority). 907 + let json = r#"{"lexicon": 1, "id": "com.example.lexicon.perms", "defs": {"main": {"type": "permission-set", "title": "test case", "detail": "test detail", "permissions": [{"type": "permission", "resource": "space", "spaceType": "com.atmoboards.forum", "action": ["read"]}]}}}"#; 908 + let result = SchemaFile::parse(json); 909 + assert!( 910 + matches!( 911 + result, 912 + Err(DataValidationError::PermissionNsidOutsideNamespace { .. }) 913 + ), 914 + "got: {result:?}" 915 + ); 916 + } 917 + 918 + #[test] 919 + fn test_space_permission_cross_namespace_collection_allowed() { 920 + // The collection list MAY reference NSIDs under a different namespace 921 + // authority than the space and the permission set (spec line 465). 922 + let json = r#"{"lexicon": 1, "id": "com.example.lexicon.perms", "defs": {"main": {"type": "permission-set", "title": "test case", "detail": "test detail", "permissions": [{"type": "permission", "resource": "space", "spaceType": "com.example.lexicon.group", "collection": ["org.other.note"], "action": ["read", "create"]}]}}}"#; 923 + assert!(SchemaFile::parse(json).is_ok()); 683 924 } 684 925 }
+2 -1
crates/atproto-lexicon/src/validation/validate.rs
··· 624 624 | SchemaDef::Query(_) 625 625 | SchemaDef::Procedure(_) 626 626 | SchemaDef::Subscription(_) 627 - | SchemaDef::PermissionSet(_) => Err(DataValidationError::SchemaStructureInvalid { 627 + | SchemaDef::PermissionSet(_) 628 + | SchemaDef::Space(_) => Err(DataValidationError::SchemaStructureInvalid { 628 629 message: format!( 629 630 "primary type '{}' cannot be used as a nested schema", 630 631 schema.type_name()
+1 -1
crates/atproto-oauth-aip/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth-aip" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "ATProtocol AIP OAuth tools" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-oauth-axum/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth-axum" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "Axum web framework integration for AT Protocol OAuth workflows" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-oauth/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-oauth" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "OAuth workflow implementation for AT Protocol - PKCE, DPoP, and secure authentication flows" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+21
crates/atproto-oauth/src/errors.rs
··· 458 458 ResponseBodyObjectParsingFailed, 459 459 } 460 460 461 + /// Error returned when a required OAuth permission scope is missing. 462 + /// 463 + /// Mirrors the reference `ScopeMissingError` from `@atproto/oauth-scopes`. The 464 + /// embedded scope string is the minimal scope that would have satisfied the 465 + /// attempted operation, as produced by the relevant `scope_needed_for` helper. 466 + #[derive(Debug, Clone, PartialEq, Eq, Error)] 467 + #[error("error-atproto-oauth-scope-1 Missing required scope: {scope}")] 468 + pub struct ScopeMissingError { 469 + /// The minimal scope string that would satisfy the attempted operation. 470 + pub scope: String, 471 + } 472 + 473 + impl ScopeMissingError { 474 + /// Create a new [`ScopeMissingError`] for the given required scope string. 475 + pub fn new(scope: impl Into<String>) -> Self { 476 + ScopeMissingError { 477 + scope: scope.into(), 478 + } 479 + } 480 + } 481 + 461 482 /// Error types that can occur when working with OAuth request storage operations 462 483 #[derive(Debug, Error)] 463 484 pub enum OAuthStorageError {
+289
crates/atproto-oauth/src/scopes.rs
··· 21 21 use std::fmt; 22 22 use std::str::FromStr; 23 23 24 + /// `space:` permission scope (AT Protocol permissioned-data spaces). 25 + pub mod space_permission; 26 + 27 + pub use space_permission::{ 28 + SpaceAction, SpaceCollection, SpaceCollections, SpaceDid, SpaceManageTarget, SpaceManageVerb, 29 + SpacePermission, SpaceSkey, SpaceTarget, SpaceType, 30 + }; 31 + 24 32 /// Represents an AT Protocol OAuth scope 25 33 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 26 34 pub enum Scope { ··· 34 42 Repo(RepoScope), 35 43 /// RPC scope for method access 36 44 Rpc(RpcScope), 45 + /// Space scope for permissioned-data space operations 46 + Space(SpacePermission), 37 47 /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used 38 48 Atproto, 39 49 /// Transition scope for migration operations ··· 319 329 "blob", 320 330 "repo", 321 331 "rpc", 332 + "space", 322 333 "atproto", 323 334 "transition", 324 335 "include", ··· 359 370 "blob" => Self::parse_blob(suffix), 360 371 "repo" => Self::parse_repo(suffix), 361 372 "rpc" => Self::parse_rpc(suffix), 373 + "space" => Self::parse_space(suffix), 362 374 "atproto" => Self::parse_atproto(suffix), 363 375 "transition" => Self::parse_transition(suffix), 364 376 "include" => Self::parse_include(suffix), ··· 566 578 Ok(Scope::Rpc(RpcScope { lxm, aud })) 567 579 } 568 580 581 + fn parse_space(suffix: Option<&str>) -> Result<Self, ParseError> { 582 + Ok(Scope::Space(SpacePermission::parse_suffix(suffix)?)) 583 + } 584 + 569 585 fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> { 570 586 if suffix.is_some() { 571 587 return Err(ParseError::InvalidResource( ··· 765 781 } 766 782 } 767 783 } 784 + Scope::Space(scope) => scope.to_scope_string(), 768 785 Scope::Atproto => "atproto".to_string(), 769 786 Scope::Transition(scope) => match scope { 770 787 TransitionScope::Generic => "transition:generic".to_string(), ··· 873 890 874 891 lxm_match && aud_match 875 892 } 893 + // Space scopes only grant themselves (exact match). Subset-based 894 + // reduction is intentionally not performed for spaces; this keeps 895 + // `parse_multiple_reduced` sound (it never drops a distinct grant). 896 + (Scope::Space(a), Scope::Space(b)) => a == b, 876 897 _ => false, 877 898 } 878 899 } ··· 1014 1035 } 1015 1036 1016 1037 impl std::error::Error for ParseError {} 1038 + 1039 + /// A set of granted OAuth scopes that can be queried for permission matches. 1040 + /// 1041 + /// Mirrors the reference `ScopesSet` / `ScopePermissions`: it stores the raw 1042 + /// granted scope strings and, on demand, parses the ones relevant to a given 1043 + /// resource to evaluate whether a request is allowed. 1044 + /// 1045 + /// Scope strings that fail to parse are ignored during matching (they simply 1046 + /// cannot grant anything), matching the reference where `fromString` returning 1047 + /// `null` means the scope does not contribute to a match. 1048 + #[derive(Debug, Clone, Default, PartialEq, Eq)] 1049 + pub struct ScopesSet { 1050 + scopes: Vec<String>, 1051 + } 1052 + 1053 + impl ScopesSet { 1054 + /// Create an empty scope set. 1055 + pub fn new() -> Self { 1056 + ScopesSet::default() 1057 + } 1058 + 1059 + /// Build a scope set from a space-separated OAuth scope string. 1060 + pub fn from_scope_string(scope: &str) -> Self { 1061 + ScopesSet { 1062 + scopes: scope.split_whitespace().map(|s| s.to_string()).collect(), 1063 + } 1064 + } 1065 + 1066 + /// Build a scope set from an iterator of individual scope strings. 1067 + pub fn from_scopes<I, S>(scopes: I) -> Self 1068 + where 1069 + I: IntoIterator<Item = S>, 1070 + S: Into<String>, 1071 + { 1072 + ScopesSet { 1073 + scopes: scopes.into_iter().map(Into::into).collect(), 1074 + } 1075 + } 1076 + 1077 + /// Add a scope string to the set. 1078 + pub fn insert(&mut self, scope: impl Into<String>) { 1079 + self.scopes.push(scope.into()); 1080 + } 1081 + 1082 + /// The raw granted scope strings. 1083 + pub fn scopes(&self) -> &[String] { 1084 + &self.scopes 1085 + } 1086 + 1087 + /// Returns `true` if any granted `space:` scope satisfies the given record 1088 + /// target. An omitted-`collection` grant confers no write targets (the 1089 + /// `spaceType=*` / no-declaration case); use 1090 + /// [`allows_space_with`](Self::allows_space_with) to resolve the collection 1091 + /// default against a space type declaration's collections. 1092 + pub fn allows_space(&self, target: &SpaceTarget) -> bool { 1093 + self.allows_space_with(target, &[]) 1094 + } 1095 + 1096 + /// Like [`allows_space`](Self::allows_space) but resolves the per-grant 1097 + /// `collection` default against `declared` (the space type declaration's 1098 + /// `collections`) per spec line 413. 1099 + pub fn allows_space_with(&self, target: &SpaceTarget, declared: &[String]) -> bool { 1100 + self.scopes.iter().any(|scope| { 1101 + matches!(Scope::parse(scope), Ok(Scope::Space(permission)) if permission.matches_with(target, declared)) 1102 + }) 1103 + } 1104 + 1105 + /// Returns `true` if any granted `space:` scope satisfies the given 1106 + /// space-management target (spec lines 415-419). 1107 + pub fn allows_space_manage(&self, target: &SpaceManageTarget) -> bool { 1108 + self.scopes.iter().any(|scope| { 1109 + matches!(Scope::parse(scope), Ok(Scope::Space(permission)) if permission.matches_manage(target)) 1110 + }) 1111 + } 1112 + 1113 + /// Asserts that some granted `space:` scope satisfies the given record 1114 + /// target, returning a [`ScopeMissingError`](crate::errors::ScopeMissingError) 1115 + /// carrying the minimal scope that would satisfy it otherwise. 1116 + pub fn assert_space( 1117 + &self, 1118 + target: &SpaceTarget, 1119 + ) -> Result<(), crate::errors::ScopeMissingError> { 1120 + self.assert_space_with(target, &[]) 1121 + } 1122 + 1123 + /// Like [`assert_space`](Self::assert_space) but resolves the collection 1124 + /// default against `declared` (spec line 413). 1125 + pub fn assert_space_with( 1126 + &self, 1127 + target: &SpaceTarget, 1128 + declared: &[String], 1129 + ) -> Result<(), crate::errors::ScopeMissingError> { 1130 + if self.allows_space_with(target, declared) { 1131 + Ok(()) 1132 + } else { 1133 + Err(crate::errors::ScopeMissingError::new( 1134 + SpacePermission::scope_needed_for(target), 1135 + )) 1136 + } 1137 + } 1138 + 1139 + /// Asserts that some granted `space:` scope satisfies the given 1140 + /// space-management target (spec lines 415-419). 1141 + pub fn assert_space_manage( 1142 + &self, 1143 + target: &SpaceManageTarget, 1144 + ) -> Result<(), crate::errors::ScopeMissingError> { 1145 + if self.allows_space_manage(target) { 1146 + Ok(()) 1147 + } else { 1148 + Err(crate::errors::ScopeMissingError::new( 1149 + SpacePermission::scope_needed_for_manage(target), 1150 + )) 1151 + } 1152 + } 1153 + } 1017 1154 1018 1155 #[cfg(test)] 1019 1156 mod tests { ··· 2216 2353 let serialized = scope.to_string_normalized(); 2217 2354 let reparsed = Scope::parse(&serialized).unwrap(); 2218 2355 assert_eq!(scope, reparsed); 2356 + } 2357 + 2358 + #[test] 2359 + fn test_space_scope_parsing_dispatch() { 2360 + // The `space` prefix is recognized and dispatched (no longer an 2361 + // UnknownPrefix error). 2362 + let scope = Scope::parse("space:com.example.space").unwrap(); 2363 + assert!(matches!(scope, Scope::Space(_))); 2364 + 2365 + // A bare `space` without a type is an error, not UnknownPrefix. 2366 + assert!(matches!( 2367 + Scope::parse("space"), 2368 + Err(ParseError::MissingResource) 2369 + )); 2370 + } 2371 + 2372 + #[test] 2373 + fn test_space_scope_normalization() { 2374 + let tests = vec![ 2375 + ("space:com.example.space", "space:com.example.space"), 2376 + ("space:*", "space:*"), 2377 + // Explicit defaults stripped. 2378 + ( 2379 + "space:com.example.space?did=*&skey=*", 2380 + "space:com.example.space", 2381 + ), 2382 + ( 2383 + "space:com.example.space?action=read&action=create&action=update&action=delete", 2384 + "space:com.example.space", 2385 + ), 2386 + ( 2387 + "space:com.example.space?did=did:plc:abc&action=read", 2388 + "space:com.example.space?did=did:plc:abc&action=read", 2389 + ), 2390 + // `manage` is a separate parameter, preserved on normalization. 2391 + ( 2392 + "space:com.example.space?manage=update&manage=delete", 2393 + "space:com.example.space?manage=update&manage=delete", 2394 + ), 2395 + ]; 2396 + 2397 + for (input, expected) in tests { 2398 + let scope = Scope::parse(input).unwrap(); 2399 + assert_eq!(scope.to_string_normalized(), expected, "input: {input}"); 2400 + } 2401 + } 2402 + 2403 + #[test] 2404 + fn test_space_scope_grants_self_only() { 2405 + let a = Scope::parse("space:com.example.space?action=read").unwrap(); 2406 + let b = Scope::parse("space:com.example.space?action=read").unwrap(); 2407 + let c = Scope::parse("space:com.example.space?manage=update").unwrap(); 2408 + let account = Scope::parse("account:email").unwrap(); 2409 + 2410 + assert!(a.grants(&b)); 2411 + assert!(!a.grants(&c)); 2412 + assert!(!a.grants(&account)); 2413 + assert!(!account.grants(&a)); 2414 + } 2415 + 2416 + #[test] 2417 + fn test_space_scope_in_serialize_multiple() { 2418 + let scopes = vec![ 2419 + Scope::parse("space:com.example.space?action=read").unwrap(), 2420 + Scope::Atproto, 2421 + Scope::parse("account:email").unwrap(), 2422 + ]; 2423 + assert_eq!( 2424 + Scope::serialize_multiple(&scopes), 2425 + "account:email atproto space:com.example.space?action=read" 2426 + ); 2427 + } 2428 + 2429 + #[test] 2430 + fn test_scopes_set_allows_space() { 2431 + let set = ScopesSet::from_scope_string( 2432 + "atproto space:com.example.space?did=did:plc:abc&skey=s1&collection=com.example.note&action=read&action=create", 2433 + ); 2434 + 2435 + // read is allowed. 2436 + assert!(set.allows_space(&SpaceTarget::new( 2437 + "com.example.space", 2438 + "did:plc:abc", 2439 + "s1", 2440 + SpaceAction::Read, 2441 + ))); 2442 + 2443 + // create on covered collection is allowed. 2444 + assert!(set.allows_space(&SpaceTarget::with_collection( 2445 + "com.example.space", 2446 + "did:plc:abc", 2447 + "s1", 2448 + SpaceAction::Create, 2449 + "com.example.note", 2450 + ))); 2451 + 2452 + // delete is not granted. 2453 + assert!(!set.allows_space(&SpaceTarget::with_collection( 2454 + "com.example.space", 2455 + "did:plc:abc", 2456 + "s1", 2457 + SpaceAction::Delete, 2458 + "com.example.note", 2459 + ))); 2460 + 2461 + // a different space key is not granted. 2462 + assert!(!set.allows_space(&SpaceTarget::new( 2463 + "com.example.space", 2464 + "did:plc:abc", 2465 + "other", 2466 + SpaceAction::Read, 2467 + ))); 2468 + } 2469 + 2470 + #[test] 2471 + fn test_scopes_set_assert_space() { 2472 + let set = ScopesSet::from_scope_string("space:com.example.space?action=read"); 2473 + 2474 + // Satisfied: Ok. 2475 + assert!( 2476 + set.assert_space(&SpaceTarget::new( 2477 + "com.example.space", 2478 + "did:plc:abc", 2479 + "s1", 2480 + SpaceAction::Read, 2481 + )) 2482 + .is_ok() 2483 + ); 2484 + 2485 + // Not satisfied: returns the minimal needed scope. 2486 + let target = SpaceTarget::with_collection( 2487 + "com.example.space", 2488 + "did:plc:abc", 2489 + "s1", 2490 + SpaceAction::Create, 2491 + "com.example.note", 2492 + ); 2493 + let err = set.assert_space(&target).unwrap_err(); 2494 + assert_eq!(err.scope, SpacePermission::scope_needed_for(&target)); 2495 + assert!(err.to_string().contains("error-atproto-oauth-scope-1")); 2496 + } 2497 + 2498 + #[test] 2499 + fn test_scopes_set_ignores_unparseable_scopes() { 2500 + // A non-space and a malformed scope are simply ignored for matching. 2501 + let set = ScopesSet::from_scopes(["account:email", "space:com.example.space?action=read"]); 2502 + assert!(set.allows_space(&SpaceTarget::new( 2503 + "com.example.space", 2504 + "did:plc:abc", 2505 + "s1", 2506 + SpaceAction::Read, 2507 + ))); 2219 2508 } 2220 2509 }
+1392
crates/atproto-oauth/src/scopes/space_permission.rs
··· 1 + //! AT Protocol permissioned-data `space:` OAuth permission scope. 2 + //! 3 + //! This module implements the `space:` scope grammar, parser, formatter, and 4 + //! request-time matcher per the published **0016 Permissioned Data** spec 5 + //! (OAuth scopes, lines 369-465), choosing the spec prose over the reference 6 + //! implementation wherever they diverge. 7 + //! 8 + //! # Grammar 9 + //! 10 + //! ```text 11 + //! space:<spaceType>[?did=<did>][&skey=<skey>][&collection=<nsid>...][&action=<action>...][&manage=<op>...] 12 + //! ``` 13 + //! 14 + //! - `spaceType`: positional, **required** — a space-type NSID or `*` (any 15 + //! type). 16 + //! - `did`: a DID or `*` (any authority). Default `*`. 17 + //! - `skey`: a non-empty string up to 512 chars, or `*`. Default `*`. 18 + //! - `collection`: multi-valued NSID or `*`. Default is the space type 19 + //! declaration's `collections` (resolved by the consumer); empty when 20 + //! `spaceType=*`. 21 + //! - `action`: multi-valued. Action values are `read_self, read, create, 22 + //! update, delete`. Default (when omitted) is `{read, create, update, 23 + //! delete}` — note **not** `read_self` alone, since `read` is inclusive of 24 + //! `read_self`. 25 + //! - `manage`: multi-valued, a **separate** parameter governing operations on 26 + //! the spaces themselves. Verbs are `create, update, delete`. Default is 27 + //! none — an ordinary record-access grant confers no administrative 28 + //! capability. 29 + //! 30 + //! `did`, `spaceType`, and `skey` select **which spaces** the grant covers. 31 + //! `action` (and `collection`) govern operations on the **records** in those 32 + //! spaces. `manage` governs operations on the **spaces themselves**. 33 + //! 34 + //! # Matching 35 + //! 36 + //! At request time a [`SpaceTarget`] is checked against a granted 37 + //! [`SpacePermission`] (spec lines 401-419): 38 + //! 39 + //! - The tuple `(spaceType, did, skey)` must overlap: each grant component is 40 + //! `*` or equals the target component. 41 + //! - `read` covers every repo in the space and **ignores** `collection`. It 42 + //! also grants `getDelegationToken`. 43 + //! - `read_self` covers only the holder's **own** repo, is **constrained by** 44 + //! `collection`, and does **not** grant `getDelegationToken`. A `read` grant 45 + //! also satisfies a `read_self` request (`read` implies `read_self`). 46 + //! - `create` / `update` / `delete` act on a specific record and are 47 + //! constrained by `collection` (the action must be granted **and** the target 48 + //! collection covered). 49 + //! - A [`SpaceManageTarget`] is checked against the grant's `manage` set: a 50 + //! management verb is permitted when the grant's `manage` set contains that 51 + //! verb. `manage` ignores `collection`. 52 + 53 + use std::collections::BTreeSet; 54 + use std::fmt; 55 + 56 + use super::ParseError; 57 + 58 + /// Maximum allowed length, in characters, of a `skey` parameter value. 59 + const SKEY_MAX_LENGTH: usize = 512; 60 + 61 + /// A single action that may appear in a `space:` scope's `action` list. 62 + /// 63 + /// Per the 0016 spec param table (line 383) the action values are exactly 64 + /// `[read_self, read, create, update, delete]`. `manage` is **not** an action; 65 + /// it is a separate parameter (see [`SpaceManageVerb`]). 66 + /// 67 + /// The variant order is the canonical action order used for formatting: 68 + /// `read_self`, `read`, `create`, `update`, `delete`. 69 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] 70 + pub enum SpaceAction { 71 + /// Read the holder's **own** repo only; collection-constrained; does not 72 + /// grant `getDelegationToken`. A `read` grant also satisfies a `read_self` 73 + /// request. 74 + ReadSelf, 75 + /// Read every repo in the space; ignores `collection`; grants 76 + /// `getDelegationToken`. Implies [`SpaceAction::ReadSelf`]. 77 + Read, 78 + /// Create records in a covered collection. 79 + Create, 80 + /// Update records in a covered collection. 81 + Update, 82 + /// Delete records in a covered collection. 83 + Delete, 84 + } 85 + 86 + impl SpaceAction { 87 + /// The full, canonically ordered action set: `[read_self, read, create, 88 + /// update, delete]`. 89 + pub const ALL: [SpaceAction; 5] = [ 90 + SpaceAction::ReadSelf, 91 + SpaceAction::Read, 92 + SpaceAction::Create, 93 + SpaceAction::Update, 94 + SpaceAction::Delete, 95 + ]; 96 + 97 + /// The default `action` set when the parameter is omitted: `{read, create, 98 + /// update, delete}` (spec line 411). `read` is inclusive of `read_self`, so 99 + /// `read_self` is not listed separately. 100 + pub const DEFAULT: [SpaceAction; 4] = [ 101 + SpaceAction::Read, 102 + SpaceAction::Create, 103 + SpaceAction::Update, 104 + SpaceAction::Delete, 105 + ]; 106 + 107 + /// The lowercase wire form of this action. 108 + pub fn as_str(self) -> &'static str { 109 + match self { 110 + SpaceAction::ReadSelf => "read_self", 111 + SpaceAction::Read => "read", 112 + SpaceAction::Create => "create", 113 + SpaceAction::Update => "update", 114 + SpaceAction::Delete => "delete", 115 + } 116 + } 117 + 118 + /// Parse an action from its wire form, returning [`None`] for unknown 119 + /// values. 120 + pub fn from_wire(value: &str) -> Option<SpaceAction> { 121 + match value { 122 + "read_self" => Some(SpaceAction::ReadSelf), 123 + "read" => Some(SpaceAction::Read), 124 + "create" => Some(SpaceAction::Create), 125 + "update" => Some(SpaceAction::Update), 126 + "delete" => Some(SpaceAction::Delete), 127 + _ => None, 128 + } 129 + } 130 + } 131 + 132 + /// A single verb that may appear in a `space:` scope's `manage` list. 133 + /// 134 + /// `manage` is a separate parameter from `action` (spec line 384). Its verbs 135 + /// `create`, `update`, `delete` apply to the **space itself** rather than to 136 + /// the records in it, and map onto implementation-defined management 137 + /// operations (spec lines 415-419). 138 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] 139 + pub enum SpaceManageVerb { 140 + /// Create a space of the given `spaceType` under the given authority. 141 + Create, 142 + /// Update the space (e.g. `updateSpace`, `addMember`, `removeMember`). 143 + Update, 144 + /// Delete the space. 145 + Delete, 146 + } 147 + 148 + impl SpaceManageVerb { 149 + /// The full, canonically ordered manage-verb set: `[create, update, 150 + /// delete]`. 151 + pub const ALL: [SpaceManageVerb; 3] = [ 152 + SpaceManageVerb::Create, 153 + SpaceManageVerb::Update, 154 + SpaceManageVerb::Delete, 155 + ]; 156 + 157 + /// The lowercase wire form of this verb. 158 + pub fn as_str(self) -> &'static str { 159 + match self { 160 + SpaceManageVerb::Create => "create", 161 + SpaceManageVerb::Update => "update", 162 + SpaceManageVerb::Delete => "delete", 163 + } 164 + } 165 + 166 + /// Parse a manage verb from its wire form, returning [`None`] for unknown 167 + /// values. 168 + pub fn from_wire(value: &str) -> Option<SpaceManageVerb> { 169 + match value { 170 + "create" => Some(SpaceManageVerb::Create), 171 + "update" => Some(SpaceManageVerb::Update), 172 + "delete" => Some(SpaceManageVerb::Delete), 173 + _ => None, 174 + } 175 + } 176 + } 177 + 178 + /// The `type` component of a `space:` scope: a space-type NSID or any (`*`). 179 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 180 + pub enum SpaceType { 181 + /// Matches any space type (wildcard `*`). 182 + All, 183 + /// A specific space-type NSID. 184 + Nsid(String), 185 + } 186 + 187 + /// The `did` component of a `space:` scope: a specific DID or any (`*`). 188 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 189 + pub enum SpaceDid { 190 + /// Matches any owner DID (wildcard `*`). 191 + All, 192 + /// A specific owner DID. 193 + Did(String), 194 + } 195 + 196 + /// The `skey` component of a `space:` scope: a specific space key or any (`*`). 197 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 198 + pub enum SpaceSkey { 199 + /// Matches any space key (wildcard `*`). 200 + All, 201 + /// A specific space key (non-empty, up to 512 chars). 202 + Key(String), 203 + } 204 + 205 + /// A single `collection` value in a `space:` scope: an NSID or any (`*`). 206 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 207 + pub enum SpaceCollection { 208 + /// Matches any collection (wildcard `*`). 209 + All, 210 + /// A specific collection NSID. 211 + Nsid(String), 212 + } 213 + 214 + /// The resolved or unresolved `collection` set of a `space:` scope. 215 + /// 216 + /// When `collection` is omitted, the spec (line 413) defaults it to the 217 + /// collections declared by the space type's declaration — which the scope 218 + /// parser cannot resolve on its own. [`SpaceCollections::Default`] preserves 219 + /// that "use the declaration's collections" intent; the consumer resolves it 220 + /// against the declared collections at enforcement time via 221 + /// [`SpacePermission::collections_resolved_with`]. When `spaceType` is `*` 222 + /// there is no declaration, so the default resolves to the empty set (no write 223 + /// targets). 224 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 225 + pub enum SpaceCollections { 226 + /// `collection` was omitted; defaults to the space type declaration's 227 + /// `collections` (empty when `spaceType=*`). 228 + Default, 229 + /// `collection` was supplied explicitly (normalized; a `*` collapses to a 230 + /// single [`SpaceCollection::All`]). 231 + Explicit(BTreeSet<SpaceCollection>), 232 + } 233 + 234 + /// A parsed `space:` permission scope (0016 spec, lines 373-419). 235 + /// 236 + /// - [`collection`](Self::collection) is [`SpaceCollections::Default`] when the 237 + /// parameter is omitted (resolves to the declaration's collections at 238 + /// enforcement time) or [`SpaceCollections::Explicit`] when supplied. An 239 + /// explicit list containing `*` collapses to a single 240 + /// [`SpaceCollection::All`]; otherwise it is a sorted, deduplicated set. 241 + /// - [`action`](Self::action) is normalized to canonical action order and 242 + /// defaults to [`SpaceAction::DEFAULT`] (`{read, create, update, delete}`) 243 + /// when omitted. 244 + /// - [`manage`](Self::manage) is empty by default; an ordinary record-access 245 + /// grant confers no administrative capability. 246 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 247 + pub struct SpacePermission { 248 + /// The space type (NSID or `*`). 249 + pub space_type: SpaceType, 250 + /// The owner/authority DID (DID or `*`). 251 + pub did: SpaceDid, 252 + /// The space key (string or `*`). 253 + pub skey: SpaceSkey, 254 + /// The covered collections, or [`SpaceCollections::Default`] when omitted. 255 + pub collection: SpaceCollections, 256 + /// The granted record actions, always a subset of [`SpaceAction::ALL`]. 257 + pub action: BTreeSet<SpaceAction>, 258 + /// The granted space-management verbs. Empty by default (no admin grant). 259 + pub manage: BTreeSet<SpaceManageVerb>, 260 + } 261 + 262 + /// A request-time **record** permission check against a granted 263 + /// [`SpacePermission`]. 264 + /// 265 + /// The `collection` field is meaningful for the write actions 266 + /// ([`SpaceAction::Create`], [`SpaceAction::Update`], [`SpaceAction::Delete`]) 267 + /// and for [`SpaceAction::ReadSelf`] reads; for whole-space [`SpaceAction::Read`] 268 + /// it is ignored and may be left as [`None`]. 269 + /// 270 + /// For a read of the holder's own repo, request [`SpaceAction::ReadSelf`] 271 + /// (collection-constrained, also satisfied by a `read` grant). For a read of 272 + /// any repo in the space, request [`SpaceAction::Read`]. Management operations 273 + /// use [`SpaceManageTarget`] instead. 274 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 275 + pub struct SpaceTarget { 276 + /// The space type being accessed (a concrete NSID; `*` is also accepted but 277 + /// only overlaps a grant whose type is also `*`). 278 + pub space_type: String, 279 + /// The authority DID being accessed. 280 + pub did: String, 281 + /// The space key being accessed. 282 + pub skey: String, 283 + /// The action being attempted. 284 + pub action: SpaceAction, 285 + /// The collection being acted on. Required for writes and `read_self`; 286 + /// ignored for whole-space `read`. 287 + pub collection: Option<String>, 288 + } 289 + 290 + impl SpaceTarget { 291 + /// Construct a collection-independent whole-space `read` target. 292 + pub fn new( 293 + space_type: impl Into<String>, 294 + did: impl Into<String>, 295 + skey: impl Into<String>, 296 + action: SpaceAction, 297 + ) -> Self { 298 + SpaceTarget { 299 + space_type: space_type.into(), 300 + did: did.into(), 301 + skey: skey.into(), 302 + action, 303 + collection: None, 304 + } 305 + } 306 + 307 + /// Construct a collection-bound target (`create`, `update`, `delete`, or a 308 + /// `read_self` read). 309 + pub fn with_collection( 310 + space_type: impl Into<String>, 311 + did: impl Into<String>, 312 + skey: impl Into<String>, 313 + action: SpaceAction, 314 + collection: impl Into<String>, 315 + ) -> Self { 316 + SpaceTarget { 317 + space_type: space_type.into(), 318 + did: did.into(), 319 + skey: skey.into(), 320 + action, 321 + collection: Some(collection.into()), 322 + } 323 + } 324 + } 325 + 326 + /// A request-time **space-management** permission check against a granted 327 + /// [`SpacePermission`]. 328 + /// 329 + /// Management operations are governed by the grant's `manage` set, take a verb 330 + /// (`create`/`update`/`delete`) applied to the space itself, and ignore 331 + /// `collection` (spec lines 415-419). 332 + #[derive(Debug, Clone, PartialEq, Eq, Hash)] 333 + pub struct SpaceManageTarget { 334 + /// The space type being managed (a concrete NSID, or `*`). 335 + pub space_type: String, 336 + /// The authority DID being managed. 337 + pub did: String, 338 + /// The space key being managed. 339 + pub skey: String, 340 + /// The management verb being attempted. 341 + pub verb: SpaceManageVerb, 342 + } 343 + 344 + impl SpaceManageTarget { 345 + /// Construct a space-management target. 346 + pub fn new( 347 + space_type: impl Into<String>, 348 + did: impl Into<String>, 349 + skey: impl Into<String>, 350 + verb: SpaceManageVerb, 351 + ) -> Self { 352 + SpaceManageTarget { 353 + space_type: space_type.into(), 354 + did: did.into(), 355 + skey: skey.into(), 356 + verb, 357 + } 358 + } 359 + } 360 + 361 + impl SpacePermission { 362 + /// Parse a `space:` scope from its suffix (everything after `space`). 363 + /// 364 + /// The `suffix` is the remainder of the scope string after the `space` 365 + /// prefix has been stripped, beginning with either `:` (already removed by 366 + /// the caller, so a positional type) or `?` (a query string), matching the 367 + /// suffix handed to the other `Scope::parse_*` helpers. 368 + /// 369 + /// Returns a [`ParseError`] when the grammar or a parameter value is 370 + /// invalid (0016 spec, lines 373-384). 371 + pub(super) fn parse_suffix(suffix: Option<&str>) -> Result<SpacePermission, ParseError> { 372 + // Split the suffix into the positional `type` value and the query 373 + // string. 374 + let (positional, query) = match suffix { 375 + // `space?...` — query only, no positional type. 376 + Some(s) if s.starts_with('?') => (None, Some(&s[1..])), 377 + // `space:<type>` possibly followed by `?<query>`. 378 + Some(s) => { 379 + if let Some(pos) = s.find('?') { 380 + let positional = &s[..pos]; 381 + (Some(positional), Some(&s[pos + 1..])) 382 + } else { 383 + (Some(s), None) 384 + } 385 + } 386 + // Bare `space` — no type, which is invalid (type is required). 387 + None => (None, None), 388 + }; 389 + 390 + let mut params: Vec<(String, String)> = Vec::new(); 391 + if let Some(query) = query 392 + && !query.is_empty() 393 + { 394 + for pair in query.split('&') { 395 + if let Some(eq) = pair.find('=') { 396 + params.push((pair[..eq].to_string(), pair[eq + 1..].to_string())); 397 + } else { 398 + // A key with no `=` is not part of the schema. 399 + return Err(ParseError::InvalidResource(format!( 400 + "space scope has malformed query parameter: {pair}" 401 + ))); 402 + } 403 + } 404 + } 405 + 406 + // Reject any query key that is not part of the schema. The five valid 407 + // query parameters are `did`, `skey`, `collection`, `action`, and 408 + // `manage` (spec lines 373-384). 409 + for (key, _) in &params { 410 + match key.as_str() { 411 + "did" | "skey" | "collection" | "action" | "manage" => {} 412 + "type" => { 413 + // `type` is positional-only; it cannot be supplied as a 414 + // named parameter alongside the positional value. 415 + return Err(ParseError::InvalidResource( 416 + "space scope 'type' must be positional, not a query parameter".to_string(), 417 + )); 418 + } 419 + other => { 420 + return Err(ParseError::InvalidResource(format!( 421 + "space scope has unknown query parameter: {other}" 422 + ))); 423 + } 424 + } 425 + } 426 + 427 + // Resolve `type` (required positional). 428 + let space_type = match positional { 429 + Some(value) if !value.is_empty() => Self::parse_type(value)?, 430 + _ => return Err(ParseError::MissingResource), 431 + }; 432 + 433 + // Resolve `did` (single, default `*`). 434 + let did = match Self::single_param(&params, "did")? { 435 + Some(value) => Self::parse_did(value)?, 436 + None => SpaceDid::All, 437 + }; 438 + 439 + // Resolve `skey` (single, default `*`). 440 + let skey = match Self::single_param(&params, "skey")? { 441 + Some(value) => Self::parse_skey(value)?, 442 + None => SpaceSkey::All, 443 + }; 444 + 445 + // Resolve `collection` (multi). When omitted, the default is the space 446 + // type declaration's `collections`, which the parser cannot resolve; 447 + // represent that intent as `SpaceCollections::Default`. 448 + let mut explicit_collection: BTreeSet<SpaceCollection> = BTreeSet::new(); 449 + let mut saw_collection = false; 450 + for (key, value) in &params { 451 + if key == "collection" { 452 + saw_collection = true; 453 + explicit_collection.insert(Self::parse_collection(value)?); 454 + } 455 + } 456 + let collection = if saw_collection { 457 + normalize_collection(&mut explicit_collection); 458 + SpaceCollections::Explicit(explicit_collection) 459 + } else { 460 + SpaceCollections::Default 461 + }; 462 + 463 + // Resolve `action` (multi, default = {read, create, update, delete}). 464 + let mut action: BTreeSet<SpaceAction> = BTreeSet::new(); 465 + for (key, value) in &params { 466 + if key == "action" { 467 + let parsed = SpaceAction::from_wire(value) 468 + .ok_or_else(|| ParseError::InvalidAction(value.clone()))?; 469 + action.insert(parsed); 470 + } 471 + } 472 + if action.is_empty() { 473 + action.extend(SpaceAction::DEFAULT); 474 + } 475 + 476 + // Resolve `manage` (multi, default = none). 477 + let mut manage: BTreeSet<SpaceManageVerb> = BTreeSet::new(); 478 + for (key, value) in &params { 479 + if key == "manage" { 480 + let parsed = SpaceManageVerb::from_wire(value) 481 + .ok_or_else(|| ParseError::InvalidAction(value.clone()))?; 482 + manage.insert(parsed); 483 + } 484 + } 485 + 486 + Ok(SpacePermission { 487 + space_type, 488 + did, 489 + skey, 490 + collection, 491 + action, 492 + manage, 493 + }) 494 + } 495 + 496 + /// Extract a single-valued parameter, returning an error when it appears 497 + /// more than once (`did` and `skey` are single-valued per the spec param 498 + /// table, line 384). 499 + fn single_param<'a>( 500 + params: &'a [(String, String)], 501 + key: &str, 502 + ) -> Result<Option<&'a str>, ParseError> { 503 + let mut found: Option<&'a str> = None; 504 + for (k, v) in params { 505 + if k == key { 506 + if found.is_some() { 507 + return Err(ParseError::InvalidResource(format!( 508 + "space scope '{key}' may only appear once" 509 + ))); 510 + } 511 + found = Some(v.as_str()); 512 + } 513 + } 514 + Ok(found) 515 + } 516 + 517 + fn parse_type(value: &str) -> Result<SpaceType, ParseError> { 518 + if value == "*" { 519 + Ok(SpaceType::All) 520 + } else { 521 + Ok(SpaceType::Nsid(value.to_string())) 522 + } 523 + } 524 + 525 + fn parse_did(value: &str) -> Result<SpaceDid, ParseError> { 526 + if value == "*" { 527 + Ok(SpaceDid::All) 528 + } else { 529 + Ok(SpaceDid::Did(value.to_string())) 530 + } 531 + } 532 + 533 + fn parse_skey(value: &str) -> Result<SpaceSkey, ParseError> { 534 + if value == "*" { 535 + Ok(SpaceSkey::All) 536 + } else if value.is_empty() || value.chars().count() > SKEY_MAX_LENGTH { 537 + Err(ParseError::InvalidResource(format!( 538 + "space scope 'skey' must be non-empty and at most {SKEY_MAX_LENGTH} characters" 539 + ))) 540 + } else { 541 + Ok(SpaceSkey::Key(value.to_string())) 542 + } 543 + } 544 + 545 + fn parse_collection(value: &str) -> Result<SpaceCollection, ParseError> { 546 + if value == "*" { 547 + Ok(SpaceCollection::All) 548 + } else { 549 + Ok(SpaceCollection::Nsid(value.to_string())) 550 + } 551 + } 552 + 553 + /// Format this permission back into its canonical scope string. 554 + /// 555 + /// Parameters equal to their default are omitted: 556 + /// 557 + /// - `did=*`, `skey=*` are omitted. 558 + /// - `collection` is omitted when [`SpaceCollections::Default`]. 559 + /// - `action` is omitted when it is exactly the default action set 560 + /// (`{read, create, update, delete}`). 561 + /// - `manage` is omitted when empty. 562 + /// 563 + /// The result round-trips through [`SpacePermission::parse_suffix`]. 564 + pub fn to_scope_string(&self) -> String { 565 + let positional = match &self.space_type { 566 + SpaceType::All => "*".to_string(), 567 + SpaceType::Nsid(nsid) => nsid.clone(), 568 + }; 569 + 570 + let mut params: Vec<String> = Vec::new(); 571 + 572 + // did (default `*`). 573 + if let SpaceDid::Did(did) = &self.did { 574 + params.push(format!("did={did}")); 575 + } 576 + 577 + // skey (default `*`). 578 + if let SpaceSkey::Key(skey) = &self.skey { 579 + params.push(format!("skey={skey}")); 580 + } 581 + 582 + // collection (omitted when Default). BTreeSet iteration is sorted. 583 + if let SpaceCollections::Explicit(set) = &self.collection { 584 + for c in set { 585 + match c { 586 + SpaceCollection::All => params.push("collection=*".to_string()), 587 + SpaceCollection::Nsid(nsid) => params.push(format!("collection={nsid}")), 588 + } 589 + } 590 + } 591 + 592 + // action (default = {read, create, update, delete}). BTreeSet iteration 593 + // yields canonical order (read_self, read, create, update, delete). 594 + if !self.is_default_action_set() { 595 + for a in &self.action { 596 + params.push(format!("action={}", a.as_str())); 597 + } 598 + } 599 + 600 + // manage (default = none). 601 + for m in &self.manage { 602 + params.push(format!("manage={}", m.as_str())); 603 + } 604 + 605 + if params.is_empty() { 606 + format!("space:{positional}") 607 + } else { 608 + format!("space:{positional}?{}", params.join("&")) 609 + } 610 + } 611 + 612 + /// Returns `true` when the action set is exactly the default action set 613 + /// (`{read, create, update, delete}`), in which case it is omitted from the 614 + /// formatted scope string. 615 + fn is_default_action_set(&self) -> bool { 616 + self.action.len() == SpaceAction::DEFAULT.len() 617 + && SpaceAction::DEFAULT.iter().all(|a| self.action.contains(a)) 618 + } 619 + 620 + /// Returns `true` when `space_type`/`did`/`skey` of this grant overlap the 621 + /// given target components (each grant component is `*` or equal). 622 + fn tuple_overlaps(&self, space_type: &str, did: &str, skey: &str) -> bool { 623 + if let SpaceType::Nsid(t) = &self.space_type 624 + && t != space_type 625 + { 626 + return false; 627 + } 628 + if let SpaceDid::Did(d) = &self.did 629 + && d != did 630 + { 631 + return false; 632 + } 633 + if let SpaceSkey::Key(s) = &self.skey 634 + && s != skey 635 + { 636 + return false; 637 + } 638 + true 639 + } 640 + 641 + /// Resolve this grant's effective collection set, using `declared` as the 642 + /// default when `collection` was omitted (spec line 413). `declared` should 643 + /// be the space type declaration's `collections`; pass an empty slice when 644 + /// `spaceType=*` (no declaration) — the default then confers no write 645 + /// targets. 646 + pub fn collections_resolved_with(&self, declared: &[String]) -> BTreeSet<SpaceCollection> { 647 + match &self.collection { 648 + SpaceCollections::Explicit(set) => set.clone(), 649 + SpaceCollections::Default => declared 650 + .iter() 651 + .map(|nsid| SpaceCollection::Nsid(nsid.clone())) 652 + .collect(), 653 + } 654 + } 655 + 656 + /// Check whether this granted permission satisfies the given request-time 657 + /// record [`SpaceTarget`], resolving the collection default against the 658 + /// space type's `declared` collections (spec lines 407-413). 659 + /// 660 + /// 1. Tuple gate: `spaceType`, `did`, `skey` must each be `*` or equal the 661 + /// target. 662 + /// 2. `read`: granted if the action set includes `read`; ignores collection. 663 + /// 3. `read_self`: granted if the action set includes `read_self` **or** 664 + /// `read` (read implies read_self) **and** the target collection is 665 + /// covered. 666 + /// 4. `create` / `update` / `delete`: granted if the action set includes 667 + /// the action **and** the target collection is covered. 668 + pub fn matches_with(&self, target: &SpaceTarget, declared: &[String]) -> bool { 669 + if !self.tuple_overlaps(&target.space_type, &target.did, &target.skey) { 670 + return false; 671 + } 672 + 673 + match target.action { 674 + // Whole-space read ignores collection. 675 + SpaceAction::Read => self.action.contains(&SpaceAction::Read), 676 + // Own-repo read: read implies read_self; collection-constrained. 677 + SpaceAction::ReadSelf => { 678 + if !(self.action.contains(&SpaceAction::ReadSelf) 679 + || self.action.contains(&SpaceAction::Read)) 680 + { 681 + return false; 682 + } 683 + // A `read` grant covers every collection for the own repo too. 684 + if self.action.contains(&SpaceAction::Read) { 685 + return true; 686 + } 687 + self.collection_covers(target.collection.as_deref(), declared) 688 + } 689 + // Writes require the action AND collection coverage. 690 + SpaceAction::Create | SpaceAction::Update | SpaceAction::Delete => { 691 + if !self.action.contains(&target.action) { 692 + return false; 693 + } 694 + self.collection_covers(target.collection.as_deref(), declared) 695 + } 696 + } 697 + } 698 + 699 + /// Check whether this granted permission satisfies the given record target, 700 + /// treating an omitted-`collection` grant as conferring **no** write 701 + /// targets (the `spaceType=*` / no-declaration case). Convenience wrapper 702 + /// over [`matches_with`](Self::matches_with) with an empty declared list. 703 + pub fn matches(&self, target: &SpaceTarget) -> bool { 704 + self.matches_with(target, &[]) 705 + } 706 + 707 + /// Whether this grant's collection set (resolved against `declared`) covers 708 + /// the target collection. 709 + fn collection_covers(&self, target: Option<&str>, declared: &[String]) -> bool { 710 + let resolved = self.collections_resolved_with(declared); 711 + if resolved.contains(&SpaceCollection::All) { 712 + return true; 713 + } 714 + match target { 715 + Some(c) => resolved.contains(&SpaceCollection::Nsid(c.to_string())), 716 + None => false, 717 + } 718 + } 719 + 720 + /// Check whether this granted permission satisfies the given space-management 721 + /// [`SpaceManageTarget`] (spec lines 415-419): the tuple must overlap and the 722 + /// grant's `manage` set must contain the requested verb. `manage` ignores 723 + /// `collection`. 724 + pub fn matches_manage(&self, target: &SpaceManageTarget) -> bool { 725 + if !self.tuple_overlaps(&target.space_type, &target.did, &target.skey) { 726 + return false; 727 + } 728 + self.manage.contains(&target.verb) 729 + } 730 + 731 + /// Build the minimal `space:` scope string that would satisfy the given 732 + /// record target. 733 + /// 734 + /// For whole-space `read` the result is collection-independent; for 735 + /// `read_self` and write actions the target's collection is included. 736 + pub fn scope_needed_for(target: &SpaceTarget) -> String { 737 + let space_type = nsid_or_all(&target.space_type); 738 + let did = did_or_all(&target.did); 739 + let skey = skey_or_all(&target.skey); 740 + 741 + let collection_independent = matches!(target.action, SpaceAction::Read); 742 + 743 + let collection = if collection_independent { 744 + SpaceCollections::Explicit(BTreeSet::new()) 745 + } else { 746 + let mut set: BTreeSet<SpaceCollection> = BTreeSet::new(); 747 + if let Some(c) = &target.collection { 748 + set.insert(if c == "*" { 749 + SpaceCollection::All 750 + } else { 751 + SpaceCollection::Nsid(c.clone()) 752 + }); 753 + } 754 + normalize_collection(&mut set); 755 + SpaceCollections::Explicit(set) 756 + }; 757 + 758 + let mut action: BTreeSet<SpaceAction> = BTreeSet::new(); 759 + action.insert(target.action); 760 + 761 + SpacePermission { 762 + space_type, 763 + did, 764 + skey, 765 + collection, 766 + action, 767 + manage: BTreeSet::new(), 768 + } 769 + .to_scope_string() 770 + } 771 + 772 + /// Build the minimal `space:` scope string that would satisfy the given 773 + /// space-management target (a single `manage` verb, collection-independent). 774 + pub fn scope_needed_for_manage(target: &SpaceManageTarget) -> String { 775 + let mut manage: BTreeSet<SpaceManageVerb> = BTreeSet::new(); 776 + manage.insert(target.verb); 777 + 778 + SpacePermission { 779 + space_type: nsid_or_all(&target.space_type), 780 + did: did_or_all(&target.did), 781 + skey: skey_or_all(&target.skey), 782 + // No record actions and no collection — manage only. 783 + collection: SpaceCollections::Explicit(BTreeSet::new()), 784 + action: BTreeSet::new(), 785 + manage, 786 + } 787 + .to_scope_string() 788 + } 789 + } 790 + 791 + /// Convert a target type string into a [`SpaceType`] (`*` → `All`). 792 + fn nsid_or_all(value: &str) -> SpaceType { 793 + if value == "*" { 794 + SpaceType::All 795 + } else { 796 + SpaceType::Nsid(value.to_string()) 797 + } 798 + } 799 + 800 + /// Convert a target DID string into a [`SpaceDid`] (`*` → `All`). 801 + fn did_or_all(value: &str) -> SpaceDid { 802 + if value == "*" { 803 + SpaceDid::All 804 + } else { 805 + SpaceDid::Did(value.to_string()) 806 + } 807 + } 808 + 809 + /// Convert a target skey string into a [`SpaceSkey`] (`*` → `All`). 810 + fn skey_or_all(value: &str) -> SpaceSkey { 811 + if value == "*" { 812 + SpaceSkey::All 813 + } else { 814 + SpaceSkey::Key(value.to_string()) 815 + } 816 + } 817 + 818 + /// Normalize a collection set: if it contains `*`, collapse to just `*`. 819 + /// 820 + /// Deduplication and sorting are already handled by the [`BTreeSet`]. 821 + fn normalize_collection(collection: &mut BTreeSet<SpaceCollection>) { 822 + if collection.len() > 1 && collection.contains(&SpaceCollection::All) { 823 + collection.clear(); 824 + collection.insert(SpaceCollection::All); 825 + } 826 + } 827 + 828 + impl fmt::Display for SpacePermission { 829 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 830 + write!(f, "{}", self.to_scope_string()) 831 + } 832 + } 833 + 834 + #[cfg(test)] 835 + mod tests { 836 + use super::*; 837 + use crate::scopes::Scope; 838 + 839 + /// Parse a full `space:` scope string into a [`SpacePermission`]. 840 + fn parse(scope: &str) -> SpacePermission { 841 + match Scope::parse(scope).unwrap() { 842 + Scope::Space(permission) => permission, 843 + other => panic!("expected space scope, got {other:?}"), 844 + } 845 + } 846 + 847 + fn collections(values: &[SpaceCollection]) -> SpaceCollections { 848 + SpaceCollections::Explicit(values.iter().cloned().collect()) 849 + } 850 + 851 + fn actions(values: &[SpaceAction]) -> BTreeSet<SpaceAction> { 852 + values.iter().copied().collect() 853 + } 854 + 855 + fn manages(values: &[SpaceManageVerb]) -> BTreeSet<SpaceManageVerb> { 856 + values.iter().copied().collect() 857 + } 858 + 859 + #[test] 860 + fn test_parse_minimal_type_only() { 861 + let permission = parse("space:com.example.space"); 862 + assert_eq!( 863 + permission.space_type, 864 + SpaceType::Nsid("com.example.space".to_string()) 865 + ); 866 + // Defaults: did=*, skey=*, collection=Default, action={read,create, 867 + // update,delete} (no read_self/no manage), manage empty. 868 + assert_eq!(permission.did, SpaceDid::All); 869 + assert_eq!(permission.skey, SpaceSkey::All); 870 + assert_eq!(permission.collection, SpaceCollections::Default); 871 + assert_eq!(permission.action, actions(&SpaceAction::DEFAULT)); 872 + assert!(permission.manage.is_empty()); 873 + } 874 + 875 + #[test] 876 + fn test_parse_type_wildcard() { 877 + let permission = parse("space:*"); 878 + assert_eq!(permission.space_type, SpaceType::All); 879 + } 880 + 881 + #[test] 882 + fn test_parse_all_params() { 883 + let permission = parse( 884 + "space:com.example.space?did=did:plc:abc&skey=my-space&collection=com.example.note&collection=com.example.photo&action=read&action=create&manage=update", 885 + ); 886 + assert_eq!( 887 + permission.space_type, 888 + SpaceType::Nsid("com.example.space".to_string()) 889 + ); 890 + assert_eq!(permission.did, SpaceDid::Did("did:plc:abc".to_string())); 891 + assert_eq!(permission.skey, SpaceSkey::Key("my-space".to_string())); 892 + assert_eq!( 893 + permission.collection, 894 + collections(&[ 895 + SpaceCollection::Nsid("com.example.note".to_string()), 896 + SpaceCollection::Nsid("com.example.photo".to_string()), 897 + ]) 898 + ); 899 + assert_eq!( 900 + permission.action, 901 + actions(&[SpaceAction::Read, SpaceAction::Create]) 902 + ); 903 + assert_eq!(permission.manage, manages(&[SpaceManageVerb::Update])); 904 + } 905 + 906 + #[test] 907 + fn test_parse_read_self_action() { 908 + let permission = parse("space:com.example.space?action=read_self"); 909 + assert_eq!(permission.action, actions(&[SpaceAction::ReadSelf])); 910 + } 911 + 912 + #[test] 913 + fn test_parse_manage_is_separate_param() { 914 + // `manage` is its own parameter, distinct from `action`. 915 + let permission = parse("space:com.example.space?manage=create&manage=delete"); 916 + // action keeps its default; manage carries the verbs. 917 + assert_eq!(permission.action, actions(&SpaceAction::DEFAULT)); 918 + assert_eq!( 919 + permission.manage, 920 + manages(&[SpaceManageVerb::Create, SpaceManageVerb::Delete]) 921 + ); 922 + } 923 + 924 + #[test] 925 + fn test_parse_manage_invalid_verb_is_error() { 926 + assert!(Scope::parse("space:com.example.space?manage=read").is_err()); 927 + } 928 + 929 + #[test] 930 + fn test_parse_wildcards_in_params() { 931 + let permission = parse("space:*?did=*&skey=*&collection=*"); 932 + assert_eq!(permission.space_type, SpaceType::All); 933 + assert_eq!(permission.did, SpaceDid::All); 934 + assert_eq!(permission.skey, SpaceSkey::All); 935 + assert_eq!(permission.collection, collections(&[SpaceCollection::All])); 936 + } 937 + 938 + #[test] 939 + fn test_parse_collection_wildcard_collapses() { 940 + // A `*` mixed with NSIDs collapses to just `*`. 941 + let permission = parse("space:com.example.space?collection=com.example.note&collection=*"); 942 + assert_eq!(permission.collection, collections(&[SpaceCollection::All])); 943 + } 944 + 945 + #[test] 946 + fn test_parse_collection_dedup() { 947 + let permission = parse( 948 + "space:com.example.space?collection=com.example.note&collection=com.example.note", 949 + ); 950 + assert_eq!( 951 + permission.collection, 952 + collections(&[SpaceCollection::Nsid("com.example.note".to_string())]) 953 + ); 954 + } 955 + 956 + #[test] 957 + fn test_parse_missing_type_is_error() { 958 + assert!(Scope::parse("space").is_err()); 959 + assert!(Scope::parse("space:").is_err()); 960 + assert!(Scope::parse("space?did=did:plc:abc").is_err()); 961 + } 962 + 963 + #[test] 964 + fn test_parse_unknown_param_is_error() { 965 + assert!(Scope::parse("space:com.example.space?bogus=1").is_err()); 966 + } 967 + 968 + #[test] 969 + fn test_parse_type_as_named_param_is_error() { 970 + assert!(Scope::parse("space:com.example.space?type=other").is_err()); 971 + } 972 + 973 + #[test] 974 + fn test_parse_invalid_action_is_error() { 975 + assert!(Scope::parse("space:com.example.space?action=write").is_err()); 976 + // `manage` is no longer a valid action value (it is its own param). 977 + assert!(Scope::parse("space:com.example.space?action=manage").is_err()); 978 + } 979 + 980 + #[test] 981 + fn test_parse_duplicate_single_param_is_error() { 982 + assert!(Scope::parse("space:com.example.space?did=did:a&did=did:b").is_err()); 983 + assert!(Scope::parse("space:com.example.space?skey=a&skey=b").is_err()); 984 + } 985 + 986 + #[test] 987 + fn test_parse_skey_length_limits() { 988 + // Empty skey is invalid. 989 + assert!(Scope::parse("space:com.example.space?skey=").is_err()); 990 + 991 + // 512 chars is allowed. 992 + let ok = "x".repeat(512); 993 + assert!(Scope::parse(&format!("space:com.example.space?skey={ok}")).is_ok()); 994 + 995 + // 513 chars is rejected. 996 + let too_long = "x".repeat(513); 997 + assert!(Scope::parse(&format!("space:com.example.space?skey={too_long}")).is_err()); 998 + } 999 + 1000 + #[test] 1001 + fn test_format_omits_defaults() { 1002 + // Type-only round-trips to itself; defaults omitted. 1003 + let permission = parse("space:com.example.space"); 1004 + assert_eq!(permission.to_scope_string(), "space:com.example.space"); 1005 + 1006 + // Explicit defaults are stripped on format. 1007 + let permission = parse("space:com.example.space?did=*&skey=*"); 1008 + assert_eq!(permission.to_scope_string(), "space:com.example.space"); 1009 + 1010 + // The default action set ({read,create,update,delete}) is omitted. 1011 + let permission = 1012 + parse("space:com.example.space?action=read&action=create&action=update&action=delete"); 1013 + assert_eq!(permission.to_scope_string(), "space:com.example.space"); 1014 + } 1015 + 1016 + #[test] 1017 + fn test_format_includes_non_defaults() { 1018 + let permission = parse( 1019 + "space:com.example.space?did=did:plc:abc&skey=my-space&collection=com.example.note&action=read", 1020 + ); 1021 + assert_eq!( 1022 + permission.to_scope_string(), 1023 + "space:com.example.space?did=did:plc:abc&skey=my-space&collection=com.example.note&action=read" 1024 + ); 1025 + } 1026 + 1027 + #[test] 1028 + fn test_format_includes_manage() { 1029 + let permission = parse("space:com.example.space?manage=update&manage=delete"); 1030 + assert_eq!( 1031 + permission.to_scope_string(), 1032 + "space:com.example.space?manage=update&manage=delete" 1033 + ); 1034 + } 1035 + 1036 + #[test] 1037 + fn test_round_trip() { 1038 + let cases = [ 1039 + "space:com.example.space", 1040 + "space:*", 1041 + "space:com.example.space?did=did:plc:abc", 1042 + "space:com.example.space?skey=my-space", 1043 + "space:com.example.space?collection=com.example.note", 1044 + "space:com.example.space?collection=*", 1045 + "space:com.example.space?action=read", 1046 + "space:com.example.space?action=read_self", 1047 + "space:com.example.space?action=create&action=update", 1048 + "space:com.example.space?manage=update", 1049 + "space:com.example.space?action=read_self&manage=update&manage=delete", 1050 + "space:com.example.space?did=did:plc:abc&skey=s&collection=a.b.c&action=read&action=create", 1051 + ]; 1052 + 1053 + for case in cases { 1054 + let first = parse(case); 1055 + let formatted = first.to_scope_string(); 1056 + let second = parse(&formatted); 1057 + assert_eq!(first, second, "round-trip mismatch for {case}"); 1058 + // Formatting is idempotent. 1059 + assert_eq!(formatted, second.to_scope_string()); 1060 + } 1061 + } 1062 + 1063 + // --- Matching matrix --- 1064 + 1065 + #[test] 1066 + fn test_match_tuple_gate() { 1067 + let grant = parse("space:com.example.space?did=did:plc:abc&skey=s1"); 1068 + 1069 + // Exact tuple matches for read (read is in default action set). 1070 + assert!(grant.matches(&SpaceTarget::new( 1071 + "com.example.space", 1072 + "did:plc:abc", 1073 + "s1", 1074 + SpaceAction::Read, 1075 + ))); 1076 + 1077 + // Wrong type, did, or skey each block. 1078 + assert!(!grant.matches(&SpaceTarget::new( 1079 + "com.other.space", 1080 + "did:plc:abc", 1081 + "s1", 1082 + SpaceAction::Read, 1083 + ))); 1084 + assert!(!grant.matches(&SpaceTarget::new( 1085 + "com.example.space", 1086 + "did:plc:xyz", 1087 + "s1", 1088 + SpaceAction::Read, 1089 + ))); 1090 + assert!(!grant.matches(&SpaceTarget::new( 1091 + "com.example.space", 1092 + "did:plc:abc", 1093 + "s2", 1094 + SpaceAction::Read, 1095 + ))); 1096 + } 1097 + 1098 + #[test] 1099 + fn test_match_tuple_wildcards() { 1100 + let grant = parse("space:*"); 1101 + // type/did/skey all `*` overlap any concrete tuple. 1102 + assert!(grant.matches(&SpaceTarget::new( 1103 + "anything", 1104 + "did:plc:zzz", 1105 + "whatever", 1106 + SpaceAction::Read, 1107 + ))); 1108 + } 1109 + 1110 + #[test] 1111 + fn test_match_read_does_not_imply_write_or_manage() { 1112 + // A read grant does not confer create. 1113 + let grant = parse("space:com.example.space?action=read"); 1114 + assert!(!grant.matches(&SpaceTarget::with_collection( 1115 + "com.example.space", 1116 + "did:plc:abc", 1117 + "s1", 1118 + SpaceAction::Create, 1119 + "com.example.note", 1120 + ))); 1121 + // A grant without read does not confer whole-space read. 1122 + let grant = parse("space:com.example.space?action=create&collection=com.example.note"); 1123 + assert!(!grant.matches(&SpaceTarget::new( 1124 + "com.example.space", 1125 + "did:plc:abc", 1126 + "s1", 1127 + SpaceAction::Read, 1128 + ))); 1129 + } 1130 + 1131 + #[test] 1132 + fn test_match_read_implies_read_self() { 1133 + // A whole-space `read` grant satisfies a read_self request and is not 1134 + // collection-constrained for the own repo. 1135 + let grant = parse("space:com.example.space?action=read"); 1136 + assert!(grant.matches(&SpaceTarget::with_collection( 1137 + "com.example.space", 1138 + "did:plc:abc", 1139 + "s1", 1140 + SpaceAction::ReadSelf, 1141 + "any.collection.here", 1142 + ))); 1143 + } 1144 + 1145 + #[test] 1146 + fn test_match_read_self_is_collection_constrained() { 1147 + // A read_self grant covers only the listed collection on the own repo. 1148 + let grant = parse("space:com.example.space?action=read_self&collection=com.example.note"); 1149 + assert!(grant.matches(&SpaceTarget::with_collection( 1150 + "com.example.space", 1151 + "did:plc:abc", 1152 + "s1", 1153 + SpaceAction::ReadSelf, 1154 + "com.example.note", 1155 + ))); 1156 + // A different collection is not covered. 1157 + assert!(!grant.matches(&SpaceTarget::with_collection( 1158 + "com.example.space", 1159 + "did:plc:abc", 1160 + "s1", 1161 + SpaceAction::ReadSelf, 1162 + "com.example.photo", 1163 + ))); 1164 + // read_self does NOT confer whole-space read. 1165 + assert!(!grant.matches(&SpaceTarget::new( 1166 + "com.example.space", 1167 + "did:plc:abc", 1168 + "s1", 1169 + SpaceAction::Read, 1170 + ))); 1171 + } 1172 + 1173 + #[test] 1174 + fn test_match_manage_verbs() { 1175 + let grant = parse("space:com.example.space?manage=update"); 1176 + // The granted verb is permitted; collection is ignored. 1177 + assert!(grant.matches_manage(&SpaceManageTarget::new( 1178 + "com.example.space", 1179 + "did:plc:abc", 1180 + "s1", 1181 + SpaceManageVerb::Update, 1182 + ))); 1183 + // A different verb is not. 1184 + assert!(!grant.matches_manage(&SpaceManageTarget::new( 1185 + "com.example.space", 1186 + "did:plc:abc", 1187 + "s1", 1188 + SpaceManageVerb::Create, 1189 + ))); 1190 + } 1191 + 1192 + #[test] 1193 + fn test_match_bare_grant_confers_no_manage() { 1194 + // An ordinary record-access grant confers no administrative capability. 1195 + let grant = parse("space:com.example.space"); 1196 + for verb in SpaceManageVerb::ALL { 1197 + assert!(!grant.matches_manage(&SpaceManageTarget::new( 1198 + "com.example.space", 1199 + "did:plc:abc", 1200 + "s1", 1201 + verb, 1202 + ))); 1203 + } 1204 + } 1205 + 1206 + #[test] 1207 + fn test_match_write_requires_action_and_collection() { 1208 + let grant = parse( 1209 + "space:com.example.space?collection=com.example.note&action=create&action=update", 1210 + ); 1211 + 1212 + // create on the covered collection is granted. 1213 + assert!(grant.matches(&SpaceTarget::with_collection( 1214 + "com.example.space", 1215 + "did:plc:abc", 1216 + "s1", 1217 + SpaceAction::Create, 1218 + "com.example.note", 1219 + ))); 1220 + 1221 + // delete is not in the action list. 1222 + assert!(!grant.matches(&SpaceTarget::with_collection( 1223 + "com.example.space", 1224 + "did:plc:abc", 1225 + "s1", 1226 + SpaceAction::Delete, 1227 + "com.example.note", 1228 + ))); 1229 + 1230 + // create on a different collection is not covered. 1231 + assert!(!grant.matches(&SpaceTarget::with_collection( 1232 + "com.example.space", 1233 + "did:plc:abc", 1234 + "s1", 1235 + SpaceAction::Create, 1236 + "com.example.photo", 1237 + ))); 1238 + } 1239 + 1240 + #[test] 1241 + fn test_match_write_collection_wildcard() { 1242 + let grant = parse("space:com.example.space?collection=*&action=create"); 1243 + assert!(grant.matches(&SpaceTarget::with_collection( 1244 + "com.example.space", 1245 + "did:plc:abc", 1246 + "s1", 1247 + SpaceAction::Create, 1248 + "any.collection.here", 1249 + ))); 1250 + } 1251 + 1252 + #[test] 1253 + fn test_match_collection_default_resolves_to_declared() { 1254 + // A bare grant defaults `collection` to the declared collections. 1255 + let grant = parse("space:com.example.space"); 1256 + assert!(grant.action.contains(&SpaceAction::Create)); 1257 + assert_eq!(grant.collection, SpaceCollections::Default); 1258 + 1259 + let declared = vec!["com.example.note".to_string()]; 1260 + 1261 + // With no declared collections (e.g. spaceType=*), writes are blocked. 1262 + assert!(!grant.matches(&SpaceTarget::with_collection( 1263 + "com.example.space", 1264 + "did:plc:abc", 1265 + "s1", 1266 + SpaceAction::Create, 1267 + "com.example.note", 1268 + ))); 1269 + 1270 + // With the declared collections resolved in, the write is permitted. 1271 + assert!(grant.matches_with( 1272 + &SpaceTarget::with_collection( 1273 + "com.example.space", 1274 + "did:plc:abc", 1275 + "s1", 1276 + SpaceAction::Create, 1277 + "com.example.note", 1278 + ), 1279 + &declared, 1280 + )); 1281 + // A collection outside the declaration is still blocked. 1282 + assert!(!grant.matches_with( 1283 + &SpaceTarget::with_collection( 1284 + "com.example.space", 1285 + "did:plc:abc", 1286 + "s1", 1287 + SpaceAction::Create, 1288 + "com.example.photo", 1289 + ), 1290 + &declared, 1291 + )); 1292 + 1293 + // Whole-space read is allowed regardless of collection. 1294 + assert!(grant.matches(&SpaceTarget::new( 1295 + "com.example.space", 1296 + "did:plc:abc", 1297 + "s1", 1298 + SpaceAction::Read, 1299 + ))); 1300 + } 1301 + 1302 + #[test] 1303 + fn test_collections_resolved_with() { 1304 + let bare = parse("space:com.example.space"); 1305 + let declared = vec!["a.b.c".to_string(), "d.e.f".to_string()]; 1306 + assert_eq!( 1307 + bare.collections_resolved_with(&declared), 1308 + [ 1309 + SpaceCollection::Nsid("a.b.c".to_string()), 1310 + SpaceCollection::Nsid("d.e.f".to_string()) 1311 + ] 1312 + .into_iter() 1313 + .collect() 1314 + ); 1315 + 1316 + // An explicit list ignores the declaration. 1317 + let explicit = parse("space:com.example.space?collection=x.y.z"); 1318 + assert_eq!( 1319 + explicit.collections_resolved_with(&declared), 1320 + [SpaceCollection::Nsid("x.y.z".to_string())] 1321 + .into_iter() 1322 + .collect() 1323 + ); 1324 + } 1325 + 1326 + #[test] 1327 + fn test_match_write_without_target_collection_is_false() { 1328 + let grant = parse("space:com.example.space?collection=com.example.note&action=create"); 1329 + // A write target with no collection cannot match a non-wildcard grant. 1330 + let target = SpaceTarget::new( 1331 + "com.example.space", 1332 + "did:plc:abc", 1333 + "s1", 1334 + SpaceAction::Create, 1335 + ); 1336 + assert!(!grant.matches(&target)); 1337 + } 1338 + 1339 + // --- scope_needed_for --- 1340 + 1341 + #[test] 1342 + fn test_scope_needed_for_read_is_collection_independent() { 1343 + let target = SpaceTarget::new("com.example.space", "did:plc:abc", "s1", SpaceAction::Read); 1344 + assert_eq!( 1345 + SpacePermission::scope_needed_for(&target), 1346 + "space:com.example.space?did=did:plc:abc&skey=s1&action=read" 1347 + ); 1348 + } 1349 + 1350 + #[test] 1351 + fn test_scope_needed_for_manage_is_collection_independent() { 1352 + let target = SpaceManageTarget::new( 1353 + "com.example.space", 1354 + "did:plc:abc", 1355 + "s1", 1356 + SpaceManageVerb::Update, 1357 + ); 1358 + assert_eq!( 1359 + SpacePermission::scope_needed_for_manage(&target), 1360 + "space:com.example.space?did=did:plc:abc&skey=s1&manage=update" 1361 + ); 1362 + } 1363 + 1364 + #[test] 1365 + fn test_scope_needed_for_write_includes_collection() { 1366 + let target = SpaceTarget::with_collection( 1367 + "com.example.space", 1368 + "did:plc:abc", 1369 + "s1", 1370 + SpaceAction::Create, 1371 + "com.example.note", 1372 + ); 1373 + assert_eq!( 1374 + SpacePermission::scope_needed_for(&target), 1375 + "space:com.example.space?did=did:plc:abc&skey=s1&collection=com.example.note&action=create" 1376 + ); 1377 + } 1378 + 1379 + #[test] 1380 + fn test_scope_needed_for_round_trips_and_satisfies() { 1381 + let target = SpaceTarget::with_collection( 1382 + "com.example.space", 1383 + "did:plc:abc", 1384 + "s1", 1385 + SpaceAction::Update, 1386 + "com.example.note", 1387 + ); 1388 + let scope = SpacePermission::scope_needed_for(&target); 1389 + let permission = parse(&scope); 1390 + assert!(permission.matches(&target)); 1391 + } 1392 + }
+2 -5
crates/atproto-pds/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-pds" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol Personal Data Server — server library and `pds` binary" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates" ··· 32 32 [dependencies] 33 33 atproto-dasl.workspace = true 34 34 atproto-identity.workspace = true 35 + atproto-lexicon.workspace = true 35 36 atproto-oauth.workspace = true 36 37 atproto-record.workspace = true 37 38 atproto-repo.workspace = true ··· 128 129 # `metrics` — Prometheus exporter at `/metrics` + axum request-counter 129 130 # middleware. 130 131 metrics = ["dep:prometheus-client"] 131 - # `ecmh` — replaces the placeholder `XorSha256SetHash` with the production 132 - # `EcmhSetHash` (secp256k1 multiset hash) for all Spaces commitments. The 133 - # digest size changes from 32 to 33 bytes; consumers must mirror. 134 - ecmh = ["atproto-space/ecmh"] 135 132 clap = ["dep:clap"] 136 133 hickory-dns = ["atproto-identity/hickory-dns"] 137 134
+13 -17
crates/atproto-pds/README.md
··· 57 57 (RFC 7009), `/oauth/jwks`, `/.well-known/oauth-authorization-server`, 58 58 `/.well-known/oauth-protected-resource`. Multi-key JWK rotation is 59 59 supported via `PDS_OAUTH_KEYS_JWK_SET`. 60 - - **Spaces (`com.atproto.space.*`)** — `createSpace`, `getSpace`, `listSpaces`, 61 - `addMember`, `removeMember`, `getMembers`, `applyWrites` (permissioned), 62 - `getRecord`, `listRecords`, `getRepoState`, `getRepoOplog`, `getMemberState`, 63 - `getMemberOplog`, `getMemberGrant`, `getSpaceCredential` (replay-protected 64 - via the in-memory JTI guard, with optional Valkey/Redis backing). 60 + - **Spaces** — owner-side management under `com.atproto.simplespace.*` 61 + (`createSpace`, `updateSpace`, `deleteSpace`, `addMember`, `removeMember`, 62 + `listMembers`) and the permissioned realm under `com.atproto.space.*` 63 + (`getSpace`, `listSpaces`, `applyWrites`, `createRecord`, `putRecord`, 64 + `deleteRecord`, `getRecord`, `listRecords` (keys-only), `getBlob`, 65 + `listRepos`, `getRepoState`, `listRepoOps`, `getDelegationToken` → 66 + `getSpaceCredential` (the two-step delegation-token/credential exchange, 67 + replay-protected via the in-memory JTI guard with optional Valkey/Redis 68 + backing), `registerNotify`, and the contentless 69 + `notifyWrite`/`notifySpaceDeleted` inbound hooks). Aligned to the published 70 + 0016 Permissioned Data spec. 65 71 - **Admin** (`com.atproto.admin.*`) — `getAccountInfo`, `getAccountInfos`, 66 72 `getSubjectStatus`, `updateSubjectStatus`, `deleteAccount`, 67 73 `searchAccounts`, `getInviteCodes`, `disableInviteCodes`, ··· 81 87 Two compile-time-mutually-exclusive backends for the per-actor store: 82 88 83 89 - **SQLite (default)** — per-actor SQLite files. Matches the upstream 84 - Spaces Design Spec exactly. `cargo build` (or `cargo install`) produces 85 - this profile. 90 + 0016 Permissioned Data draft exactly. `cargo build` (or `cargo install`) 91 + produces this profile. 86 92 - **fjall** — single fjall `Database` per data-dir with one `Keyspace` 87 93 per logical table. Lower-overhead single-host alternative; build with 88 94 `--no-default-features --features fjall,smtp,metrics,hickory-dns`. The ··· 110 116 | `http` | yes | axum router + WebSocket subscribeRepos. | 111 117 | `clap` | | Build the `pds` and `atproto-pds-admin` binaries. | 112 118 | `hickory-dns` | yes | Hickory resolver via `atproto-identity/hickory-dns`. | 113 - | `ecmh` | | Production `EcmhSetHash` over secp256k1 (replaces the placeholder `XorSha256SetHash`). | 114 119 | `smtp` | | SMTP integration via `lettre`. When off, email-issuing endpoints fall back to dev-only INFO logging. | 115 120 | `metrics` | | `prometheus-client` exporter at `GET /metrics` + axum request-counter middleware. | 116 121 | `valkey` | | Valkey/Redis-backed JTI replay guard + sliding-window rate limiter. Wins over `--durability-profile` when `PDS_VALKEY_URL` is set. | ··· 176 181 PDS_POSTGRES_TEST_URL=postgres://pds:pds@127.0.0.1:5432/pds_live \ 177 182 cargo test -p atproto-pds --features postgres-live-tests \ 178 183 --test feature_postgres_live 179 - ``` 180 - 181 - ## Benchmarks 182 - 183 - `atproto-space` ships criterion benches comparing `XorSha256SetHash` and 184 - `EcmhSetHash`: 185 - 186 - ```bash 187 - cargo bench -p atproto-space --features ecmh 188 184 ``` 189 185 190 186 ## License
+6
crates/atproto-pds/migrations/accounts/20260501000001_init.sql
··· 95 95 target_endpoint TEXT NOT NULL, 96 96 payload_cbor BLOB NOT NULL, 97 97 nsid TEXT NOT NULL, 98 + -- Outbound request content-type. notifyWrite is application/json 99 + -- (contentless { space, repo, rev }); notifyMembership is application/cbor. 100 + content_type TEXT NOT NULL DEFAULT 'application/cbor', 101 + -- Pre-minted service-auth bearer (iss = owner DID, aud = target service). 102 + -- NULL for payloads that don't carry service auth. 103 + auth_token TEXT, 98 104 attempt_count INTEGER NOT NULL DEFAULT 0, 99 105 last_attempt_at TEXT, 100 106 next_attempt_at TEXT NOT NULL,
+29 -2
crates/atproto-pds/migrations/actor/20260501000001_init.sql
··· 70 70 uri TEXT PRIMARY KEY, 71 71 is_owner INTEGER NOT NULL DEFAULT 0, 72 72 is_member INTEGER NOT NULL DEFAULT 0, 73 - created_at TEXT NOT NULL 73 + created_at TEXT NOT NULL, 74 + -- simplespace config (com.atproto.simplespace.defs#spaceConfig). 75 + -- mint_policy: 'public' | 'member-list' (default) | 'managing-app'. 76 + mint_policy TEXT NOT NULL DEFAULT 'member-list', 77 + -- app_access: JSON form of the #open / #allowList open union. 78 + app_access TEXT NOT NULL DEFAULT '{"type":"open"}', 79 + -- managing_app: optional service identifier of the managing application. 80 + managing_app TEXT, 81 + -- deleted_at: tombstone timestamp; non-NULL means the space is deleted 82 + -- and all reads/writes must fail with SpaceNotFound. 83 + deleted_at TEXT 74 84 ); 75 85 76 86 CREATE TABLE space_member_state ( ··· 127 137 PRIMARY KEY (space, rev, idx) 128 138 ); 129 139 140 + -- Notify subscriptions (recipients of notifyWrite / notifyMembership fan-out). 141 + -- 142 + -- A row may be registered two ways: 143 + -- * `getSpaceCredential` — owner records the credential consumer so future 144 + -- commits fan out to it (whole-space; `repo` IS NULL). 145 + -- * `registerNotify` — a space credential holder subscribes an endpoint, either 146 + -- to the whole space (`repo` IS NULL, space-host case) or to a single repo 147 + -- (`repo` = that account's DID, repo-host case). 148 + -- 149 + -- `expires_at` is the registration lifetime (RFC 3339). NULL means no expiry 150 + -- (the `getSpaceCredential` self-registration path leaves it unset). Uniqueness 151 + -- is keyed on (space, repo, service_did) so per-repo and whole-space 152 + -- subscriptions for the same service coexist; `repo` uses the empty string as 153 + -- the "whole space" sentinel so it participates in the PRIMARY KEY (SQLite 154 + -- treats NULL as distinct in unique constraints, which would defeat upserts). 130 155 CREATE TABLE space_credential_recipient ( 131 156 space TEXT NOT NULL REFERENCES space(uri) ON DELETE CASCADE, 157 + repo TEXT NOT NULL DEFAULT '', 132 158 service_did TEXT NOT NULL, 133 159 service_endpoint TEXT NOT NULL, 134 160 last_issued_at TEXT NOT NULL, 135 - PRIMARY KEY (space, service_did) 161 + expires_at TEXT, 162 + PRIMARY KEY (space, repo, service_did) 136 163 );
+2
crates/atproto-pds/migrations/postgres/20260507000001_init.sql
··· 111 111 target_endpoint TEXT NOT NULL, 112 112 payload_cbor BYTEA NOT NULL, 113 113 nsid TEXT NOT NULL, 114 + content_type TEXT NOT NULL DEFAULT 'application/cbor', 115 + auth_token TEXT, 114 116 attempt_count BIGINT NOT NULL DEFAULT 0, 115 117 last_attempt_at TEXT, 116 118 next_attempt_at TEXT NOT NULL,
+6 -19
crates/atproto-pds/src/actor_store/fjall/keyspace.rs
··· 281 281 buf 282 282 } 283 283 284 - /// Strict-greater oplog cursor: `<space_uri>\0<rev>\0\xFF` so iteration 285 - /// resumes from the next rev forward. 286 - #[must_use] 287 - pub fn oplog_after_rev(space_uri: &str, rev: &str) -> Vec<u8> { 288 - let mut buf = Vec::with_capacity(space_uri.len() + rev.len() + 3); 289 - buf.extend_from_slice(space_uri.as_bytes()); 290 - buf.push(0); 291 - buf.extend_from_slice(rev.as_bytes()); 292 - buf.push(0); 293 - buf.push(0xFF); 294 - buf 295 - } 296 - 297 284 // --------------------------------------------------------------------------- 298 285 // Public-realm key encodings (§1.2). Each row keys a per-DID prefix so 299 286 // range scans by DID are cheap and a single fjall keyspace can hold all ··· 481 468 } 482 469 483 470 #[test] 484 - fn oplog_after_rev_skips_current_rev() { 485 - let mid = oplog_key("ats://x/y/z", "abc", 0); 486 - let cursor = oplog_after_rev("ats://x/y/z", "abc"); 487 - assert!(cursor > mid); 488 - let next_rev_first = oplog_key("ats://x/y/z", "abd", 0); 489 - assert!(cursor < next_rev_first); 471 + fn oplog_key_orders_across_revs() { 472 + // A composite `(rev, idx)` cursor resumes at the exact op key, so the 473 + // last idx of one rev sorts before the first idx of the next rev. 474 + let rev_a_last = oplog_key("ats://x/y/z", "abc", 99); 475 + let rev_b_first = oplog_key("ats://x/y/z", "abd", 0); 476 + assert!(rev_a_last < rev_b_first); 490 477 } 491 478 }
+6 -68
crates/atproto-pds/src/actor_store/fjall/space_members_storage.rs
··· 1 1 //! `FjallSpaceMembersStorage` — `SpaceMembersStorage` impl over fjall. 2 2 3 - use super::keyspace::{ 4 - FjallActorStore, member_key, member_prefix, oplog_after_rev, oplog_key, oplog_prefix_by_space, 5 - }; 3 + use super::keyspace::{FjallActorStore, member_key, member_prefix, oplog_key}; 6 4 use async_trait::async_trait; 7 5 use atproto_space::SpaceError; 8 6 use atproto_space::errors::SpaceResult; 9 7 use atproto_space::storage::{ 10 - MemberChange, MemberPage, MemberRow, MemberState, OplogEntry, OplogPage, PreparedCommitMembers, 11 - SpaceMembersStorage, 8 + MemberChange, MemberPage, MemberRow, MemberState, PreparedCommitMembers, SpaceMembersStorage, 12 9 }; 13 10 use atproto_space::types::SpaceUri; 14 11 use serde::{Deserialize, Serialize}; ··· 174 171 .map_err(|e| fjall_err(format!("commit batch: {e}")))?; 175 172 Ok(()) 176 173 } 177 - 178 - async fn read_oplog( 179 - &self, 180 - space: &SpaceUri, 181 - since: Option<&str>, 182 - limit: u32, 183 - ) -> SpaceResult<OplogPage> { 184 - let space_uri = space.to_string(); 185 - let limit = limit.clamp(1, 1000) as usize; 186 - let part = self.store.space_member_oplog(); 187 - 188 - let prefix = oplog_prefix_by_space(&space_uri); 189 - let cursor = since.map(|s| oplog_after_rev(&space_uri, s)); 190 - 191 - let mut ops = Vec::with_capacity(limit); 192 - for kv in part.prefix(&prefix) { 193 - let (k, v) = kv 194 - .into_inner() 195 - .map_err(|e| fjall_err(format!("scan oplog: {e}")))?; 196 - if let Some(c) = cursor.as_ref() 197 - && k.as_ref() <= c.as_slice() 198 - { 199 - continue; 200 - } 201 - let suffix = &k[prefix.len()..]; 202 - let null_pos = suffix 203 - .iter() 204 - .position(|b| *b == 0) 205 - .ok_or_else(|| fjall_err("oplog key missing rev separator".to_string()))?; 206 - let rev = std::str::from_utf8(&suffix[..null_pos]) 207 - .map_err(|e| fjall_err(format!("rev utf-8: {e}")))? 208 - .to_string(); 209 - let idx_str = std::str::from_utf8(&suffix[null_pos + 1..]) 210 - .map_err(|e| fjall_err(format!("idx utf-8: {e}")))?; 211 - let idx: u32 = idx_str 212 - .parse() 213 - .map_err(|e| fjall_err(format!("idx parse: {e}")))?; 214 - 215 - let row: MemberOplogRow = decode(&v)?; 216 - ops.push(OplogEntry { 217 - rev, 218 - idx, 219 - action: row.action, 220 - collection: None, 221 - rkey: None, 222 - cid: None, 223 - prev: None, 224 - did: Some(row.did), 225 - }); 226 - if ops.len() >= limit { 227 - break; 228 - } 229 - } 230 - let state = self.current_state(space).await?; 231 - Ok(OplogPage { ops, state }) 232 - } 233 174 } 234 175 235 176 #[cfg(test)] 236 177 mod tests { 237 178 use super::*; 238 - use atproto_space::set_hash::XorSha256SetHash; 179 + use atproto_space::set_hash::LtHash; 239 180 use atproto_space::space_members::{MemberOp, MemberOpAction, SpaceMembers}; 240 181 use atproto_space::types::{SpaceKey, SpaceType}; 241 182 use tempfile::TempDir; ··· 248 189 ) 249 190 } 250 191 251 - type TestMembers = SpaceMembers<FjallSpaceMembersStorage, XorSha256SetHash>; 192 + type TestMembers = SpaceMembers<FjallSpaceMembersStorage, LtHash>; 252 193 253 194 fn fresh() -> (FjallSpaceMembersStorage, TempDir) { 254 195 let tmp = TempDir::new().unwrap(); ··· 287 228 } 288 229 289 230 #[tokio::test(flavor = "multi_thread")] 290 - async fn list_paginates_and_oplog_round_trips() { 231 + async fn list_paginates() { 291 232 let (storage, _tmp) = fresh(); 292 233 let space = test_space(); 293 234 let m: TestMembers = SpaceMembers::new(space.clone(), storage); ··· 306 247 let page = m.list_members(None, 2).await.unwrap(); 307 248 assert_eq!(page.members.len(), 2); 308 249 assert_eq!(page.members[0].did, "did:plc:a"); 309 - 310 - let oplog = m.read_oplog(None, 100).await.unwrap(); 311 - assert_eq!(oplog.ops.len(), 3); 312 - assert!(oplog.ops.iter().all(|o| o.action == "add")); 250 + assert_eq!(page.cursor.as_deref(), Some("did:plc:b")); 313 251 } 314 252 }
+12 -9
crates/atproto-pds/src/actor_store/fjall/space_repo_storage.rs
··· 8 8 //! Key layouts: see [`super::keyspace`]. 9 9 10 10 use super::keyspace::{ 11 - FjallActorStore, oplog_after_rev, oplog_key, oplog_prefix_by_space, record_key, record_prefix, 12 - repo_state_key, 11 + FjallActorStore, oplog_key, oplog_prefix_by_space, record_key, record_prefix, repo_state_key, 13 12 }; 14 13 use async_trait::async_trait; 15 14 use atproto_space::SpaceError; 16 15 use atproto_space::errors::SpaceResult; 17 16 use atproto_space::storage::{ 18 - OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, RepoState, 19 - SpaceRepoStorage, 17 + OplogCursor, OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, 18 + RepoState, SpaceRepoStorage, 20 19 }; 21 20 use atproto_space::types::SpaceUri; 22 21 use serde::{Deserialize, Serialize}; ··· 259 258 async fn read_oplog( 260 259 &self, 261 260 space: &SpaceUri, 262 - since: Option<&str>, 261 + since: Option<&OplogCursor>, 263 262 limit: u32, 264 263 ) -> SpaceResult<OplogPage> { 265 264 let space_uri = space.to_string(); 266 265 let limit = limit.clamp(1, 1000) as usize; 267 266 let part = self.store.space_record_oplog(); 268 267 269 - let cursor = since.map(|s| oplog_after_rev(&space_uri, s)); 268 + // The oplog key encodes `(rev, idx)` with a zero-padded idx, so the 269 + // exact key for the cursor's `(rev, idx)` is the highest key to skip; 270 + // `k <= cursor` yields `(rev, idx) > (since.rev, since.idx)`. 271 + let cursor = since.map(|c| oplog_key(&space_uri, &c.rev, c.idx)); 270 272 let prefix = oplog_prefix_by_space(&space_uri); 271 273 272 274 let mut ops = Vec::with_capacity(limit); ··· 317 319 #[cfg(test)] 318 320 mod tests { 319 321 use super::*; 320 - use atproto_space::set_hash::XorSha256SetHash; 322 + use atproto_space::set_hash::LtHash; 321 323 use atproto_space::space_repo::{Op, OpAction, SpaceRepo}; 322 324 use atproto_space::types::{SpaceKey, SpaceType}; 323 325 use tempfile::TempDir; ··· 330 332 ) 331 333 } 332 334 333 - type TestRepo = SpaceRepo<FjallSpaceRepoStorage, XorSha256SetHash>; 335 + type TestRepo = SpaceRepo<FjallSpaceRepoStorage, LtHash>; 334 336 335 337 fn fresh_storage() -> (FjallSpaceRepoStorage, TempDir) { 336 338 let tmp = TempDir::new().unwrap(); ··· 489 491 ) 490 492 .await 491 493 .unwrap(); 492 - let oplog = repo.read_oplog(Some(&first_rev), 100).await.unwrap(); 494 + let cursor = OplogCursor::new(first_rev, 0); 495 + let oplog = repo.read_oplog(Some(&cursor), 100).await.unwrap(); 493 496 assert_eq!(oplog.ops.len(), 1); 494 497 assert_eq!(oplog.ops[0].rkey.as_deref(), Some("b")); 495 498 }
+5 -57
crates/atproto-pds/src/actor_store/sql/space_members_storage.rs
··· 6 6 use atproto_space::SpaceError; 7 7 use atproto_space::errors::SpaceResult; 8 8 use atproto_space::storage::{ 9 - MemberChange, MemberPage, MemberRow, MemberState, OplogEntry, OplogPage, PreparedCommitMembers, 10 - SpaceMembersStorage, 9 + MemberChange, MemberPage, MemberRow, MemberState, PreparedCommitMembers, SpaceMembersStorage, 11 10 }; 12 11 use atproto_space::types::SpaceUri; 13 12 use sqlx::SqlitePool; ··· 183 182 .map_err(|e| sql_err(format!("apply_commit commit: {e}")))?; 184 183 Ok(()) 185 184 } 186 - 187 - async fn read_oplog( 188 - &self, 189 - space: &SpaceUri, 190 - since: Option<&str>, 191 - limit: u32, 192 - ) -> SpaceResult<OplogPage> { 193 - let limit = limit.clamp(1, 1000); 194 - let rows: Vec<(String, i64, String, String)> = match since { 195 - Some(s) => { 196 - sqlx::query_as( 197 - "SELECT rev, idx, action, did FROM space_member_oplog 198 - WHERE space = ? AND rev > ? ORDER BY rev ASC, idx ASC LIMIT ?", 199 - ) 200 - .bind(space.to_string()) 201 - .bind(s) 202 - .bind(limit as i64) 203 - .fetch_all(&self.pool) 204 - .await 205 - } 206 - None => { 207 - sqlx::query_as( 208 - "SELECT rev, idx, action, did FROM space_member_oplog 209 - WHERE space = ? ORDER BY rev ASC, idx ASC LIMIT ?", 210 - ) 211 - .bind(space.to_string()) 212 - .bind(limit as i64) 213 - .fetch_all(&self.pool) 214 - .await 215 - } 216 - } 217 - .map_err(|e| sql_err(format!("read_oplog: {e}")))?; 218 - let ops = rows 219 - .into_iter() 220 - .map(|(rev, idx, action, did)| OplogEntry { 221 - rev, 222 - idx: idx as u32, 223 - action, 224 - collection: None, 225 - rkey: None, 226 - cid: None, 227 - prev: None, 228 - did: Some(did), 229 - }) 230 - .collect(); 231 - let state = self.current_state(space).await?; 232 - Ok(OplogPage { ops, state }) 233 - } 234 185 } 235 186 236 187 #[cfg(test)] 237 188 mod tests { 238 189 use super::*; 239 190 use crate::actor_store::sql::SqlActorStore; 240 - use atproto_space::set_hash::XorSha256SetHash; 191 + use atproto_space::set_hash::LtHash; 241 192 use atproto_space::space_members::{MemberOp, MemberOpAction, SpaceMembers}; 242 193 use atproto_space::types::{SpaceKey, SpaceType}; 243 194 ··· 254 205 SqlSpaceMembersStorage::new(store.pool().clone()) 255 206 } 256 207 257 - type TestMembers = SpaceMembers<SqlSpaceMembersStorage, XorSha256SetHash>; 208 + type TestMembers = SpaceMembers<SqlSpaceMembersStorage, LtHash>; 258 209 259 210 #[tokio::test(flavor = "multi_thread")] 260 211 async fn add_then_remove() { ··· 286 237 } 287 238 288 239 #[tokio::test(flavor = "multi_thread")] 289 - async fn list_paginates_and_oplog_round_trips() { 240 + async fn list_paginates() { 290 241 let space = test_space(); 291 242 let m: TestMembers = SpaceMembers::new(space.clone(), fresh_storage().await); 292 243 for did in ["did:plc:a", "did:plc:b", "did:plc:c"] { ··· 304 255 let page = m.list_members(None, 2).await.unwrap(); 305 256 assert_eq!(page.members.len(), 2); 306 257 assert_eq!(page.members[0].did, "did:plc:a"); 307 - 308 - let oplog = m.read_oplog(None, 100).await.unwrap(); 309 - assert_eq!(oplog.ops.len(), 3); 310 - assert!(oplog.ops.iter().all(|o| o.action == "add")); 258 + assert_eq!(page.cursor.as_deref(), Some("did:plc:b")); 311 259 } 312 260 }
+70 -14
crates/atproto-pds/src/actor_store/sql/space_repo_storage.rs
··· 8 8 use atproto_space::SpaceError; 9 9 use atproto_space::errors::SpaceResult; 10 10 use atproto_space::storage::{ 11 - OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, RepoState, 12 - SpaceRepoStorage, 11 + OplogCursor, OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, 12 + RepoState, SpaceRepoStorage, 13 13 }; 14 14 use atproto_space::types::SpaceUri; 15 15 use sqlx::SqlitePool; ··· 251 251 async fn read_oplog( 252 252 &self, 253 253 space: &SpaceUri, 254 - since: Option<&str>, 254 + since: Option<&OplogCursor>, 255 255 limit: u32, 256 256 ) -> SpaceResult<OplogPage> { 257 257 let limit = limit.clamp(1, 1000); ··· 264 264 Option<String>, 265 265 Option<String>, 266 266 )> = match since { 267 - Some(s) => { 267 + Some(cur) => { 268 + // `(rev, idx) > (since.rev, since.idx)` so an atomic batch 269 + // (entries sharing a rev) that exceeds `limit` pages fully. 268 270 sqlx::query_as( 269 271 "SELECT rev, idx, action, collection, rkey, cid, prev 270 - FROM space_record_oplog WHERE space = ? AND rev > ? 272 + FROM space_record_oplog 273 + WHERE space = ? AND (rev > ? OR (rev = ? AND idx > ?)) 271 274 ORDER BY rev ASC, idx ASC LIMIT ?", 272 275 ) 273 276 .bind(space.to_string()) 274 - .bind(s) 277 + .bind(&cur.rev) 278 + .bind(&cur.rev) 279 + .bind(cur.idx as i64) 275 280 .bind(limit as i64) 276 281 .fetch_all(&self.pool) 277 282 .await ··· 313 318 mod tests { 314 319 use super::*; 315 320 use crate::actor_store::sql::SqlActorStore; 316 - use atproto_space::set_hash::{SetHash, XorSha256SetHash}; 321 + use atproto_space::set_hash::{LtHash, SetHash}; 317 322 use atproto_space::space_repo::{Op, OpAction, SpaceRepo}; 318 323 use atproto_space::types::{SpaceKey, SpaceType}; 319 324 ··· 330 335 SqlSpaceRepoStorage::new(store.pool().clone()) 331 336 } 332 337 333 - type TestRepo = SpaceRepo<SqlSpaceRepoStorage, XorSha256SetHash>; 338 + type TestRepo = SpaceRepo<SqlSpaceRepoStorage, LtHash>; 334 339 335 340 #[tokio::test(flavor = "multi_thread")] 336 341 async fn create_and_read_record() { ··· 390 395 assert_eq!(page.ops[1].idx, 1); 391 396 } 392 397 398 + /// Regression: a single atomic batch larger than the page `limit` pages 399 + /// fully via the SQL `(rev > ? OR (rev = ? AND idx > ?))` predicate, with no 400 + /// tail skipped. A bare-rev `rev > ?` cursor would skip ops `limit..N`. 401 + #[tokio::test(flavor = "multi_thread")] 402 + async fn batch_larger_than_limit_pages_fully() { 403 + let space = test_space(); 404 + let repo: TestRepo = SpaceRepo::new(space.clone(), fresh_storage().await); 405 + 406 + const N: usize = 7; 407 + const LIMIT: u32 = 3; 408 + 409 + let ops: Vec<Op> = (0..N) 410 + .map(|i| Op { 411 + action: OpAction::Create, 412 + collection: "c".to_string(), 413 + rkey: format!("k{i}"), 414 + cid: Some(format!("cid{i}")), 415 + value: Some(vec![]), 416 + }) 417 + .collect(); 418 + let prepared = repo.format_commit(&ops).await.unwrap(); 419 + repo.apply_commit(prepared).await.unwrap(); 420 + 421 + let mut seen: Vec<(String, u32)> = Vec::new(); 422 + let mut cursor: Option<OplogCursor> = None; 423 + loop { 424 + let page = repo.read_oplog(cursor.as_ref(), LIMIT).await.unwrap(); 425 + for op in &page.ops { 426 + seen.push((op.rev.clone(), op.idx)); 427 + } 428 + let caught_up = (page.ops.len() as u32) < LIMIT; 429 + cursor = page 430 + .ops 431 + .last() 432 + .map(|o| OplogCursor::new(o.rev.clone(), o.idx)); 433 + if caught_up { 434 + break; 435 + } 436 + } 437 + 438 + assert_eq!(seen.len(), N, "all batch ops delivered, none skipped"); 439 + let rev = &seen[0].0; 440 + for (i, (r, idx)) in seen.iter().enumerate() { 441 + assert_eq!(r, rev, "all ops share the batch rev"); 442 + assert_eq!(*idx as usize, i, "idx is dense and monotonic"); 443 + } 444 + } 445 + 393 446 #[tokio::test(flavor = "multi_thread")] 394 447 async fn list_records_paginates() { 395 448 let space = test_space(); ··· 454 507 assert!(repo.get_record("c", "k").await.unwrap().is_none()); 455 508 } 456 509 510 + /// The persisted lattice state round-trips through 511 + /// `state_bytes()`/`from_state_bytes()`, preserving both state and digest. 457 512 #[test] 458 - fn xor_set_hash_matches_in_memory() { 459 - let mut h1 = XorSha256SetHash::empty(); 460 - let mut h2 = XorSha256SetHash::empty(); 461 - h1.add(b"x"); 462 - h2.add(b"x"); 463 - assert_eq!(h1.digest(), h2.digest()); 513 + fn set_hash_state_round_trips() { 514 + let mut h = LtHash::empty(); 515 + h.add(b"x"); 516 + let state = h.state_bytes(); 517 + let rehydrated = LtHash::from_state_bytes(&state).unwrap(); 518 + assert_eq!(rehydrated.state_bytes(), state); 519 + assert_eq!(rehydrated.digest(), h.digest()); 464 520 } 465 521 }
+1 -1
crates/atproto-pds/src/admin/handlers.rs
··· 762 762 format!("invalid space URI: {e}"), 763 763 ) 764 764 })?; 765 - let store = crate::actor_store::sql::SqlActorStore::open(manager.data_dir(), &space.owner_did) 765 + let store = crate::actor_store::sql::SqlActorStore::open(manager.data_dir(), &space.space_did) 766 766 .await 767 767 .map_err(XrpcError::from)?; 768 768 if input.takedown {
+30 -14
crates/atproto-pds/src/bin/pds.rs
··· 195 195 /// loop walks each per-actor store on each tick and prunes oplog 196 196 /// rows whose TID sorts below the cutoff. `0` disables the sweep. 197 197 /// Receivers that lag behind this window must re-sync via 198 - /// `getRepoState` + `exportSpaces`. 198 + /// `getRepoState`. 199 199 #[arg( 200 200 long, 201 201 env = "PDS_SPACE_OPLOG_RETENTION_DAYS", ··· 225 225 space_notify_retry_max_attempts: u32, 226 226 227 227 /// SpaceCredential TTL in seconds. Default 228 - /// 10800 (3h, matching `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS`). 228 + /// 7200 (2h, matching `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS`). 229 229 #[arg( 230 230 long, 231 231 env = "PDS_SPACE_CREDENTIAL_TTL_SECONDS", ··· 240 240 /// accepted (back-compat for dev / test deployments). 241 241 #[arg(long, env = "PDS_SERVICE_HANDLE_DOMAINS", value_delimiter = ',')] 242 242 service_handle_domains: Vec<String>, 243 - 244 - /// When set, `space.addMember` / `space.removeMember` send an 245 - /// notification email to the affected member via `EmailService` 246 - /// The flag is a sentinel — set to any 247 - /// truthy value (`1`, `true`) to enable. 248 - #[arg(long, env = "PDS_NOTIFY_MEMBERSHIP_EMAIL", default_value_t = false)] 249 - notify_membership_email: bool, 250 243 251 244 /// Comma-separated list of crawler hostnames to notify via 252 245 /// `com.atproto.sync.requestCrawl`. Each ··· 457 450 args.data_dir.clone(), 458 451 account_manager.clone(), 459 452 )); 460 - let space_writer = Arc::new(SpaceWriter::new( 461 - account_manager.clone(), 462 - args.data_dir.clone(), 463 - )); 453 + let space_writer = Arc::new( 454 + SpaceWriter::new(account_manager.clone(), args.data_dir.clone()) 455 + .with_plc_directory(args.plc_directory.clone()), 456 + ); 464 457 let space_reader = Arc::new(SpaceReader::new( 465 458 account_manager.clone(), 466 459 args.data_dir.clone(), ··· 599 592 .with_public_realm_backend(public_realm_backend.clone()) 600 593 .with_space_credential_ttl(args.space_credential_ttl_seconds) 601 594 .with_service_handle_domains(args.service_handle_domains.clone()) 602 - .with_notify_membership_email(args.notify_membership_email) 603 595 .with_crawlers(args.crawlers.clone()) 604 596 // Persist OAuth in-flight state (PAR / auth-codes / refresh handles) 605 597 // to the accounts DB so the lifecycle survives PDS restart. See ··· 620 612 } 621 613 #[cfg(feature = "hickory-dns")] 622 614 { 615 + // Space-type declaration resolver (NSID → declared `collections`) for 616 + // the bare `space:` grant default (spec line 413). Reuses the same 617 + // DNS resolver + PLC hostname as handle resolution; results are 618 + // TTL-cached to avoid resolving on every authorization check. 619 + let plc_hostname = args 620 + .plc_directory 621 + .as_deref() 622 + .and_then(|url| url.split("://").nth(1).map(|h| h.to_string())) 623 + .unwrap_or_else(|| "plc.directory".to_string()); 624 + let decl_http = reqwest::Client::builder() 625 + .user_agent(user_agent()) 626 + .timeout(std::time::Duration::from_secs(5)) 627 + .build() 628 + .unwrap_or_default(); 629 + let network = atproto_pds::space::NetworkSpaceDeclarationResolver::new( 630 + dns_resolver.clone(), 631 + plc_hostname, 632 + decl_http, 633 + ); 634 + let cached = atproto_pds::space::CachingSpaceDeclarationResolver::new( 635 + Arc::new(network), 636 + std::time::Duration::from_secs(300), 637 + ); 638 + state = state.with_space_declaration_resolver(Arc::new(cached)); 623 639 state = state.with_dns_resolver(dns_resolver); 624 640 } 625 641 if let (Some(did), Some(url)) = (
+17
crates/atproto-pds/src/errors.rs
··· 53 53 what: String, 54 54 }, 55 55 56 + /// error-atproto-pds-space-2: the referenced space does not exist or has 57 + /// been tombstoned (`deleted_at IS NOT NULL`). Surfaced to clients as the 58 + /// `SpaceNotFound` XRPC error. 59 + #[error("error-atproto-pds-space-2 space not found: {uri}")] 60 + SpaceNotFound { 61 + /// URI of the missing or deleted space. 62 + uri: String, 63 + }, 64 + 65 + /// error-atproto-pds-space-3: the caller is not the owner of the space. 66 + /// Surfaced to clients as the `NotSpaceOwner` XRPC error. 67 + #[error("error-atproto-pds-space-3 not the space owner: {uri}")] 68 + NotSpaceOwner { 69 + /// URI of the space. 70 + uri: String, 71 + }, 72 + 56 73 /// error-atproto-pds-account-1: account state transition rejected. 57 74 #[error("error-atproto-pds-account-1 invalid account state transition: {from} -> {to}")] 58 75 InvalidAccountTransition {
+1 -1
crates/atproto-pds/src/gc.rs
··· 32 32 pub const DEFAULT_NOTIFY_FAILED_RETENTION_DAYS: i64 = 30; 33 33 /// Default retention for `space_*_oplog` rows. 34 34 /// Receivers that lag behind this window need a full re-sync via 35 - /// `getRepoState` + `exportSpaces`. Operators tighten via 35 + /// `getRepoState`. Operators tighten via 36 36 /// `PDS_SPACE_OPLOG_RETENTION_DAYS`. 37 37 pub const DEFAULT_SPACE_OPLOG_RETENTION_DAYS: i64 = 30; 38 38
+33
crates/atproto-pds/src/http/auth.rs
··· 60 60 } 61 61 } 62 62 63 + /// OAuth `client_id` of the requesting app, when the token is an OAuth 64 + /// access token. `None` for app-password sessions (which have no 65 + /// associated OAuth client). 66 + #[must_use] 67 + pub fn client_id(&self) -> Option<&str> { 68 + match self { 69 + AuthSubject::AppPassword(_) => None, 70 + AuthSubject::OAuth(c) => Some(&c.client_id), 71 + } 72 + } 73 + 63 74 /// `true` when this is an OAuth token bound to a DPoP key (the 64 75 /// `cnf.jkt` claim is present). 65 76 #[must_use] 66 77 pub fn is_dpop_bound(&self) -> bool { 67 78 matches!(self, AuthSubject::OAuth(c) if c.cnf.is_some()) 79 + } 80 + 81 + /// `true` when this is an OAuth access token (as opposed to an 82 + /// app-password session). Space scope gating only applies to OAuth 83 + /// tokens — app-password sessions carry no `space:` grants and so can 84 + /// never satisfy [`assert_space`](atproto_oauth::scopes::ScopesSet::assert_space). 85 + #[must_use] 86 + pub fn is_oauth(&self) -> bool { 87 + matches!(self, AuthSubject::OAuth(_)) 88 + } 89 + 90 + /// The granted OAuth scope set, parsed from the access token's `scope` 91 + /// claim. Returns an empty set for app-password sessions (which have no 92 + /// OAuth scope string), so callers can uniformly run 93 + /// [`assert_space`](atproto_oauth::scopes::ScopesSet::assert_space) 94 + /// against it — an empty set simply satisfies nothing. 95 + #[must_use] 96 + pub fn scopes(&self) -> atproto_oauth::scopes::ScopesSet { 97 + match self { 98 + AuthSubject::AppPassword(_) => atproto_oauth::scopes::ScopesSet::new(), 99 + AuthSubject::OAuth(c) => atproto_oauth::scopes::ScopesSet::from_scope_string(&c.scope), 100 + } 68 101 } 69 102 70 103 /// `true` if the token is allowed to perform privileged operations
+10
crates/atproto-pds/src/http/errors.rs
··· 50 50 PdsError::NotFound { what } => { 51 51 XrpcError::new(StatusCode::BAD_REQUEST, "NotFound", what) 52 52 } 53 + PdsError::SpaceNotFound { uri } => XrpcError::new( 54 + StatusCode::BAD_REQUEST, 55 + "SpaceNotFound", 56 + format!("no such space {uri}"), 57 + ), 58 + PdsError::NotSpaceOwner { uri } => XrpcError::new( 59 + StatusCode::FORBIDDEN, 60 + "NotSpaceOwner", 61 + format!("not the owner of {uri}"), 62 + ), 53 63 PdsError::AuthDenied { reason } => { 54 64 XrpcError::new(StatusCode::FORBIDDEN, "Forbidden", reason) 55 65 }
+49 -32
crates/atproto-pds/src/http/router.rs
··· 257 257 "/.well-known/oauth-protected-resource", 258 258 get(oauth::oauth_protected_resource), 259 259 ) 260 - // com.atproto.space.* — permissioned realm 260 + // com.atproto.simplespace.* — host-internal space management 261 261 .route( 262 - "/xrpc/com.atproto.space.createSpace", 262 + "/xrpc/com.atproto.simplespace.createSpace", 263 263 post(space_handlers::create_space), 264 264 ) 265 265 .route( 266 - "/xrpc/com.atproto.space.getSpace", 267 - get(space_handlers::get_space), 266 + "/xrpc/com.atproto.simplespace.updateSpace", 267 + post(space_handlers::update_space), 268 268 ) 269 269 .route( 270 - "/xrpc/com.atproto.space.listSpaces", 271 - get(space_handlers::list_spaces), 270 + "/xrpc/com.atproto.simplespace.deleteSpace", 271 + post(space_handlers::delete_space), 272 272 ) 273 273 .route( 274 - "/xrpc/com.atproto.space.addMember", 274 + "/xrpc/com.atproto.simplespace.addMember", 275 275 post(space_handlers::add_member), 276 276 ) 277 277 .route( 278 - "/xrpc/com.atproto.space.removeMember", 278 + "/xrpc/com.atproto.simplespace.removeMember", 279 279 post(space_handlers::remove_member), 280 280 ) 281 281 .route( 282 - "/xrpc/com.atproto.space.getMembers", 282 + "/xrpc/com.atproto.simplespace.listMembers", 283 283 get(space_handlers::get_members), 284 284 ) 285 + // com.atproto.space.* — permissioned realm 286 + .route( 287 + "/xrpc/com.atproto.space.getSpace", 288 + get(space_handlers::get_space), 289 + ) 290 + .route( 291 + "/xrpc/com.atproto.space.listSpaces", 292 + get(space_handlers::list_spaces), 293 + ) 285 294 .route( 286 295 "/xrpc/com.atproto.space.applyWrites", 287 296 post(space_handlers::apply_writes), 288 297 ) 289 298 .route( 299 + "/xrpc/com.atproto.space.createRecord", 300 + post(space_handlers::create_record_write), 301 + ) 302 + .route( 303 + "/xrpc/com.atproto.space.putRecord", 304 + post(space_handlers::put_record_write), 305 + ) 306 + .route( 307 + "/xrpc/com.atproto.space.deleteRecord", 308 + post(space_handlers::delete_record_write), 309 + ) 310 + .route( 290 311 "/xrpc/com.atproto.space.getRecord", 291 312 get(space_handlers::get_record), 292 313 ) ··· 295 316 get(space_handlers::list_records), 296 317 ) 297 318 .route( 298 - "/xrpc/com.atproto.space.getRepoState", 299 - get(space_handlers::get_repo_state), 319 + "/xrpc/com.atproto.space.getBlob", 320 + get(space_handlers::get_blob), 300 321 ) 301 322 .route( 302 - "/xrpc/com.atproto.space.getRepoOplog", 303 - get(space_handlers::get_repo_oplog), 323 + "/xrpc/com.atproto.space.listRepos", 324 + get(space_handlers::list_repos), 304 325 ) 305 326 .route( 306 - "/xrpc/com.atproto.space.getMemberState", 307 - get(space_handlers::get_member_state), 327 + "/xrpc/com.atproto.space.getRepoState", 328 + get(space_handlers::get_repo_state), 308 329 ) 309 330 .route( 310 - "/xrpc/com.atproto.space.getMemberOplog", 311 - get(space_handlers::get_member_oplog), 331 + "/xrpc/com.atproto.space.listRepoOps", 332 + get(space_handlers::list_repo_ops), 312 333 ) 313 334 .route( 314 - "/xrpc/com.atproto.space.getMemberGrant", 315 - post(space_handlers::get_member_grant), 335 + "/xrpc/com.atproto.space.getDelegationToken", 336 + get(space_handlers::get_delegation_token), 316 337 ) 317 338 .route( 318 339 "/xrpc/com.atproto.space.getSpaceCredential", 319 340 post(space_handlers::get_space_credential), 320 341 ) 321 - // Inbound notifyWrite + notifyMembership. 322 - // Auth is structural: the embedded signed Commit IS the auth. 323 - .route( 324 - "/xrpc/com.atproto.space.notifyWrite", 325 - post(space_handlers::notify_write), 326 - ) 342 + // Notify subscription (space-credential auth). 327 343 .route( 328 - "/xrpc/com.atproto.space.notifyMembership", 329 - post(space_handlers::notify_membership), 344 + "/xrpc/com.atproto.space.registerNotify", 345 + post(space_handlers::register_notify), 330 346 ) 331 - // Spaces export/import. 347 + // Inbound notifyWrite (service auth; contentless { space, repo, rev }). 332 348 .route( 333 - "/xrpc/com.atproto.space.exportSpaces", 334 - get(space_handlers::export_spaces), 349 + "/xrpc/com.atproto.space.notifyWrite", 350 + post(space_handlers::notify_write), 335 351 ) 352 + // Space-deletion lifecycle (service auth). 336 353 .route( 337 - "/xrpc/com.atproto.space.importSpaces", 338 - post(space_handlers::import_spaces), 354 + "/xrpc/com.atproto.space.notifySpaceDeleted", 355 + post(space_handlers::notify_space_deleted), 339 356 ) 340 357 // com.atproto.admin.* 341 358 .route(
+238 -259
crates/atproto-pds/src/http/space_auth.rs
··· 4 4 //! 1. **OAuth bearer** — same DPoP-bound HS256 token used for the public 5 5 //! realm. Verified by [`crate::oauth::token::verify_oauth_jwt`]. Used for 6 6 //! own-PDS reads and writes. 7 - //! 2. **MemberGrant JWT** (`typ=space_member_grant`) — passed to 8 - //! `getSpaceCredential`. Signed by the member's atproto signing key. 9 - //! 3. **SpaceCredential JWT** (`typ=space_credential`) — passed to 7 + //! 2. **Delegation token** (`typ=atproto-space-delegation+jwt`) — presented in 8 + //! the `Authorization: Bearer` header to `getSpaceCredential`. Signed by the 9 + //! member's atproto signing key (header `kid="#atproto"`). 10 + //! 3. **SpaceCredential JWT** (`typ=atproto-space-credential+jwt`) — passed to 10 11 //! `getRecord`/`listRecords`/`getRepoState`/etc. by remote consumers. 11 - //! Signed by the space owner's atproto signing key. 12 + //! Signed by the space authority's `#atproto_space` signing key. 12 13 //! 13 - //! the design (§15.7), MemberGrant verification at the owner's PDS 14 - //! requires resolving the member's DID document to obtain their signing 15 - //! key. The **same-PDS** case (the member is also a locally-managed 16 - //! account on this PDS) looks up the signing key directly via the 17 - //! `AccountManager` + `KeyStore`. Cross-PDS resolution through 18 - //! `atproto-identity` is wired — the resolver picks the path automatically based on 19 - //! whether the DID is locally managed. 14 + //! Delegation-token verification at the authority's PDS requires resolving the 15 + //! member's DID document to obtain their `#atproto` signing key. The 16 + //! **same-PDS** case (the member is also a locally-managed account on this PDS) 17 + //! looks up the signing key directly via the `AccountManager` + `KeyStore`. 18 + //! Cross-PDS resolution through `atproto-identity` is wired — the resolver 19 + //! picks the path automatically based on whether the DID is locally managed. 20 20 21 21 use crate::account::AccountManager; 22 22 use crate::http::errors::XrpcError; 23 - use crate::security::JtiReplayGuard; 24 23 use atproto_identity::key::{KeyData, to_public}; 25 24 use atproto_space::credential::{ 26 - LXM_GET_SPACE_CREDENTIAL, MemberGrant, TYP_MEMBER_GRANT, TYP_SPACE_CREDENTIAL, 27 - verify_member_grant, 25 + DelegationToken, TYP_DELEGATION_TOKEN, TYP_SPACE_CREDENTIAL, verify_delegation_token, 28 26 }; 29 27 use atproto_space::types::SpaceUri; 30 28 use axum::http::StatusCode; 31 29 use base64::{Engine as _, engine::general_purpose}; 32 30 use serde::Deserialize; 33 - use sha2::{Digest, Sha256}; 34 31 use std::sync::Arc; 35 - use std::time::{Duration, SystemTime, UNIX_EPOCH}; 36 32 37 33 /// JWT typ discriminator inspection. 38 34 #[derive(Debug, Deserialize)] ··· 55 51 /// Recognised Spaces token shapes. 56 52 #[derive(Debug, PartialEq, Eq)] 57 53 pub enum SpaceTokenKind { 58 - /// `space_member_grant`. 59 - MemberGrant, 60 - /// `space_credential`. 54 + /// `atproto-space-delegation+jwt`. 55 + DelegationToken, 56 + /// `atproto-space-credential+jwt`. 61 57 SpaceCredential, 62 58 /// Anything else (likely an OAuth access token). 63 59 Other(String), ··· 68 64 pub fn classify(token: &str) -> Option<SpaceTokenKind> { 69 65 let typ = classify_token_typ(token)?; 70 66 Some(match typ.as_str() { 71 - TYP_MEMBER_GRANT => SpaceTokenKind::MemberGrant, 67 + TYP_DELEGATION_TOKEN => SpaceTokenKind::DelegationToken, 72 68 TYP_SPACE_CREDENTIAL => SpaceTokenKind::SpaceCredential, 73 69 _ => SpaceTokenKind::Other(typ), 74 70 }) ··· 107 103 } 108 104 109 105 /// Resolve a locally-managed account's *public* signing key (used for 110 - /// verifying MemberGrants issued by that account). 106 + /// verifying delegation tokens issued by that account). 111 107 pub async fn local_public_key(accounts: &AccountManager, did: &str) -> Result<KeyData, XrpcError> { 112 108 let private = local_signing_key(accounts, did).await?; 113 109 to_public(&private).map_err(|e| { ··· 119 115 }) 120 116 } 121 117 122 - /// Verify a MemberGrant against a same-PDS member's signing key, with replay 123 - /// protection. 124 - /// 125 - /// MemberGrants don't carry an explicit `jti` claim — instead we synthesize 126 - /// one from `(iss, iat, lxm, space, clientId)` so the replay guard rejects 127 - /// duplicate exchanges of the same grant. TTL is the grant's remaining 128 - /// lifetime (`exp - now`). 129 - /// 130 - /// Returns the decoded payload on success. Used by `getSpaceCredential`. 118 + /// Peek the unverified `iss` claim of a delegation token without checking the 119 + /// signature, so the caller knows which member key to resolve. 131 120 /// 132 121 /// # Errors 133 122 /// 134 - /// - 400 `InvalidToken` if the grant is unparseable / expired / claim mismatch. 135 - /// - 401 `AuthenticationRequired` if the issuer (member) is not known on this PDS. 136 - /// - 409 `Replay` if the same grant has already been exchanged within its TTL. 137 - pub async fn verify_local_member_grant( 138 - accounts: &Arc<AccountManager>, 139 - jti_guard: &JtiReplayGuard, 140 - grant_jwt: &str, 141 - expected_owner_did: &str, 142 - expected_space: &SpaceUri, 143 - expected_client_id: &str, 144 - ) -> Result<MemberGrant, XrpcError> { 145 - // Peek the issuer claim without verifying signature, so we know which 146 - // member's key to fetch. We re-verify with the proper key below. 123 + /// Returns 400 `InvalidToken` when the token is structurally malformed. 124 + pub fn peek_delegation_token(grant_jwt: &str) -> Result<DelegationToken, XrpcError> { 147 125 let payload_b64 = grant_jwt.split('.').nth(1).ok_or_else(|| { 148 126 XrpcError::new( 149 127 StatusCode::BAD_REQUEST, 150 128 "InvalidToken", 151 - "MemberGrant: missing payload", 129 + "delegation token: missing payload", 152 130 ) 153 131 })?; 154 132 let payload_bytes = general_purpose::URL_SAFE_NO_PAD ··· 157 135 XrpcError::new( 158 136 StatusCode::BAD_REQUEST, 159 137 "InvalidToken", 160 - "MemberGrant: payload not base64url", 138 + "delegation token: payload not base64url", 161 139 ) 162 140 })?; 163 - let unverified: MemberGrant = serde_json::from_slice(&payload_bytes).map_err(|_| { 141 + serde_json::from_slice(&payload_bytes).map_err(|_| { 164 142 XrpcError::new( 165 143 StatusCode::BAD_REQUEST, 166 144 "InvalidToken", 167 - "MemberGrant: payload not JSON", 145 + "delegation token: payload not JSON", 168 146 ) 169 - })?; 147 + }) 148 + } 170 149 171 - if unverified.lxm != LXM_GET_SPACE_CREDENTIAL { 172 - return Err(XrpcError::new( 173 - StatusCode::BAD_REQUEST, 174 - "InvalidToken", 175 - format!("MemberGrant lxm mismatch: {}", unverified.lxm), 176 - )); 177 - } 150 + /// Verify a delegation token against a same-PDS member's signing key. 151 + /// 152 + /// Returns the decoded payload on success. Used by `getSpaceCredential`. 153 + /// 154 + /// # Errors 155 + /// 156 + /// - 400 `InvalidToken` if the token is unparseable / expired / claim mismatch. 157 + /// - 404 `AccountNotFound` if the issuer (member) is not known on this PDS. 158 + pub async fn verify_local_delegation_token( 159 + accounts: &Arc<AccountManager>, 160 + grant_jwt: &str, 161 + expected_authority_did: &str, 162 + expected_space: &SpaceUri, 163 + ) -> Result<DelegationToken, XrpcError> { 164 + // Peek the issuer claim without verifying signature, so we know which 165 + // member's key to fetch. We re-verify with the proper key below. 166 + let unverified = peek_delegation_token(grant_jwt)?; 178 167 179 168 let member_pub = local_public_key(accounts, &unverified.iss).await?; 180 - let payload = verify_member_grant( 169 + let payload = verify_delegation_token( 181 170 grant_jwt, 182 - expected_owner_did, 171 + expected_authority_did, 183 172 expected_space, 184 - expected_client_id, 185 173 &member_pub, 186 174 ) 187 175 .map_err(|e| { 188 176 XrpcError::new( 189 177 StatusCode::FORBIDDEN, 190 178 "InvalidToken", 191 - format!("MemberGrant verification: {e}"), 179 + format!("delegation token verification: {e}"), 192 180 ) 193 181 })?; 194 - 195 - // Replay protection. Synthesize a JTI from the structural identity of the 196 - // grant; record with TTL = remaining lifetime so the guard self-cleans. 197 - let synthetic_jti = synthesize_member_grant_jti(&payload); 198 - let now = SystemTime::now() 199 - .duration_since(UNIX_EPOCH) 200 - .map(|d| d.as_secs()) 201 - .unwrap_or(0); 202 - let ttl = Duration::from_secs(payload.exp.saturating_sub(now)); 203 - jti_guard 204 - .check_and_insert(&synthetic_jti, ttl) 205 - .await 206 - .map_err(|e| { 207 - XrpcError::new( 208 - StatusCode::CONFLICT, 209 - "Replay", 210 - format!("MemberGrant replay rejected: {e}"), 211 - ) 212 - })?; 213 182 214 183 Ok(payload) 215 184 } 216 185 217 - /// Verify a MemberGrant issued by a member on a *remote* PDS by resolving 186 + /// Verify a delegation token issued by a member on a *remote* PDS by resolving 218 187 /// their DID document for the atproto signing key. 219 188 /// 220 - /// The flow mirrors [`verify_local_member_grant`] but the public-key 189 + /// The flow mirrors [`verify_local_delegation_token`] but the public-key 221 190 /// lookup goes through `atproto-identity` instead of the local 222 191 /// `AccountManager`: 223 192 /// 224 - /// 1. Peek the JWT payload to learn the issuer DID + claim shape. 193 + /// 1. Peek the JWT payload to learn the issuer DID. 225 194 /// 2. Resolve the issuer's DID document via the configured PLC directory 226 195 /// (for `did:plc`) or `.well-known/did.json` (for `did:web`). 227 196 /// 3. Find the verification method whose id ends in `#atproto`; decode the 228 197 /// multibase public key into a `KeyData`. 229 - /// 4. Re-verify the JWT against that key, plus the same JTI replay guard 230 - /// used on the local path. 198 + /// 4. Re-verify the JWT against that key. 231 199 /// 232 200 /// `plc_directory_hostname` is the configured PLC directory (e.g. 233 201 /// `plc.directory`); pass `None` to use the upstream default. 234 202 /// 235 203 /// # Errors 236 204 /// 237 - /// - 400 `InvalidToken` — grant unparseable / wrong `lxm` / claim mismatch. 205 + /// - 400 `InvalidToken` — token unparseable / claim mismatch. 238 206 /// - 401 `AuthenticationRequired` — DID document doesn't resolve, or 239 207 /// doesn't carry an `#atproto` verification method. 240 208 /// - 403 `InvalidToken` — signature verification failed. 241 - /// - 409 `Replay` — the same grant has already been exchanged. 242 - pub async fn verify_remote_member_grant( 209 + pub async fn verify_remote_delegation_token( 243 210 http: &reqwest::Client, 244 - jti_guard: &JtiReplayGuard, 245 211 grant_jwt: &str, 246 - expected_owner_did: &str, 212 + expected_authority_did: &str, 247 213 expected_space: &SpaceUri, 248 - expected_client_id: &str, 249 214 plc_directory_hostname: Option<&str>, 250 - ) -> Result<MemberGrant, XrpcError> { 251 - let payload_b64 = grant_jwt.split('.').nth(1).ok_or_else(|| { 252 - XrpcError::new( 253 - StatusCode::BAD_REQUEST, 254 - "InvalidToken", 255 - "MemberGrant: missing payload", 256 - ) 257 - })?; 258 - let payload_bytes = general_purpose::URL_SAFE_NO_PAD 259 - .decode(payload_b64.as_bytes()) 260 - .map_err(|_| { 261 - XrpcError::new( 262 - StatusCode::BAD_REQUEST, 263 - "InvalidToken", 264 - "MemberGrant: payload not base64url", 265 - ) 266 - })?; 267 - let unverified: MemberGrant = serde_json::from_slice(&payload_bytes).map_err(|_| { 268 - XrpcError::new( 269 - StatusCode::BAD_REQUEST, 270 - "InvalidToken", 271 - "MemberGrant: payload not JSON", 272 - ) 273 - })?; 274 - 275 - if unverified.lxm != LXM_GET_SPACE_CREDENTIAL { 276 - return Err(XrpcError::new( 277 - StatusCode::BAD_REQUEST, 278 - "InvalidToken", 279 - format!("MemberGrant lxm mismatch: {}", unverified.lxm), 280 - )); 281 - } 215 + ) -> Result<DelegationToken, XrpcError> { 216 + let unverified = peek_delegation_token(grant_jwt)?; 282 217 283 218 let member_pub = remote_atproto_signing_key(http, &unverified.iss, plc_directory_hostname) 284 219 .await ··· 289 224 format!("resolve member DID document for {}: {e}", unverified.iss), 290 225 ) 291 226 })?; 292 - let payload = verify_member_grant( 227 + let payload = verify_delegation_token( 293 228 grant_jwt, 294 - expected_owner_did, 229 + expected_authority_did, 295 230 expected_space, 296 - expected_client_id, 297 231 &member_pub, 298 232 ) 299 233 .map_err(|e| { 300 234 XrpcError::new( 301 235 StatusCode::FORBIDDEN, 302 236 "InvalidToken", 303 - format!("MemberGrant verification: {e}"), 237 + format!("delegation token verification: {e}"), 304 238 ) 305 239 })?; 306 240 307 - let synthetic_jti = synthesize_member_grant_jti(&payload); 308 - let now = SystemTime::now() 309 - .duration_since(UNIX_EPOCH) 310 - .map(|d| d.as_secs()) 311 - .unwrap_or(0); 312 - let ttl = Duration::from_secs(payload.exp.saturating_sub(now)); 313 - jti_guard 314 - .check_and_insert(&synthetic_jti, ttl) 315 - .await 316 - .map_err(|e| { 317 - XrpcError::new( 318 - StatusCode::CONFLICT, 319 - "Replay", 320 - format!("MemberGrant replay rejected: {e}"), 321 - ) 322 - })?; 323 - 324 241 Ok(payload) 325 242 } 326 243 327 - /// Resolve a remote DID's atproto signing key (the `#atproto` verification 328 - /// method's `publicKeyMultibase`). Returns `KeyData` ready for 329 - /// `verify_member_grant` etc. 330 - async fn remote_atproto_signing_key( 244 + /// Fetch a `did:plc:`/`did:web:` DID document for remote verification. 245 + async fn fetch_remote_document( 331 246 http: &reqwest::Client, 332 247 did: &str, 333 248 plc_directory_hostname: Option<&str>, 334 - ) -> anyhow::Result<KeyData> { 335 - use atproto_identity::key::identify_key; 336 - use atproto_identity::model::VerificationMethod; 249 + ) -> anyhow::Result<atproto_identity::model::Document> { 337 250 use atproto_identity::plc::query as plc_query; 338 251 use atproto_identity::web::query as web_query; 339 - let document = if did.starts_with("did:plc:") { 252 + if did.starts_with("did:plc:") { 340 253 let host = plc_directory_hostname.unwrap_or("plc.directory"); 341 - plc_query(http, host, did).await? 254 + Ok(plc_query(http, host, did).await?) 342 255 } else if did.starts_with("did:web:") { 343 - web_query(http, did).await? 256 + Ok(web_query(http, did).await?) 344 257 } else { 345 - anyhow::bail!("unsupported DID method for remote member-grant verification: {did}"); 346 - }; 347 - let mut atproto_pub: Option<String> = None; 348 - for method in &document.verification_method { 349 - if let VerificationMethod::Multikey { 350 - id, 351 - public_key_multibase, 352 - .. 353 - } = method 354 - { 355 - // Match either `#atproto` (relative) or 356 - // `did:plc:xxx#atproto` (absolute). Spec allows either form. 357 - if id.ends_with("#atproto") { 358 - atproto_pub = Some(public_key_multibase.clone()); 359 - break; 360 - } 361 - } 258 + anyhow::bail!("unsupported DID method for remote space verification: {did}") 362 259 } 363 - let mb = atproto_pub.ok_or_else(|| { 364 - anyhow::anyhow!("DID document has no #atproto Multikey verification method") 365 - })?; 260 + } 261 + 262 + /// Turn a multibase / `did:key:` value into `KeyData`. 263 + fn multibase_to_key(mb: &str) -> anyhow::Result<KeyData> { 264 + use atproto_identity::key::identify_key; 366 265 // `identify_key` accepts either a bare multibase value or a `did:key:` 367 266 // wrapper. Normalize so the call site is robust to either form. 368 267 let did_key = if mb.starts_with("did:key:") { 369 - mb 268 + mb.to_string() 370 269 } else { 371 - format!("did:key:{}", mb) 270 + format!("did:key:{mb}") 372 271 }; 373 272 Ok(identify_key(&did_key)?) 374 273 } 375 274 376 - /// Build a deterministic JTI for a MemberGrant payload. We hash the load-bearing 377 - /// claims so two grants with the same `(iss, iat, lxm, space, clientId)` 378 - /// collide (replay) but distinct issuances do not. 379 - fn synthesize_member_grant_jti(grant: &MemberGrant) -> String { 380 - let mut hasher = Sha256::new(); 381 - hasher.update(b"mg:"); 382 - hasher.update(grant.iss.as_bytes()); 383 - hasher.update(b"|"); 384 - hasher.update(grant.iat.to_be_bytes()); 385 - hasher.update(b"|"); 386 - hasher.update(grant.lxm.as_bytes()); 387 - hasher.update(b"|"); 388 - hasher.update(grant.space.as_bytes()); 389 - hasher.update(b"|"); 390 - hasher.update(grant.client_id.as_bytes()); 391 - let digest = hasher.finalize(); 392 - hex::encode(digest) 275 + /// Resolve a remote DID's atproto signing key (the `#atproto` verification 276 + /// method's `publicKeyMultibase`). Returns `KeyData` ready for 277 + /// `verify_delegation_token` etc. The delegation token's `kid` is `#atproto` 278 + /// per 0016 line 162, so this resolves the member's public-data signing key. 279 + async fn remote_atproto_signing_key( 280 + http: &reqwest::Client, 281 + did: &str, 282 + plc_directory_hostname: Option<&str>, 283 + ) -> anyhow::Result<KeyData> { 284 + let document = fetch_remote_document(http, did, plc_directory_hostname).await?; 285 + let mb = document 286 + .verification_method_multibase("atproto") 287 + .ok_or_else(|| { 288 + anyhow::anyhow!("DID document has no #atproto Multikey verification method") 289 + })?; 290 + multibase_to_key(mb) 291 + } 292 + 293 + /// Resolve a remote space authority's credential-verification key. 294 + /// 295 + /// Per 0016 §"Space authority" (lines 87-92) the authority DID exposes the 296 + /// credential-signing public key as the `#atproto_space` verification method, 297 + /// which MAY coincide in value with `#atproto` (line 92). This prefers the 298 + /// dedicated `#atproto_space` entry and falls back to `#atproto` for authority 299 + /// DID documents that exercise the coincidence allowance without publishing a 300 + /// distinct entry. 301 + pub async fn remote_space_credential_key( 302 + http: &reqwest::Client, 303 + authority_did: &str, 304 + plc_directory_hostname: Option<&str>, 305 + ) -> anyhow::Result<KeyData> { 306 + let document = fetch_remote_document(http, authority_did, plc_directory_hostname).await?; 307 + let mb = space_credential_multibase(&document).ok_or_else(|| { 308 + anyhow::anyhow!( 309 + "authority DID document has no #atproto_space or #atproto Multikey verification method" 310 + ) 311 + })?; 312 + multibase_to_key(mb) 313 + } 314 + 315 + /// Select the authority's credential-verification public key from a DID 316 + /// document: prefer `#atproto_space`, fall back to `#atproto` (0016 line 92). 317 + fn space_credential_multibase(document: &atproto_identity::model::Document) -> Option<&str> { 318 + document 319 + .verification_method_multibase("atproto_space") 320 + .or_else(|| document.verification_method_multibase("atproto")) 321 + } 322 + 323 + /// Resolve a remote space authority's host endpoint. 324 + /// 325 + /// Per 0016 §"Space authority" (lines 87-92) the authority DID exposes its host 326 + /// as the `#atproto_space_host` service, which MAY coincide with `#atproto_pds` 327 + /// (line 92). This prefers the dedicated `#atproto_space_host` entry and falls 328 + /// back to `#atproto_pds`. 329 + pub async fn remote_space_host_endpoint( 330 + http: &reqwest::Client, 331 + authority_did: &str, 332 + plc_directory_hostname: Option<&str>, 333 + ) -> anyhow::Result<String> { 334 + let document = fetch_remote_document(http, authority_did, plc_directory_hostname).await?; 335 + space_host_endpoint(&document) 336 + .map(|s| s.to_string()) 337 + .ok_or_else(|| { 338 + anyhow::anyhow!( 339 + "authority DID document has no #atproto_space_host or #atproto_pds service" 340 + ) 341 + }) 342 + } 343 + 344 + /// Select the authority's host endpoint from a DID document: prefer 345 + /// `#atproto_space_host`, fall back to `#atproto_pds` (0016 line 92). 346 + fn space_host_endpoint(document: &atproto_identity::model::Document) -> Option<&str> { 347 + document 348 + .service_endpoint("atproto_space_host") 349 + .or_else(|| document.service_endpoint("atproto_pds")) 393 350 } 394 351 395 352 #[cfg(test)] ··· 398 355 use crate::account::{AccountDirectory, AccountManager, CreateAccountParams}; 399 356 use crate::keys::{KeyStore, MemoryKeyStore}; 400 357 use atproto_identity::key::KeyType; 401 - use atproto_space::credential::{MEMBER_GRANT_TTL_SECS, create_member_grant}; 358 + use atproto_identity::model::Document; 359 + use atproto_space::credential::{DELEGATION_TOKEN_TTL_SECS, create_delegation_token}; 402 360 use atproto_space::types::{SpaceKey, SpaceType}; 403 361 use std::sync::Arc; 404 362 use tempfile::TempDir; 405 363 364 + fn doc_from(json: &str) -> Document { 365 + serde_json::from_str(json).expect("DID document parses") 366 + } 367 + 368 + #[test] 369 + fn space_credential_key_prefers_atproto_space_vm() { 370 + // 0016 lines 87-92: prefer the dedicated #atproto_space VM when present. 371 + let doc = doc_from( 372 + r##"{"id":"did:plc:authority", 373 + "verificationMethod":[ 374 + {"id":"#atproto","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zATPROTO"}, 375 + {"id":"#atproto_space","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zSPACE"} 376 + ]}"##, 377 + ); 378 + assert_eq!(space_credential_multibase(&doc), Some("zSPACE")); 379 + } 380 + 381 + #[test] 382 + fn space_credential_key_falls_back_to_atproto_vm() { 383 + // Line 92 MAY-coincide: authorities that don't publish a distinct 384 + // #atproto_space entry fall back to the #atproto signing key. 385 + let doc = doc_from( 386 + r##"{"id":"did:plc:authority", 387 + "verificationMethod":[ 388 + {"id":"#atproto","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zATPROTO"} 389 + ]}"##, 390 + ); 391 + assert_eq!(space_credential_multibase(&doc), Some("zATPROTO")); 392 + } 393 + 394 + #[test] 395 + fn space_credential_key_absent_when_neither_present() { 396 + let doc = doc_from(r##"{"id":"did:plc:authority","verificationMethod":[]}"##); 397 + assert_eq!(space_credential_multibase(&doc), None); 398 + } 399 + 400 + #[test] 401 + fn space_host_prefers_atproto_space_host_service() { 402 + // 0016 lines 87-92: prefer the dedicated #atproto_space_host service. 403 + let doc = doc_from( 404 + r##"{"id":"did:plc:authority", 405 + "service":[ 406 + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}, 407 + {"id":"#atproto_space_host","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://host.example.com"} 408 + ]}"##, 409 + ); 410 + assert_eq!(space_host_endpoint(&doc), Some("https://host.example.com")); 411 + } 412 + 413 + #[test] 414 + fn space_host_falls_back_to_atproto_pds_service() { 415 + // Line 92 MAY-coincide: fall back to #atproto_pds when no dedicated 416 + // host service is published. 417 + let doc = doc_from( 418 + r##"{"id":"did:plc:authority", 419 + "service":[ 420 + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"} 421 + ]}"##, 422 + ); 423 + assert_eq!(space_host_endpoint(&doc), Some("https://pds.example.com")); 424 + } 425 + 426 + #[test] 427 + fn space_host_absent_when_neither_present() { 428 + let doc = doc_from(r##"{"id":"did:plc:authority","service":[]}"##); 429 + assert_eq!(space_host_endpoint(&doc), None); 430 + } 431 + 406 432 async fn fresh_manager(dir: &std::path::Path) -> Arc<AccountManager> { 407 433 let accounts_db = AccountDirectory::open(&dir.join("accounts.sqlite")) 408 434 .await ··· 441 467 #[tokio::test(flavor = "multi_thread")] 442 468 async fn classify_token_kinds() { 443 469 // synthetic header-only jwt-shaped strings 444 - let mg_header = general_purpose::URL_SAFE_NO_PAD 445 - .encode(br#"{"alg":"ES256","typ":"space_member_grant"}"#); 446 - let sc_header = 447 - general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"space_credential"}"#); 448 - let jwt_a = format!("{}.payload.sig", mg_header); 470 + let dt_header = general_purpose::URL_SAFE_NO_PAD 471 + .encode(br##"{"alg":"ES256","typ":"atproto-space-delegation+jwt","kid":"#atproto"}"##); 472 + let sc_header = general_purpose::URL_SAFE_NO_PAD.encode( 473 + br##"{"alg":"ES256","typ":"atproto-space-credential+jwt","kid":"#atproto_space"}"##, 474 + ); 475 + let jwt_a = format!("{}.payload.sig", dt_header); 449 476 let jwt_b = format!("{}.payload.sig", sc_header); 450 - assert_eq!(classify(&jwt_a), Some(SpaceTokenKind::MemberGrant)); 477 + assert_eq!(classify(&jwt_a), Some(SpaceTokenKind::DelegationToken)); 451 478 assert_eq!(classify(&jwt_b), Some(SpaceTokenKind::SpaceCredential)); 452 479 } 453 480 454 - fn fresh_jti_guard() -> JtiReplayGuard { 455 - JtiReplayGuard::new(1024) 456 - } 457 - 458 481 #[tokio::test(flavor = "multi_thread")] 459 - async fn local_member_grant_round_trip() { 460 - let tmp = TempDir::new().unwrap(); 461 - let manager = fresh_manager(tmp.path()).await; 462 - let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap(); 463 - 464 - let space = test_space(); 465 - let grant = create_member_grant( 466 - "did:plc:alice", 467 - "did:plc:owner", 468 - &space, 469 - "https://app.example/client-metadata.json", 470 - &alice_priv, 471 - MEMBER_GRANT_TTL_SECS, 472 - ) 473 - .unwrap(); 474 - 475 - let guard = fresh_jti_guard(); 476 - let payload = verify_local_member_grant( 477 - &manager, 478 - &guard, 479 - &grant, 480 - "did:plc:owner", 481 - &space, 482 - "https://app.example/client-metadata.json", 483 - ) 484 - .await 485 - .unwrap(); 486 - assert_eq!(payload.iss, "did:plc:alice"); 487 - } 488 - 489 - #[tokio::test(flavor = "multi_thread")] 490 - async fn member_grant_replay_rejected() { 482 + async fn local_delegation_token_round_trip() { 491 483 let tmp = TempDir::new().unwrap(); 492 484 let manager = fresh_manager(tmp.path()).await; 493 485 let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap(); 494 486 495 487 let space = test_space(); 496 - let grant = create_member_grant( 488 + let grant = create_delegation_token( 497 489 "did:plc:alice", 498 - "did:plc:owner", 499 490 &space, 500 - "client", 501 491 &alice_priv, 502 - MEMBER_GRANT_TTL_SECS, 492 + DELEGATION_TOKEN_TTL_SECS, 503 493 ) 504 494 .unwrap(); 505 495 506 - let guard = fresh_jti_guard(); 507 - 508 - // First exchange: succeeds. 509 - verify_local_member_grant(&manager, &guard, &grant, "did:plc:owner", &space, "client") 496 + let payload = verify_local_delegation_token(&manager, &grant, "did:plc:owner", &space) 510 497 .await 511 498 .unwrap(); 512 - 513 - // Second exchange of the same grant: rejected as a replay. 514 - let result = 515 - verify_local_member_grant(&manager, &guard, &grant, "did:plc:owner", &space, "client") 516 - .await; 517 - assert!(result.is_err(), "second exchange should be rejected"); 499 + assert_eq!(payload.iss, "did:plc:alice"); 518 500 } 519 501 520 502 #[tokio::test(flavor = "multi_thread")] 521 - async fn member_grant_unknown_issuer_rejected() { 503 + async fn delegation_token_unknown_issuer_rejected() { 522 504 let tmp = TempDir::new().unwrap(); 523 505 let manager = fresh_manager(tmp.path()).await; 524 506 let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap(); 525 507 526 508 let space = test_space(); 527 509 // Issue with a *different* iss claim — verify lookup of the unknown 528 - // issuer fails. We hand-craft the payload to bypass create_member_grant. 510 + // issuer fails. We hand-craft the payload to bypass 511 + // create_delegation_token. 529 512 let header = general_purpose::URL_SAFE_NO_PAD 530 - .encode(br#"{"alg":"ES256K","typ":"space_member_grant"}"#); 513 + .encode(br##"{"alg":"ES256K","typ":"atproto-space-delegation+jwt","kid":"#atproto"}"##); 531 514 let bad_payload = serde_json::to_vec(&serde_json::json!({ 532 515 "iss": "did:plc:nobody", 533 - "aud": "did:plc:owner", 534 - "space": space.to_string(), 535 - "clientId": "client", 536 - "lxm": "com.atproto.space.getSpaceCredential", 516 + "aud": "did:plc:owner#atproto_space_host", 517 + "sub": space.to_string(), 537 518 "iat": 1_700_000_000, 538 519 "exp": 9_999_999_999u64, 520 + "jti": "nonce", 539 521 })) 540 522 .unwrap(); 541 523 let payload_b64 = general_purpose::URL_SAFE_NO_PAD.encode(&bad_payload); ··· 547 529 general_purpose::URL_SAFE_NO_PAD.encode(&sig) 548 530 ); 549 531 550 - let guard = fresh_jti_guard(); 551 - let result = 552 - verify_local_member_grant(&manager, &guard, &token, "did:plc:owner", &space, "client") 553 - .await; 532 + let result = verify_local_delegation_token(&manager, &token, "did:plc:owner", &space).await; 554 533 assert!(result.is_err()); 555 534 } 556 535 }
+2240 -583
crates/atproto-pds/src/http/space_handlers.rs
··· 1 - //! XRPC HTTP handlers for `com.atproto.space.*`. 1 + //! XRPC HTTP handlers for `com.atproto.space.*` and `com.atproto.simplespace.*`. 2 + //! 3 + //! Surface: 2 4 //! 3 - //! Surface (): 5 + //! Simplespace management (owner-only, OAuth): 6 + //! - `POST /xrpc/com.atproto.simplespace.createSpace` 7 + //! - `POST /xrpc/com.atproto.simplespace.addMember` 8 + //! - `POST /xrpc/com.atproto.simplespace.removeMember` 9 + //! - `GET /xrpc/com.atproto.simplespace.listMembers` 4 10 //! 5 - //! Management (owner-only, OAuth): 6 - //! - `POST /xrpc/com.atproto.space.createSpace` 11 + //! Space reads (OAuth): 7 12 //! - `GET /xrpc/com.atproto.space.getSpace` 8 13 //! - `GET /xrpc/com.atproto.space.listSpaces` 9 - //! - `POST /xrpc/com.atproto.space.addMember` 10 - //! - `POST /xrpc/com.atproto.space.removeMember` 11 - //! - `GET /xrpc/com.atproto.space.getMembers` 12 14 //! 13 15 //! Records (member-OAuth or remote SpaceCredential): 14 16 //! - `POST /xrpc/com.atproto.space.applyWrites` 17 + //! - `POST /xrpc/com.atproto.space.createRecord` 18 + //! - `POST /xrpc/com.atproto.space.putRecord` 19 + //! - `POST /xrpc/com.atproto.space.deleteRecord` 15 20 //! - `GET /xrpc/com.atproto.space.getRecord` 16 21 //! - `GET /xrpc/com.atproto.space.listRecords` 17 22 //! 18 23 //! Sync (read-only state + oplog): 19 24 //! - `GET /xrpc/com.atproto.space.getRepoState` 20 - //! - `GET /xrpc/com.atproto.space.getRepoOplog` 21 - //! - `GET /xrpc/com.atproto.space.getMemberState` 22 - //! - `GET /xrpc/com.atproto.space.getMemberOplog` 25 + //! - `GET /xrpc/com.atproto.space.listRepoOps` 23 26 //! 24 27 //! Credentials (member + owner two-step flow): 25 - //! - `POST /xrpc/com.atproto.space.getMemberGrant` (member-OAuth) 28 + //! - `GET /xrpc/com.atproto.space.getDelegationToken` (member-OAuth) 26 29 //! - `POST /xrpc/com.atproto.space.getSpaceCredential` (no auth — grant *is* the auth) 27 30 28 31 use crate::account::AccountManager; 29 32 use crate::actor_store::sql::SqlActorStore; 30 - use crate::errors::PdsError; 31 - use crate::http::auth::{bearer_token, request_htm_htu, require_authn_sub}; 33 + use crate::http::auth::{bearer_token, request_htm_htu, require_authn, require_authn_sub}; 32 34 use crate::http::errors::XrpcError; 33 35 use crate::http::space_auth::{ 34 - SpaceTokenKind, classify, local_signing_key, verify_local_member_grant, 36 + SpaceTokenKind, classify, local_signing_key, peek_delegation_token, 37 + verify_local_delegation_token, 35 38 }; 36 39 use crate::http::state::HttpState; 37 40 use crate::space::notify::upsert_recipient; ··· 39 42 use crate::space::writer::{SpaceCommitResult, SpaceWriteAction, SpaceWriteOp}; 40 43 use crate::space::{SpaceReader, SpaceService, SpaceSync, SpaceWriter}; 41 44 use atproto_space::credential::{ 42 - MEMBER_GRANT_TTL_SECS, create_member_grant, create_space_credential, 45 + DELEGATION_TOKEN_TTL_SECS, create_delegation_token, create_space_credential, 43 46 }; 47 + use atproto_space::storage::OplogCursor; 44 48 use atproto_space::types::SpaceUri; 45 49 use axum::Json; 46 50 use axum::extract::{Query, State}; ··· 122 126 require_authn_sub(parts, state, &htm, &htu).await 123 127 } 124 128 129 + /// Like [`require_session_subject`] but returns the full 130 + /// [`AuthSubject`](crate::http::auth::AuthSubject), so the caller can both 131 + /// read the subject DID and run an [`assert_space_scope`] check on OAuth 132 + /// tokens. 133 + async fn require_session_auth( 134 + parts: &Parts, 135 + state: &HttpState, 136 + ) -> Result<crate::http::auth::AuthSubject, XrpcError> { 137 + let (htm, htu) = request_htm_htu(parts); 138 + require_authn(parts, state, &htm, &htu).await 139 + } 140 + 125 141 // --------------------------------------------------------------------------- 126 142 // Management endpoints. 127 143 // --------------------------------------------------------------------------- 128 144 129 - /// Inputs for `createSpace`. 145 + /// Inputs for `com.atproto.simplespace.createSpace`. 146 + /// 147 + /// Matches the authoritative lexicon: `{did, type, skey?, config?}`. `did` 148 + /// is the DID of the space authority — it defaults to the authenticated 149 + /// caller and, if supplied, must equal the caller. `skey` auto-generates a 150 + /// TID when absent. `config` carries the initial `#spaceConfig`. 130 151 #[derive(Debug, Deserialize)] 131 152 pub struct CreateSpaceInput { 153 + /// DID of the space (the authority). Defaults to the caller. 154 + pub did: Option<String>, 132 155 /// NSID space type (e.g., `app.bsky.group`). 133 - #[serde(rename = "spaceType")] 156 + #[serde(rename = "type")] 134 157 pub space_type: String, 135 - /// Caller-chosen key. 136 - #[serde(rename = "spaceKey")] 137 - pub space_key: String, 158 + /// Space key. Auto-generated as a TID when omitted. 159 + pub skey: Option<String>, 160 + /// Initial space configuration (`com.atproto.simplespace.defs#spaceConfig`). 161 + pub config: Option<serde_json::Value>, 138 162 } 139 163 140 - /// Output of `createSpace` / `getSpace`. 164 + /// Output of `createSpace`: `{uri}`. 165 + #[derive(Debug, Serialize)] 166 + pub struct CreateSpaceResponse { 167 + /// URI of the created space. 168 + pub uri: String, 169 + } 170 + 171 + /// `getSpace` output (`{uri, config}`). 172 + pub use crate::space::GetSpaceOutput; 173 + /// Internal view of a space (re-exported for `listSpaces`). 141 174 pub use crate::space::SpaceInfo; 142 175 143 - /// `POST /xrpc/com.atproto.space.createSpace`. 176 + /// `POST /xrpc/com.atproto.simplespace.createSpace`. 144 177 pub async fn create_space( 145 178 State(state): State<HttpState>, 146 179 parts: Parts, 147 180 Json(input): Json<CreateSpaceInput>, 148 - ) -> Result<Json<SpaceInfo>, XrpcError> { 149 - let owner_did = require_session_subject(&parts, &state).await?; 181 + ) -> Result<Json<CreateSpaceResponse>, XrpcError> { 182 + let subject = require_session_auth(&parts, &state).await?; 183 + let caller = subject.sub().to_string(); 184 + // The space authority defaults to the caller; an explicit `did` must 185 + // match (callers may only create spaces under their own authority). 186 + let authority_did = match input.did { 187 + Some(ref d) if d != &caller => { 188 + return Err(XrpcError::new( 189 + StatusCode::FORBIDDEN, 190 + "NotSpaceOwner", 191 + "space did must equal the authenticated caller", 192 + )); 193 + } 194 + Some(d) => d, 195 + None => caller, 196 + }; 197 + let skey = input 198 + .skey 199 + .unwrap_or_else(|| atproto_record::tid::Tid::new().to_string()); 200 + // OAuth `space:` scope gate (manage). Build the target URI from the 201 + // resolved authority/type/skey; no-op for app-password sessions. 202 + let scope_uri = parse_space_uri(&format!( 203 + "{}{}/{}/{}", 204 + atproto_space::types::ATS_SCHEME, 205 + authority_did, 206 + input.space_type, 207 + skey 208 + ))?; 209 + assert_space_manage( 210 + &subject, 211 + &scope_uri, 212 + atproto_oauth::scopes::SpaceManageVerb::Create, 213 + )?; 214 + let config = match input.config { 215 + Some(ref v) => crate::space::SpaceConfig::from_create_input(v).map_err(XrpcError::from)?, 216 + None => crate::space::SpaceConfig::default(), 217 + }; 150 218 let svc = space_service(&state)?; 151 219 let info = svc 152 - .create_space(&owner_did, &input.space_type, &input.space_key) 220 + .create_space(&authority_did, &input.space_type, &skey, config) 221 + .await 222 + .map_err(XrpcError::from)?; 223 + Ok(Json(CreateSpaceResponse { uri: info.uri })) 224 + } 225 + 226 + /// Inputs for `com.atproto.simplespace.updateSpace`. 227 + #[derive(Debug, Deserialize)] 228 + pub struct UpdateSpaceInput { 229 + /// Space URI to update. 230 + pub space: String, 231 + /// New mint policy, if provided. 232 + #[serde(rename = "mintPolicy")] 233 + pub mint_policy: Option<String>, 234 + /// New managing-app identifier. Empty string clears to NULL. 235 + #[serde(rename = "managingApp")] 236 + pub managing_app: Option<String>, 237 + /// New app-access union, if provided (replaces wholesale). 238 + #[serde(rename = "appAccess")] 239 + pub app_access: Option<serde_json::Value>, 240 + } 241 + 242 + /// `POST /xrpc/com.atproto.simplespace.updateSpace`. 243 + pub async fn update_space( 244 + State(state): State<HttpState>, 245 + parts: Parts, 246 + Json(input): Json<UpdateSpaceInput>, 247 + ) -> Result<StatusCode, XrpcError> { 248 + let subject = require_session_auth(&parts, &state).await?; 249 + let owner = subject.sub().to_string(); 250 + let uri = parse_space_uri(&input.space)?; 251 + assert_space_manage( 252 + &subject, 253 + &uri, 254 + atproto_oauth::scopes::SpaceManageVerb::Update, 255 + )?; 256 + // Reassemble the config-field object the patch parser expects. 257 + let mut obj = serde_json::Map::new(); 258 + if let Some(p) = input.mint_policy { 259 + obj.insert("mintPolicy".to_string(), serde_json::Value::String(p)); 260 + } 261 + if let Some(a) = input.managing_app { 262 + obj.insert("managingApp".to_string(), serde_json::Value::String(a)); 263 + } 264 + if let Some(v) = input.app_access { 265 + obj.insert("appAccess".to_string(), v); 266 + } 267 + let patch = crate::space::SpaceConfigPatch::from_update_input(&serde_json::Value::Object(obj)) 268 + .map_err(XrpcError::from)?; 269 + space_service(&state)? 270 + .update_space(&owner, &uri, patch) 271 + .await 272 + .map_err(XrpcError::from)?; 273 + Ok(StatusCode::OK) 274 + } 275 + 276 + /// Inputs for `com.atproto.simplespace.deleteSpace`. 277 + #[derive(Debug, Deserialize)] 278 + pub struct DeleteSpaceInput { 279 + /// Space URI to tombstone. 280 + pub space: String, 281 + } 282 + 283 + /// `POST /xrpc/com.atproto.simplespace.deleteSpace`. 284 + pub async fn delete_space( 285 + State(state): State<HttpState>, 286 + parts: Parts, 287 + Json(input): Json<DeleteSpaceInput>, 288 + ) -> Result<StatusCode, XrpcError> { 289 + let subject = require_session_auth(&parts, &state).await?; 290 + let owner = subject.sub().to_string(); 291 + let uri = parse_space_uri(&input.space)?; 292 + assert_space_manage( 293 + &subject, 294 + &uri, 295 + atproto_oauth::scopes::SpaceManageVerb::Delete, 296 + )?; 297 + space_service(&state)? 298 + .delete_space(&owner, &uri) 153 299 .await 154 300 .map_err(XrpcError::from)?; 155 - Ok(Json(info)) 301 + 302 + // Best-effort: notify registered recipients + members that the space was 303 + // deleted (com.atproto.space.notifySpaceDeleted). Failures are swallowed — 304 + // the tombstone is already durable. 305 + fire_notify_space_deleted(&state, &uri, &owner).await; 306 + 307 + Ok(StatusCode::OK) 308 + } 309 + 310 + /// Best-effort fan-out of `notifySpaceDeleted` to every registered recipient 311 + /// and member of `uri` after the authority deletes the space. Resolves each 312 + /// target's PDS endpoint, mints a service-auth token (iss = authority, aud = 313 + /// target), and POSTs. All errors are logged and swallowed. 314 + async fn fire_notify_space_deleted(state: &HttpState, uri: &SpaceUri, authority_did: &str) { 315 + let Ok(manager) = account_manager(state) else { 316 + return; 317 + }; 318 + let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); 319 + let http = reqwest::Client::builder() 320 + .timeout(std::time::Duration::from_secs(10)) 321 + .user_agent(crate::user_agent()) 322 + .build() 323 + .unwrap_or_default(); 324 + 325 + // Owner signing key to mint the outbound service-auth tokens. 326 + let signing_key = match crate::http::space_auth::local_signing_key(manager, authority_did).await 327 + { 328 + Ok(k) => k, 329 + Err(e) => { 330 + tracing::warn!(error = ?e, space = %uri, "notifySpaceDeleted: owner signing key unavailable; skipping fan-out"); 331 + return; 332 + } 333 + }; 334 + 335 + // Open the owner's per-actor store to read recipients + members. 336 + let owner_store = match SqlActorStore::open(manager.data_dir(), authority_did).await { 337 + Ok(s) => s, 338 + Err(e) => { 339 + tracing::warn!(error = ?e, space = %uri, "notifySpaceDeleted: owner store unavailable; skipping fan-out"); 340 + return; 341 + } 342 + }; 343 + 344 + // Collect distinct target DIDs: recipient services + members. 345 + let mut targets: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); 346 + if let Ok(rows) = sqlx::query_as::<_, (String,)>( 347 + "SELECT DISTINCT service_did FROM space_credential_recipient WHERE space = ?", 348 + ) 349 + .bind(uri.to_string()) 350 + .fetch_all(owner_store.pool()) 351 + .await 352 + { 353 + for (did,) in rows { 354 + targets.insert(did); 355 + } 356 + } 357 + if let Ok(rows) = sqlx::query_as::<_, (String,)>("SELECT did FROM space_member WHERE space = ?") 358 + .bind(uri.to_string()) 359 + .fetch_all(owner_store.pool()) 360 + .await 361 + { 362 + for (did,) in rows { 363 + targets.insert(did); 364 + } 365 + } 366 + targets.remove(authority_did); 367 + 368 + for target in targets { 369 + if !target.starts_with("did:") { 370 + continue; 371 + } 372 + let endpoint = match crate::space::recipient::resolve_service_endpoint( 373 + &http, 374 + &format!("{target}#atproto_pds"), 375 + plc_dir, 376 + ) 377 + .await 378 + { 379 + Ok(Some(ep)) => ep, 380 + _ => continue, 381 + }; 382 + let token = match crate::space::service_auth::mint_service_auth( 383 + &signing_key, 384 + authority_did, 385 + &target, 386 + "com.atproto.space.notifySpaceDeleted", 387 + crate::space::service_auth::NOTIFY_SERVICE_AUTH_TTL_SECS, 388 + ) { 389 + Ok(t) => t, 390 + Err(_) => continue, 391 + }; 392 + let url = format!( 393 + "{}/xrpc/com.atproto.space.notifySpaceDeleted", 394 + endpoint.trim_end_matches('/') 395 + ); 396 + let body = serde_json::json!({ "space": uri.to_string() }); 397 + let _ = http.post(&url).bearer_auth(&token).json(&body).send().await; 398 + } 156 399 } 157 400 158 401 /// Query params for `getSpace`. 159 402 #[derive(Debug, Deserialize)] 160 403 pub struct GetSpaceQuery { 161 - /// Full `ats://...` URI of the space. 404 + /// Full space URI. 162 405 pub space: String, 163 406 } 164 407 165 408 /// `GET /xrpc/com.atproto.space.getSpace`. 409 + /// 410 + /// A **host** query authorized by a **space credential** (spec XRPC table line 411 + /// 481). A space credential confers whole-space read access, so this accepts 412 + /// either a space credential or a covering OAuth `read` scope, mirroring the 413 + /// other host/repo read methods. The `read` scope is whole-space and so is not 414 + /// collection-constrained. 166 415 pub async fn get_space( 167 416 State(state): State<HttpState>, 168 417 parts: Parts, 169 418 Query(q): Query<GetSpaceQuery>, 170 - ) -> Result<Json<SpaceInfo>, XrpcError> { 171 - let viewer = require_session_subject(&parts, &state).await?; 419 + ) -> Result<Json<GetSpaceOutput>, XrpcError> { 172 420 let uri = parse_space_uri(&q.space)?; 421 + let subject = require_any_authn(&parts, &state, &uri).await?; 422 + assert_space_read_opt(&state, &subject, &uri).await?; 423 + // The space authority hosts the space config; describe from the authority's 424 + // store regardless of which member's credential authorized the read. 425 + let viewer = match &subject { 426 + Some(s) => s.sub().to_string(), 427 + None => uri.space_did.clone(), 428 + }; 173 429 let svc = space_service(&state)?; 174 - svc.get_space(&viewer, &uri) 430 + let out = svc 431 + .get_space(&viewer, &uri) 175 432 .await 176 - .map_err(XrpcError::from)? 177 - .map(Json) 178 - .ok_or_else(|| { 179 - XrpcError::new( 180 - StatusCode::NOT_FOUND, 181 - "SpaceNotFound", 182 - format!("no such space {uri}"), 183 - ) 184 - }) 433 + .map_err(XrpcError::from)?; 434 + Ok(Json(out)) 185 435 } 186 436 187 437 /// Query params for `listSpaces`. ··· 236 486 pub did: String, 237 487 } 238 488 239 - /// `POST /xrpc/com.atproto.space.addMember`. 489 + /// `POST /xrpc/com.atproto.simplespace.addMember`. 240 490 pub async fn add_member( 241 491 State(state): State<HttpState>, 242 492 parts: Parts, 243 493 Json(input): Json<MemberInput>, 244 494 ) -> Result<StatusCode, XrpcError> { 245 - let owner = require_session_subject(&parts, &state).await?; 495 + let subject = require_session_auth(&parts, &state).await?; 496 + let owner = subject.sub().to_string(); 246 497 let uri = parse_space_uri(&input.space)?; 498 + assert_space_manage( 499 + &subject, 500 + &uri, 501 + atproto_oauth::scopes::SpaceManageVerb::Update, 502 + )?; 247 503 space_service(&state)? 248 504 .add_member(&owner, &uri, &input.did) 249 505 .await 250 506 .map_err(XrpcError::from)?; 251 - if state.notify_membership_email { 252 - notify_membership_change(&state, &input.did, &uri, "added").await; 253 - } 254 507 Ok(StatusCode::OK) 255 508 } 256 509 257 - /// `POST /xrpc/com.atproto.space.removeMember`. 510 + /// `POST /xrpc/com.atproto.simplespace.removeMember`. 258 511 pub async fn remove_member( 259 512 State(state): State<HttpState>, 260 513 parts: Parts, 261 514 Json(input): Json<MemberInput>, 262 515 ) -> Result<StatusCode, XrpcError> { 263 - let owner = require_session_subject(&parts, &state).await?; 516 + let subject = require_session_auth(&parts, &state).await?; 517 + let owner = subject.sub().to_string(); 264 518 let uri = parse_space_uri(&input.space)?; 519 + assert_space_manage( 520 + &subject, 521 + &uri, 522 + atproto_oauth::scopes::SpaceManageVerb::Update, 523 + )?; 265 524 space_service(&state)? 266 525 .remove_member(&owner, &uri, &input.did) 267 526 .await 268 527 .map_err(XrpcError::from)?; 269 - if state.notify_membership_email { 270 - notify_membership_change(&state, &input.did, &uri, "removed").await; 271 - } 272 528 Ok(StatusCode::OK) 273 529 } 274 530 275 - /// Best-effort: send a notification email to the affected member when 276 - /// `PDS_NOTIFY_MEMBERSHIP_EMAIL` is enabled. Logs 277 - /// continues on any failure (no email address, send error). 278 - async fn notify_membership_change(state: &HttpState, member_did: &str, uri: &SpaceUri, verb: &str) { 279 - // Lookup member's email — only send when the affected member is on 280 - // this PDS and has a confirmed email. Cross-PDS members are skipped 281 - // (we'd need a service-auth proxy to reach their PDS, out of scope). 282 - let directory = state.reader.accounts(); 283 - let row = match directory.lookup_did(member_did).await { 284 - Ok(Some(r)) => r, 285 - _ => { 286 - tracing::debug!(member_did, "membership-email: member not local; skipping"); 287 - return; 288 - } 289 - }; 290 - let Some(email) = row.email else { 291 - tracing::debug!( 292 - member_did, 293 - "membership-email: no email on account; skipping" 294 - ); 295 - return; 296 - }; 297 - let subject = match verb { 298 - "added" => "You've been added to a Spaces group", 299 - "removed" => "You've been removed from a Spaces group", 300 - _ => "Spaces membership change", 301 - }; 302 - let body = format!( 303 - "Your account ({member_did}) has been {verb} {} the Spaces group {uri}.\n\n\ 304 - If you didn't expect this, contact the space owner via your client.", 305 - if verb == "added" { "to" } else { "from" } 306 - ); 307 - if let Err(e) = state.email.send(&email, subject, &body).await { 308 - tracing::warn!(error = ?e, member_did, "membership-email send failed"); 309 - } 310 - } 311 - 312 - /// Query params for `getMembers`. 531 + /// Query params for `listMembers`. 313 532 #[derive(Debug, Deserialize)] 314 533 pub struct GetMembersQuery { 315 534 /// Space URI. ··· 320 539 pub limit: Option<u32>, 321 540 } 322 541 323 - /// Output of `getMembers`. 542 + /// Output of `listMembers`. 324 543 #[derive(Debug, Serialize)] 325 544 pub struct GetMembersResponse { 326 545 /// Member DIDs on this page. ··· 343 562 pub added_at: String, 344 563 } 345 564 346 - /// `GET /xrpc/com.atproto.space.getMembers`. 565 + /// `GET /xrpc/com.atproto.simplespace.listMembers`. 347 566 pub async fn get_members( 348 567 State(state): State<HttpState>, 349 568 parts: Parts, 350 569 Query(q): Query<GetMembersQuery>, 351 570 ) -> Result<Json<GetMembersResponse>, XrpcError> { 352 - let owner = require_session_subject(&parts, &state).await?; 571 + let subject = require_session_auth(&parts, &state).await?; 572 + let owner = subject.sub().to_string(); 353 573 let uri = parse_space_uri(&q.space)?; 574 + assert_space_scope( 575 + &state, 576 + &subject, 577 + &uri, 578 + atproto_oauth::scopes::SpaceAction::Read, 579 + None, 580 + ) 581 + .await?; 354 582 let page = space_service(&state)? 355 583 .list_members(&owner, &uri, q.cursor.as_deref(), q.limit.unwrap_or(50)) 356 584 .await ··· 405 633 parts: Parts, 406 634 Json(input): Json<ApplyWritesInput>, 407 635 ) -> Result<Json<SpaceCommitResult>, XrpcError> { 408 - let member_did = require_session_subject(&parts, &state).await?; 636 + let auth = require_session_auth(&parts, &state).await?; 637 + let member_did = auth.sub().to_string(); 409 638 let uri = parse_space_uri(&input.space)?; 410 639 let writer = space_writer(&state)?; 411 640 ··· 419 648 420 649 let mut ops = Vec::with_capacity(input.writes.len()); 421 650 for w in input.writes { 422 - let action = match w.action.as_str() { 423 - "create" => SpaceWriteAction::Create, 424 - "update" => SpaceWriteAction::Update, 425 - "delete" => SpaceWriteAction::Delete, 651 + let (action, scope_action) = match w.action.as_str() { 652 + "create" => ( 653 + SpaceWriteAction::Create, 654 + atproto_oauth::scopes::SpaceAction::Create, 655 + ), 656 + "update" => ( 657 + SpaceWriteAction::Update, 658 + atproto_oauth::scopes::SpaceAction::Update, 659 + ), 660 + "delete" => ( 661 + SpaceWriteAction::Delete, 662 + atproto_oauth::scopes::SpaceAction::Delete, 663 + ), 426 664 other => { 427 665 return Err(XrpcError::new( 428 666 StatusCode::BAD_REQUEST, ··· 431 669 )); 432 670 } 433 671 }; 672 + // OAuth `space:` scope gate — each op's action must be covered for 673 + // its collection (no-op for app-password sessions). 674 + assert_space_scope(&state, &auth, &uri, scope_action, Some(&w.collection)).await?; 434 675 ops.push(SpaceWriteOp { 435 676 action, 436 677 collection: w.collection, ··· 446 687 .map_err(XrpcError::from) 447 688 } 448 689 690 + // --------------------------------------------------------------------------- 691 + // Single-op record writes: createRecord / putRecord / deleteRecord. 692 + // 693 + // Each is a thin wrapper over the SpaceWriter single-op path. The `repo` 694 + // field names the DID being written to and MUST equal the authenticated 695 + // subject — members write only to their own per-actor store. 696 + // --------------------------------------------------------------------------- 697 + 698 + /// Output of `createRecord` / `putRecord`. 699 + #[derive(Debug, Serialize)] 700 + pub struct WriteRecordResponse { 701 + /// Six-segment space-URI of the written record. 702 + pub uri: String, 703 + /// CID of the record value (DAG-CBOR). 704 + pub cid: String, 705 + /// Validation status when known. 706 + #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] 707 + pub validation_status: Option<String>, 708 + } 709 + 710 + /// Inputs for `createRecord`. 711 + #[derive(Debug, Deserialize)] 712 + pub struct CreateRecordInput { 713 + /// Space URI. 714 + pub space: String, 715 + /// DID of the repo to write to (the authenticated member). 716 + pub repo: String, 717 + /// NSID collection. 718 + pub collection: String, 719 + /// Record key (optional — auto-TID when omitted). 720 + pub rkey: Option<String>, 721 + /// Lexicon validation toggle (reserved; not yet enforced). 722 + #[allow(dead_code)] 723 + pub validate: Option<bool>, 724 + /// Record value (must contain a `$type` field). 725 + pub record: serde_json::Value, 726 + } 727 + 728 + /// `POST /xrpc/com.atproto.space.createRecord`. 729 + pub async fn create_record_write( 730 + State(state): State<HttpState>, 731 + parts: Parts, 732 + Json(input): Json<CreateRecordInput>, 733 + ) -> Result<Json<WriteRecordResponse>, XrpcError> { 734 + let auth = require_session_auth(&parts, &state).await?; 735 + let subject = auth.sub().to_string(); 736 + require_repo_matches_subject(&input.repo, &subject)?; 737 + let uri = parse_space_uri(&input.space)?; 738 + assert_space_scope( 739 + &state, 740 + &auth, 741 + &uri, 742 + atproto_oauth::scopes::SpaceAction::Create, 743 + Some(&input.collection), 744 + ) 745 + .await?; 746 + let writer = space_writer(&state)?; 747 + let result = writer 748 + .create_record( 749 + &subject, 750 + &uri, 751 + input.collection, 752 + input.rkey.unwrap_or_default(), 753 + input.record, 754 + ) 755 + .await 756 + .map_err(XrpcError::from)?; 757 + Ok(Json(single_write_response(result)?)) 758 + } 759 + 760 + /// Inputs for `putRecord`. 761 + #[derive(Debug, Deserialize)] 762 + pub struct PutRecordInput { 763 + /// Space URI. 764 + pub space: String, 765 + /// DID of the repo to write to (the authenticated member). 766 + pub repo: String, 767 + /// NSID collection. 768 + pub collection: String, 769 + /// Record key. 770 + pub rkey: String, 771 + /// Lexicon validation toggle (reserved; not yet enforced). 772 + #[allow(dead_code)] 773 + pub validate: Option<bool>, 774 + /// Record value. 775 + pub record: serde_json::Value, 776 + } 777 + 778 + /// `POST /xrpc/com.atproto.space.putRecord`. 779 + pub async fn put_record_write( 780 + State(state): State<HttpState>, 781 + parts: Parts, 782 + Json(input): Json<PutRecordInput>, 783 + ) -> Result<Json<WriteRecordResponse>, XrpcError> { 784 + let auth = require_session_auth(&parts, &state).await?; 785 + let subject = auth.sub().to_string(); 786 + require_repo_matches_subject(&input.repo, &subject)?; 787 + let uri = parse_space_uri(&input.space)?; 788 + // putRecord may either create or update the record, so it requires both 789 + // the `create` and `update` actions per the 0016 OAuth-scope rules (spec 790 + // lines 405-411), asserting both before the upsert. 791 + assert_space_scope( 792 + &state, 793 + &auth, 794 + &uri, 795 + atproto_oauth::scopes::SpaceAction::Create, 796 + Some(&input.collection), 797 + ) 798 + .await?; 799 + assert_space_scope( 800 + &state, 801 + &auth, 802 + &uri, 803 + atproto_oauth::scopes::SpaceAction::Update, 804 + Some(&input.collection), 805 + ) 806 + .await?; 807 + let writer = space_writer(&state)?; 808 + let result = writer 809 + .put_record(&subject, &uri, input.collection, input.rkey, input.record) 810 + .await 811 + .map_err(XrpcError::from)?; 812 + Ok(Json(single_write_response(result)?)) 813 + } 814 + 815 + /// Inputs for `deleteRecord`. 816 + #[derive(Debug, Deserialize)] 817 + pub struct DeleteRecordInput { 818 + /// Space URI. 819 + pub space: String, 820 + /// DID of the repo to delete from (the authenticated member). 821 + pub repo: String, 822 + /// NSID collection. 823 + pub collection: String, 824 + /// Record key. 825 + pub rkey: String, 826 + } 827 + 828 + /// `POST /xrpc/com.atproto.space.deleteRecord`. 829 + pub async fn delete_record_write( 830 + State(state): State<HttpState>, 831 + parts: Parts, 832 + Json(input): Json<DeleteRecordInput>, 833 + ) -> Result<Json<serde_json::Value>, XrpcError> { 834 + let auth = require_session_auth(&parts, &state).await?; 835 + let subject = auth.sub().to_string(); 836 + require_repo_matches_subject(&input.repo, &subject)?; 837 + let uri = parse_space_uri(&input.space)?; 838 + assert_space_scope( 839 + &state, 840 + &auth, 841 + &uri, 842 + atproto_oauth::scopes::SpaceAction::Delete, 843 + Some(&input.collection), 844 + ) 845 + .await?; 846 + let writer = space_writer(&state)?; 847 + writer 848 + .delete_record(&subject, &uri, input.collection, input.rkey) 849 + .await 850 + .map_err(XrpcError::from)?; 851 + Ok(Json(serde_json::json!({}))) 852 + } 853 + 854 + /// Enforce that the `repo` field of a record-write request names the 855 + /// authenticated subject. Members may only write to their own per-actor 856 + /// store. 857 + fn require_repo_matches_subject(repo: &str, subject: &str) -> Result<(), XrpcError> { 858 + if repo == subject { 859 + Ok(()) 860 + } else { 861 + Err(XrpcError::new( 862 + StatusCode::FORBIDDEN, 863 + "InvalidRequest", 864 + "repo must equal the authenticated subject", 865 + )) 866 + } 867 + } 868 + 869 + /// Project a single-op [`SpaceCommitResult`] into a `createRecord` / 870 + /// `putRecord` output `{uri, cid}`. 871 + fn single_write_response(result: SpaceCommitResult) -> Result<WriteRecordResponse, XrpcError> { 872 + let uri = result.uris.into_iter().next().ok_or_else(|| { 873 + XrpcError::new( 874 + StatusCode::INTERNAL_SERVER_ERROR, 875 + "InternalError", 876 + "write produced no record URI", 877 + ) 878 + })?; 879 + let cid = result.cids.into_iter().next().flatten().ok_or_else(|| { 880 + XrpcError::new( 881 + StatusCode::INTERNAL_SERVER_ERROR, 882 + "InternalError", 883 + "write produced no record CID", 884 + ) 885 + })?; 886 + Ok(WriteRecordResponse { 887 + uri, 888 + cid, 889 + validation_status: None, 890 + }) 891 + } 892 + 449 893 /// Query params for `getRecord`. 450 894 #[derive(Debug, Deserialize)] 451 895 pub struct GetSpaceRecordQuery { ··· 455 899 pub collection: String, 456 900 /// Record key. 457 901 pub rkey: String, 902 + /// DID of the member whose repo to read from. If omitted, defaults to 903 + /// the authenticated subject (OAuth auth). Required when using 904 + /// space-credential auth. 905 + pub repo: Option<String>, 458 906 } 459 907 460 908 /// Output of `getRecord`. ··· 475 923 Query(q): Query<GetSpaceRecordQuery>, 476 924 ) -> Result<Json<GetSpaceRecordResponse>, XrpcError> { 477 925 let uri = parse_space_uri(&q.space)?; 478 - let auth = resolve_record_auth(&parts, &state).await?; 926 + let resolved = resolve_record_auth(&parts, &state, q.repo.as_deref()).await?; 927 + if let Some(subject) = &resolved.subject { 928 + assert_space_record_read( 929 + &state, 930 + subject, 931 + &uri, 932 + &resolved.target_repo, 933 + Some(&q.collection), 934 + ) 935 + .await?; 936 + } 479 937 let row = space_reader(&state)? 480 - .get_record(&uri, auth, &q.collection, &q.rkey) 938 + .get_record( 939 + &uri, 940 + resolved.auth, 941 + &resolved.target_repo, 942 + &q.collection, 943 + &q.rkey, 944 + ) 481 945 .await 482 946 .map_err(XrpcError::from)? 483 947 .ok_or_else(|| { ··· 506 970 pub struct ListSpaceRecordsQuery { 507 971 /// Space URI. 508 972 pub space: String, 509 - /// NSID collection. 510 - pub collection: String, 511 - /// Cursor (last `rkey`). 973 + /// NSID collection. When omitted, records are listed across every 974 + /// collection in the space (one page per collection, no cross-collection 975 + /// cursor). 976 + pub collection: Option<String>, 977 + /// Cursor (last `rkey`). Ignored when `collection` is omitted. 512 978 pub cursor: Option<String>, 513 979 /// Page size. 514 980 pub limit: Option<u32>, 981 + /// DID of the member whose repo to read from. If omitted, defaults to 982 + /// the authenticated subject (OAuth auth). Required when using 983 + /// space-credential auth. 984 + pub repo: Option<String>, 515 985 } 516 986 517 - /// One record in `listRecords`. 987 + /// One record in `listRecords` — keys-only per 988 + /// `com.atproto.space.listRecords#record` (`{collection, rkey, cid}`). Fetch 989 + /// the value separately via `getRecord`. 518 990 #[derive(Debug, Serialize)] 519 991 pub struct SpaceRecordItem { 520 - /// AT-URI. 521 - pub uri: String, 522 - /// CID. 992 + /// NSID collection. 993 + pub collection: String, 994 + /// Record key. 995 + pub rkey: String, 996 + /// CID of the record value. 523 997 pub cid: String, 524 - /// Decoded value. 525 - pub value: serde_json::Value, 526 998 } 527 999 528 1000 /// Output of `listRecords`. ··· 542 1014 Query(q): Query<ListSpaceRecordsQuery>, 543 1015 ) -> Result<Json<ListSpaceRecordsResponse>, XrpcError> { 544 1016 let uri = parse_space_uri(&q.space)?; 545 - let auth = resolve_record_auth(&parts, &state).await?; 1017 + let resolved = resolve_record_auth(&parts, &state, q.repo.as_deref()).await?; 1018 + if let Some(subject) = &resolved.subject { 1019 + // listRecords may span every collection in the repo (collection 1020 + // omitted). A `read_self` grant is collection-constrained, so a 1021 + // cross-collection list of the own repo requires a whole-space `read` 1022 + // grant; pass `None` to force the `read` path in that case. 1023 + assert_space_record_read( 1024 + &state, 1025 + subject, 1026 + &uri, 1027 + &resolved.target_repo, 1028 + q.collection.as_deref(), 1029 + ) 1030 + .await?; 1031 + } 546 1032 let page = space_reader(&state)? 547 1033 .list_records( 548 1034 &uri, 549 - auth, 550 - &q.collection, 1035 + resolved.auth, 1036 + &resolved.target_repo, 1037 + q.collection.as_deref(), 551 1038 q.cursor.as_deref(), 552 1039 q.limit.unwrap_or(50), 553 1040 ) 554 1041 .await 555 1042 .map_err(XrpcError::from)?; 556 - let mut records = Vec::with_capacity(page.records.len()); 557 - for r in page.records { 558 - let value: serde_json::Value = atproto_dasl::from_slice(&r.value).map_err(|e| { 559 - XrpcError::new( 560 - StatusCode::INTERNAL_SERVER_ERROR, 561 - "InternalError", 562 - format!("decode record value: {e}"), 563 - ) 564 - })?; 565 - records.push(SpaceRecordItem { 566 - uri: format!("{}/{}/{}", uri, r.collection, r.rkey), 1043 + let records: Vec<SpaceRecordItem> = page 1044 + .records 1045 + .into_iter() 1046 + .map(|r| SpaceRecordItem { 1047 + collection: r.collection, 1048 + rkey: r.rkey, 567 1049 cid: r.cid, 568 - value, 569 - }); 570 - } 1050 + }) 1051 + .collect(); 571 1052 Ok(Json(ListSpaceRecordsResponse { 572 1053 records, 573 1054 cursor: page.cursor, 574 1055 })) 575 1056 } 576 1057 1058 + /// Resolved auth + read-target DID for a Spaces record read. 1059 + struct ResolvedRecordAuth<'a> { 1060 + auth: SpaceReadAuth<'a>, 1061 + target_repo: String, 1062 + /// The bearer subject when the request authenticated via a session/OAuth 1063 + /// access token. `None` for SpaceCredential auth, which pre-authorizes 1064 + /// whole-space read at the auth layer and skips the `space:` scope gate. 1065 + subject: Option<crate::http::auth::AuthSubject>, 1066 + } 1067 + 577 1068 /// Decide which auth flavor a record-read uses based on the bearer token's 578 - /// `typ` header. Owns the borrow of `parts` so callers don't need separate 579 - /// branches. 1069 + /// `typ` header, validate the `repo` parameter against the auth mode, and 1070 + /// return the resolved target DID. 1071 + /// 1072 + /// - **OAuth / session bearer** — `repo` may be omitted (defaults to the 1073 + /// authenticated subject) or supplied to read another member's per-actor 1074 + /// store on this PDS. 1075 + /// - **SpaceCredential** — `repo` is **required**; returns 400 1076 + /// `InvalidRequest` when missing because a SpaceCredential is not bound 1077 + /// to any one member's repo. 1078 + /// - **Delegation token** — rejected; delegation tokens must be exchanged at 1079 + /// `getSpaceCredential` before being used to read records. 580 1080 async fn resolve_record_auth<'a>( 581 1081 parts: &'a Parts, 582 1082 state: &HttpState, 583 - ) -> Result<SpaceReadAuth<'a>, XrpcError> { 1083 + repo: Option<&str>, 1084 + ) -> Result<ResolvedRecordAuth<'a>, XrpcError> { 584 1085 let raw = bearer_token(parts)?; 585 1086 match classify(raw) { 586 - Some(SpaceTokenKind::SpaceCredential) => Ok(SpaceReadAuth::SpaceCredential { 587 - token: raw, 588 - // Issuer-binding simplification: bind to the issuer's 589 - // `client_id` claim rather than enforcing an HTTP-layer 590 - // expected client. The SpaceReader will re-verify the JWT 591 - // including its `client_id` claim; we pass the same value 592 - // here so that check is a no-op. The follow-up tracked in 593 - // pulls the expected `client_id` from a 594 - // DPoP/cnf binding on the peer access token wrapping this 595 - // credential. 596 - expected_client_id: extract_credential_client_id(raw) 597 - .map(|s| Box::leak(s.into_boxed_str()) as &str) 598 - .unwrap_or(""), 599 - }), 600 - Some(SpaceTokenKind::MemberGrant) => Err(XrpcError::new( 1087 + Some(SpaceTokenKind::SpaceCredential) => { 1088 + let repo = repo.ok_or_else(|| { 1089 + XrpcError::new( 1090 + StatusCode::BAD_REQUEST, 1091 + "InvalidRequest", 1092 + "repo is required for space credential auth", 1093 + ) 1094 + })?; 1095 + Ok(ResolvedRecordAuth { 1096 + auth: SpaceReadAuth::SpaceCredential { token: raw }, 1097 + target_repo: repo.to_string(), 1098 + subject: None, 1099 + }) 1100 + } 1101 + Some(SpaceTokenKind::DelegationToken) => Err(XrpcError::new( 601 1102 StatusCode::BAD_REQUEST, 602 1103 "InvalidToken", 603 - "MemberGrant cannot be used to read records; exchange it at getSpaceCredential first", 1104 + "delegation token cannot be used to read records; exchange it at getSpaceCredential first", 604 1105 )), 605 1106 _ => { 606 1107 // Treat as a session-style or OAuth access token. The unified 607 1108 // helper transparently accepts both shapes and enforces DPoP 608 1109 // when an OAuth token carries a `cnf.jkt` thumbprint. 609 1110 let (htm, htu) = request_htm_htu(parts); 610 - let sub = require_authn_sub(parts, state, &htm, &htu).await?; 611 - let did_static: &'a str = Box::leak(sub.into_boxed_str()); 612 - Ok(SpaceReadAuth::OwnPds { 613 - account_did: did_static, 1111 + let subject = require_authn(parts, state, &htm, &htu).await?; 1112 + let sub = subject.sub().to_string(); 1113 + let did_static: &'a str = Box::leak(sub.clone().into_boxed_str()); 1114 + let target_repo = repo.map(|r| r.to_string()).unwrap_or(sub); 1115 + Ok(ResolvedRecordAuth { 1116 + auth: SpaceReadAuth::OwnPds { 1117 + account_did: did_static, 1118 + }, 1119 + target_repo, 1120 + subject: Some(subject), 614 1121 }) 615 1122 } 616 1123 } 617 1124 } 618 1125 619 - /// Best-effort extraction of `clientId` from a SpaceCredential JWT *without* 620 - /// signature verification — used solely to forward the value into 621 - /// `SpaceReadAuth::SpaceCredential::expected_client_id` so the reader's 622 - /// re-verification accepts the same bound `client_id`. 623 - fn extract_credential_client_id(token: &str) -> Option<String> { 624 - let payload_b64 = token.split('.').nth(1)?; 625 - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD 626 - .decode(payload_b64.as_bytes()) 627 - .ok()?; 628 - let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; 629 - Some(value.get("clientId")?.as_str()?.to_string()) 630 - } 631 - 632 1126 // --------------------------------------------------------------------------- 633 1127 // Sync endpoints. 634 1128 // --------------------------------------------------------------------------- ··· 638 1132 pub struct RepoStateQuery { 639 1133 /// Space URI. 640 1134 pub space: String, 641 - /// Member DID whose record-state to fetch. 642 - pub member: String, 1135 + /// DID of the account whose repo state to retrieve. 1136 + pub repo: String, 643 1137 } 644 1138 645 - /// Output of `getRepoState` / `getMemberState`. 1139 + /// JSON wire form of a signed commit (`com.atproto.space.defs#signedCommit`). 1140 + /// 1141 + /// The four byte fields are emitted in atproto's lex-data `bytes` form 1142 + /// (`{"$bytes": "<base64>"}`, standard alphabet, unpadded) rather than the 1143 + /// JSON array that [`atproto_space::Commit`]'s `serde_bytes` derive would 1144 + /// produce, so the wire shape matches the lexicon and the 0016 spec 1145 + /// `#signedCommit` field table (lines 307-316). 1146 + #[derive(Debug, Serialize)] 1147 + pub struct SignedCommitDto { 1148 + /// `sha256` of the LtHash state (32 bytes), as `{"$bytes": ...}`. 1149 + pub hash: BytesValue, 1150 + /// `HMAC-SHA256` over `hash`, as `{"$bytes": ...}`. 1151 + pub mac: BytesValue, 1152 + /// Per-commit fresh IKM (32 bytes), as `{"$bytes": ...}`. 1153 + pub ikm: BytesValue, 1154 + /// `sign(ctx)` over the commit context, as `{"$bytes": ...}`. 1155 + pub sig: BytesValue, 1156 + /// Commit revision (TID). 1157 + pub rev: String, 1158 + } 1159 + 1160 + /// atproto lex-data `bytes` value — serializes as `{"$bytes": "<base64>"}` 1161 + /// (standard alphabet, unpadded). 1162 + #[derive(Debug)] 1163 + pub struct BytesValue(Vec<u8>); 1164 + 1165 + impl Serialize for BytesValue { 1166 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 1167 + where 1168 + S: serde::Serializer, 1169 + { 1170 + use serde::ser::SerializeMap; 1171 + let b64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&self.0); 1172 + let mut map = serializer.serialize_map(Some(1))?; 1173 + map.serialize_entry("$bytes", &b64)?; 1174 + map.end() 1175 + } 1176 + } 1177 + 1178 + impl SignedCommitDto { 1179 + /// Convert an [`atproto_space::Commit`] into its `$bytes`-encoded wire DTO. 1180 + fn from_commit(c: atproto_space::Commit) -> Self { 1181 + Self { 1182 + hash: BytesValue(c.hash), 1183 + mac: BytesValue(c.mac), 1184 + ikm: BytesValue(c.ikm), 1185 + sig: BytesValue(c.sig), 1186 + rev: c.rev, 1187 + } 1188 + } 1189 + } 1190 + 1191 + /// Output of `getRepoState`. 1192 + /// 1193 + /// `commit` is absent when the repo has never been written to, per 1194 + /// `com.atproto.space.getRepoState`. 646 1195 #[derive(Debug, Serialize)] 647 1196 pub struct StateResponse { 648 - /// Hex-encoded SetHash digest. `null` if empty. 649 - #[serde(rename = "setHash")] 650 - pub set_hash: Option<String>, 651 - /// Latest rev (TID). `null` if empty. 652 - pub rev: Option<String>, 1197 + /// The current signed commit, or absent when empty. 1198 + #[serde(skip_serializing_if = "Option::is_none")] 1199 + pub commit: Option<SignedCommitDto>, 1200 + } 1201 + 1202 + /// Build a signed commit from a persisted SetHash state + rev. 1203 + /// 1204 + /// Rehydrates the [`PdsSetHash`](crate::realm::PdsSetHash) lattice from the 1205 + /// 2048-byte state persisted in [`RepoState`](atproto_space::RepoState), 1206 + /// derives the 32-byte commitment, and signs a [`SpaceContext`] (the full 1207 + /// `ats://` space URI + rev) with `signing_key`, per the 0016 Permissioned Data 1208 + /// draft (§ Commit signature). Returns `None` when the state is empty (no 1209 + /// commits yet). 1210 + fn signed_commit_from_state( 1211 + space: &SpaceUri, 1212 + state: &atproto_space::RepoState, 1213 + signing_key: &atproto_identity::key::KeyData, 1214 + ) -> Result<Option<SignedCommitDto>, XrpcError> { 1215 + use atproto_space::set_hash::SetHash; 1216 + let (Some(state_bytes), Some(rev)) = (state.set_hash.as_deref(), state.rev.as_deref()) else { 1217 + return Ok(None); 1218 + }; 1219 + let set_hash = crate::realm::PdsSetHash::from_state_bytes(state_bytes).map_err(|e| { 1220 + XrpcError::new( 1221 + StatusCode::INTERNAL_SERVER_ERROR, 1222 + "InternalError", 1223 + format!("rehydrate set hash: {e}"), 1224 + ) 1225 + })?; 1226 + let ctx = atproto_space::SpaceContext { 1227 + space: space.to_string(), 1228 + rev: rev.to_string(), 1229 + }; 1230 + let commit = atproto_space::create_commit(&set_hash, &ctx, signing_key).map_err(|e| { 1231 + XrpcError::new( 1232 + StatusCode::INTERNAL_SERVER_ERROR, 1233 + "InternalError", 1234 + format!("sign commit: {e}"), 1235 + ) 1236 + })?; 1237 + Ok(Some(SignedCommitDto::from_commit(commit))) 653 1238 } 654 1239 655 1240 /// `GET /xrpc/com.atproto.space.getRepoState`. 1241 + /// 1242 + /// Returns the repo account's current signed commit (`records` scope, signed 1243 + /// by the repo account's atproto signing key). `commit` is absent when the 1244 + /// repo is empty. 656 1245 pub async fn get_repo_state( 657 1246 State(state): State<HttpState>, 658 1247 parts: Parts, 659 1248 Query(q): Query<RepoStateQuery>, 660 1249 ) -> Result<Json<StateResponse>, XrpcError> { 661 - require_any_authn(&parts, &state).await?; 662 1250 let uri = parse_space_uri(&q.space)?; 1251 + let subject = require_any_authn(&parts, &state, &uri).await?; 1252 + assert_space_read_opt(&state, &subject, &uri).await?; 663 1253 let st = space_sync(&state)? 664 - .get_repo_state(&uri, &q.member) 1254 + .get_repo_state(&uri, &q.repo) 665 1255 .await 666 1256 .map_err(XrpcError::from)?; 667 - Ok(Json(StateResponse { 668 - set_hash: st.set_hash.as_deref().map(hex::encode), 669 - rev: st.rev, 670 - })) 1257 + let manager = account_manager(&state)?; 1258 + let signing_key = local_signing_key(manager, &q.repo).await?; 1259 + let commit = signed_commit_from_state(&uri, &st, &signing_key)?; 1260 + Ok(Json(StateResponse { commit })) 671 1261 } 672 1262 673 - /// Query params for `getRepoOplog`. 1263 + /// Query params for `listRepoOps`. 674 1264 #[derive(Debug, Deserialize)] 675 1265 pub struct RepoOplogQuery { 676 1266 /// Space URI. 677 1267 pub space: String, 678 - /// Member DID. 679 - pub member: String, 680 - /// Rev to start *after* (exclusive). 1268 + /// DID of the account whose oplog to retrieve. 1269 + pub repo: String, 1270 + /// Opaque `(rev, idx)` cursor (`"<rev>__<idx>"`) to start *after* 1271 + /// (exclusive). Carries the last op delivered on the prior page so that an 1272 + /// atomic batch larger than `limit` is not skipped across paging. 681 1273 pub since: Option<String>, 682 1274 /// Page size. 683 1275 pub limit: Option<u32>, 684 1276 } 685 1277 686 - /// One oplog entry in the wire form. 1278 + /// One records-oplog entry, wire shape per `com.atproto.space.listRepoOps#opEntry`. 1279 + /// 1280 + /// Exactly `{ rev, collection, rkey, cid, prev }`. `cid` is `null` for deletes; 1281 + /// `prev` is `null` for creates (both keys are always present, per the 1282 + /// lexicon's `nullable` set). 687 1283 #[derive(Debug, Serialize)] 688 - pub struct OplogEntryDto { 689 - /// Rev (TID). 1284 + pub struct RecordOpEntry { 1285 + /// Rev (TID). Ops sharing a rev belong to the same batch. 690 1286 pub rev: String, 691 - /// Index within the batch. 692 - pub idx: u32, 693 - /// Action. 694 - pub action: String, 695 - /// NSID collection (records only). 696 - #[serde(skip_serializing_if = "Option::is_none")] 697 - pub collection: Option<String>, 698 - /// Record key (records only). 699 - #[serde(skip_serializing_if = "Option::is_none")] 700 - pub rkey: Option<String>, 701 - /// New CID (records only). 702 - #[serde(skip_serializing_if = "Option::is_none")] 1287 + /// NSID collection. 1288 + pub collection: String, 1289 + /// Record key. 1290 + pub rkey: String, 1291 + /// New record CID; `null` for deletes. 703 1292 pub cid: Option<String>, 704 - /// Prior CID (records only). 705 - #[serde(skip_serializing_if = "Option::is_none")] 1293 + /// Prior record CID; `null` for creates. 706 1294 pub prev: Option<String>, 707 - /// DID (members only). 708 - #[serde(skip_serializing_if = "Option::is_none")] 709 - pub did: Option<String>, 710 1295 } 711 1296 712 - /// Output of `getRepoOplog` / `getMemberOplog`. 1297 + /// Output of `listRepoOps`. 1298 + /// 1299 + /// `commit` is included only when the page reaches the head of the oplog 1300 + /// (`ops.len() < limit`), so a caught-up consumer can verify the resulting 1301 + /// state; it is omitted on backfill responses. 713 1302 #[derive(Debug, Serialize)] 714 - pub struct OplogResponse { 1303 + pub struct RepoOpsResponse { 715 1304 /// Oplog ops on this page (rev,idx ascending). 716 - pub ops: Vec<OplogEntryDto>, 717 - /// Current state at read time. 718 - pub state: StateResponse, 1305 + pub ops: Vec<RecordOpEntry>, 1306 + /// The repo's current signed commit, when caught up. Absent on backfill 1307 + /// or when the repo is empty. 1308 + #[serde(skip_serializing_if = "Option::is_none")] 1309 + pub commit: Option<SignedCommitDto>, 1310 + /// Opaque `(rev, idx)` cursor for the next page (the last op on this page), 1311 + /// when more may remain. Encoded as `"<rev>__<idx>"` so a batch larger than 1312 + /// `limit` resumes within the batch rather than skipping its tail. 1313 + #[serde(skip_serializing_if = "Option::is_none")] 1314 + pub cursor: Option<String>, 719 1315 } 720 1316 721 - /// `GET /xrpc/com.atproto.space.getRepoOplog`. 722 - pub async fn get_repo_oplog( 1317 + /// `GET /xrpc/com.atproto.space.listRepoOps`. 1318 + /// 1319 + /// Incremental sync for a per-account repo within a space. On a caught-up 1320 + /// page (fewer ops than `limit`), attaches the repo's current signed commit 1321 + /// (`records` scope, signed by the repo account's key). 1322 + pub async fn list_repo_ops( 723 1323 State(state): State<HttpState>, 724 1324 parts: Parts, 725 1325 Query(q): Query<RepoOplogQuery>, 726 - ) -> Result<Json<OplogResponse>, XrpcError> { 727 - require_any_authn(&parts, &state).await?; 1326 + ) -> Result<Json<RepoOpsResponse>, XrpcError> { 728 1327 let uri = parse_space_uri(&q.space)?; 1328 + let subject = require_any_authn(&parts, &state, &uri).await?; 1329 + assert_space_read_opt(&state, &subject, &uri).await?; 1330 + let limit = q.limit.unwrap_or(100); 1331 + let since = match q.since.as_deref() { 1332 + Some(token) => Some(OplogCursor::from_token(token).map_err(|_| { 1333 + XrpcError::new( 1334 + StatusCode::BAD_REQUEST, 1335 + "InvalidRequest", 1336 + "since cursor is malformed", 1337 + ) 1338 + })?), 1339 + None => None, 1340 + }; 729 1341 let page = space_sync(&state)? 730 - .get_repo_oplog(&uri, &q.member, q.since.as_deref(), q.limit.unwrap_or(100)) 731 - .await 732 - .map_err(XrpcError::from)?; 733 - Ok(Json(oplog_to_dto(page))) 734 - } 735 - 736 - /// Query params for `getMemberState`. 737 - #[derive(Debug, Deserialize)] 738 - pub struct MemberStateQuery { 739 - /// Space URI. 740 - pub space: String, 741 - } 742 - 743 - /// `GET /xrpc/com.atproto.space.getMemberState`. 744 - pub async fn get_member_state( 745 - State(state): State<HttpState>, 746 - parts: Parts, 747 - Query(q): Query<MemberStateQuery>, 748 - ) -> Result<Json<StateResponse>, XrpcError> { 749 - require_any_authn(&parts, &state).await?; 750 - let uri = parse_space_uri(&q.space)?; 751 - let st = space_sync(&state)? 752 - .get_member_state(&uri) 1342 + .list_repo_ops(&uri, &q.repo, since.as_ref(), limit) 753 1343 .await 754 1344 .map_err(XrpcError::from)?; 755 - Ok(Json(StateResponse { 756 - set_hash: st.set_hash.as_deref().map(hex::encode), 757 - rev: st.rev, 758 - })) 759 - } 760 1345 761 - /// Query params for `getMemberOplog`. 762 - #[derive(Debug, Deserialize)] 763 - pub struct MemberOplogQuery { 764 - /// Space URI. 765 - pub space: String, 766 - /// Rev to start after. 767 - pub since: Option<String>, 768 - /// Page size. 769 - pub limit: Option<u32>, 770 - } 1346 + let caught_up = (page.ops.len() as u32) < limit; 1347 + // Next-page cursor is the `(rev, idx)` of the last op on this page, so a 1348 + // batch larger than `limit` resumes within the batch on the next call. 1349 + let cursor = if caught_up { 1350 + None 1351 + } else { 1352 + page.ops 1353 + .last() 1354 + .map(|o| OplogCursor::new(o.rev.clone(), o.idx).to_token()) 1355 + }; 1356 + let ops: Vec<RecordOpEntry> = page 1357 + .ops 1358 + .into_iter() 1359 + .map(|o| RecordOpEntry { 1360 + rev: o.rev, 1361 + collection: o.collection.unwrap_or_default(), 1362 + rkey: o.rkey.unwrap_or_default(), 1363 + cid: o.cid, 1364 + prev: o.prev, 1365 + }) 1366 + .collect(); 771 1367 772 - /// `GET /xrpc/com.atproto.space.getMemberOplog`. 773 - pub async fn get_member_oplog( 774 - State(state): State<HttpState>, 775 - parts: Parts, 776 - Query(q): Query<MemberOplogQuery>, 777 - ) -> Result<Json<OplogResponse>, XrpcError> { 778 - require_any_authn(&parts, &state).await?; 779 - let uri = parse_space_uri(&q.space)?; 780 - let page = space_sync(&state)? 781 - .get_member_oplog(&uri, q.since.as_deref(), q.limit.unwrap_or(100)) 782 - .await 783 - .map_err(XrpcError::from)?; 784 - Ok(Json(oplog_to_dto(page))) 785 - } 1368 + let commit = if caught_up { 1369 + let manager = account_manager(&state)?; 1370 + let signing_key = local_signing_key(manager, &q.repo).await?; 1371 + signed_commit_from_state(&uri, &page.state, &signing_key)? 1372 + } else { 1373 + None 1374 + }; 786 1375 787 - fn oplog_to_dto(page: atproto_space::storage::OplogPage) -> OplogResponse { 788 - OplogResponse { 789 - ops: page 790 - .ops 791 - .into_iter() 792 - .map(|o| OplogEntryDto { 793 - rev: o.rev, 794 - idx: o.idx, 795 - action: o.action, 796 - collection: o.collection, 797 - rkey: o.rkey, 798 - cid: o.cid, 799 - prev: o.prev, 800 - did: o.did, 801 - }) 802 - .collect(), 803 - state: StateResponse { 804 - set_hash: page.state.set_hash.as_deref().map(hex::encode), 805 - rev: page.state.rev, 806 - }, 807 - } 1376 + Ok(Json(RepoOpsResponse { 1377 + ops, 1378 + commit, 1379 + cursor, 1380 + })) 808 1381 } 809 1382 810 1383 // --------------------------------------------------------------------------- 811 1384 // Credential mint endpoints. 812 1385 // --------------------------------------------------------------------------- 813 1386 814 - /// Inputs for `getMemberGrant`. 1387 + /// Query params for `getDelegationToken`. 815 1388 #[derive(Debug, Deserialize)] 816 - pub struct GetMemberGrantInput { 1389 + pub struct GetDelegationTokenQuery { 817 1390 /// Space URI. 818 1391 pub space: String, 819 - /// OAuth `client_id` of the requesting app. 820 - #[serde(rename = "clientId")] 821 - pub client_id: String, 822 1392 } 823 1393 824 - /// Output of `getMemberGrant` and `getSpaceCredential`. 1394 + /// Output of `getDelegationToken` — `{ token }` only, per the 1395 + /// `com.atproto.space.getDelegationToken` lexicon. 825 1396 #[derive(Debug, Serialize)] 826 - pub struct TokenWrapper { 827 - /// The compact-form JWT. 1397 + pub struct DelegationTokenResponse { 1398 + /// The compact-form delegation JWT. 828 1399 pub token: String, 829 - /// Expiry in seconds since epoch. 830 - #[serde(rename = "expiresAt")] 831 - pub expires_at: u64, 1400 + } 1401 + 1402 + /// Output of `getSpaceCredential` — `{ credential }`, the bare JWT, per the 1403 + /// `com.atproto.space.getSpaceCredential` lexicon (spec lines 246). 1404 + #[derive(Debug, Serialize)] 1405 + pub struct SpaceCredentialResponse { 1406 + /// The compact-form space-credential JWT. 1407 + pub credential: String, 832 1408 } 833 1409 834 - /// `POST /xrpc/com.atproto.space.getMemberGrant` — member-OAuth gated. 835 - /// Mints a [`MemberGrant`](atproto_space::credential::MemberGrant) signed by 836 - /// the member's atproto signing key, scoped to the given `clientId`. 837 - pub async fn get_member_grant( 1410 + /// `GET /xrpc/com.atproto.space.getDelegationToken` — member-OAuth gated. 1411 + /// Mints a [`DelegationToken`](atproto_space::credential::DelegationToken) 1412 + /// signed by the member's atproto signing key (header `kid="#atproto"`). 1413 + /// 1414 + /// The delegation token asserts only the user-to-app delegation; it carries no 1415 + /// app identity. It is `aud`-addressed to the space host 1416 + /// (`<spaceDid>#atproto_space_host`) and `sub`-bound to the space URI. The 1417 + /// output body is exactly `{ "token": <jwt> }` — the token is later exchanged 1418 + /// with the space authority at `getSpaceCredential`. 1419 + pub async fn get_delegation_token( 838 1420 State(state): State<HttpState>, 839 1421 parts: Parts, 840 - Json(input): Json<GetMemberGrantInput>, 841 - ) -> Result<Json<TokenWrapper>, XrpcError> { 842 - let member_did = require_session_subject(&parts, &state).await?; 843 - let uri = parse_space_uri(&input.space)?; 844 - let manager = account_manager(&state)?; 845 - let signing_key = local_signing_key(manager, &member_did).await?; 846 - let token = create_member_grant( 847 - &member_did, 848 - &uri.owner_did, 1422 + Query(q): Query<GetDelegationTokenQuery>, 1423 + ) -> Result<Json<DelegationTokenResponse>, XrpcError> { 1424 + let (htm, htu) = request_htm_htu(&parts); 1425 + let subject = require_authn(&parts, &state, &htm, &htu).await?; 1426 + let member_did = subject.sub().to_string(); 1427 + // The delegation token proves an app is acting on the user's behalf, so 1428 + // the request must come from an OAuth session (which carries a client 1429 + // identity). The token itself records nothing about the app — app 1430 + // identity is the client attestation's job — but we still reject 1431 + // app-password sessions here, matching the OAuth-gated flow. 1432 + if subject.client_id().is_none() { 1433 + return Err(XrpcError::new( 1434 + StatusCode::FORBIDDEN, 1435 + "InvalidRequest", 1436 + "getDelegationToken requires OAuth auth with a client_id", 1437 + )); 1438 + } 1439 + let uri = parse_space_uri(&q.space)?; 1440 + // OAuth `space:` read-scope gate before minting. No-op for app-password 1441 + // sessions, which are rejected above for lacking a client_id anyway. 1442 + assert_space_scope( 1443 + &state, 1444 + &subject, 849 1445 &uri, 850 - &input.client_id, 851 - &signing_key, 852 - MEMBER_GRANT_TTL_SECS, 1446 + atproto_oauth::scopes::SpaceAction::Read, 1447 + None, 853 1448 ) 854 - .map_err(|e| { 855 - XrpcError::new( 856 - StatusCode::INTERNAL_SERVER_ERROR, 857 - "InternalError", 858 - format!("mint MemberGrant: {e}"), 859 - ) 860 - })?; 861 - Ok(Json(TokenWrapper { 862 - token, 863 - expires_at: now_secs() + MEMBER_GRANT_TTL_SECS, 864 - })) 1449 + .await?; 1450 + let manager = account_manager(&state)?; 1451 + let signing_key = local_signing_key(manager, &member_did).await?; 1452 + let token = create_delegation_token(&member_did, &uri, &signing_key, DELEGATION_TOKEN_TTL_SECS) 1453 + .map_err(|e| { 1454 + XrpcError::new( 1455 + StatusCode::INTERNAL_SERVER_ERROR, 1456 + "InternalError", 1457 + format!("mint delegation token: {e}"), 1458 + ) 1459 + })?; 1460 + Ok(Json(DelegationTokenResponse { token })) 865 1461 } 866 1462 867 - /// Inputs for `getSpaceCredential` — the grant *is* the auth. 1463 + /// Inputs for `getSpaceCredential`. The delegation token is presented in the 1464 + /// `Authorization: Bearer` header (not the body); the body carries the target 1465 + /// space and an optional client attestation. 868 1466 #[derive(Debug, Deserialize)] 869 1467 pub struct GetSpaceCredentialInput { 870 - /// MemberGrant compact-form JWT. 871 - pub grant: String, 1468 + /// The space being requested, an `ats://` URI. 1469 + pub space: String, 1470 + /// Optional client attestation (compact JWT) establishing the app's 1471 + /// identity. Required only when the space gates on app identity 1472 + /// (`appAccess` is `#allowList`). Matches the lexicon 1473 + /// `clientAttestation` field. 1474 + #[serde(rename = "clientAttestation", default)] 1475 + pub client_attestation: Option<String>, 872 1476 } 873 1477 874 - /// `POST /xrpc/com.atproto.space.getSpaceCredential` — grant-gated. 875 - /// Verifies the [`MemberGrant`](atproto_space::credential::MemberGrant) 876 - /// against the member's signing key, then mints a 1478 + /// `POST /xrpc/com.atproto.space.getSpaceCredential` — delegation-token gated. 1479 + /// Reads the [`DelegationToken`](atproto_space::credential::DelegationToken) 1480 + /// from the `Authorization: Bearer` header, verifies it against the member's 1481 + /// `#atproto` signing key, enforces single-use via its `jti`, then mints a 877 1482 /// [`SpaceCredential`](atproto_space::credential::SpaceCredential) signed by 878 - /// the owner's signing key. 1483 + /// the authority's `#atproto_space` signing key. 879 1484 pub async fn get_space_credential( 880 1485 State(state): State<HttpState>, 1486 + parts: Parts, 881 1487 Json(input): Json<GetSpaceCredentialInput>, 882 - ) -> Result<Json<TokenWrapper>, XrpcError> { 1488 + ) -> Result<Json<SpaceCredentialResponse>, XrpcError> { 883 1489 let manager = account_manager(&state)?; 884 1490 885 - // First, peek the grant payload to learn space + clientId so we can 886 - // verify against the right key. 887 - let payload_b64 = input.grant.split('.').nth(1).ok_or_else(|| { 888 - XrpcError::new(StatusCode::BAD_REQUEST, "InvalidToken", "grant: malformed") 889 - })?; 890 - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD 891 - .decode(payload_b64.as_bytes()) 1491 + // The delegation token is the bearer credential. 1492 + let grant_jwt = bearer_token(&parts)?; 1493 + 1494 + let space = parse_space_uri(&input.space)?; 1495 + 1496 + // Peek the delegation token to learn its issuer (the member) so we know 1497 + // which key to resolve, and confirm it targets this space. 1498 + let unverified = peek_delegation_token(grant_jwt)?; 1499 + 1500 + // Try the local path first (fast); on `AccountNotFound` (the 1501 + // member is not on this PDS), fall through to the remote path that 1502 + // resolves the member's DID document via atproto-identity. 1503 + let payload = 1504 + match verify_local_delegation_token(manager, grant_jwt, &space.space_did, &space).await { 1505 + Ok(p) => p, 1506 + Err(e) if e.status == StatusCode::NOT_FOUND && e.name == "AccountNotFound" => { 1507 + tracing::debug!( 1508 + member = %unverified.iss, 1509 + "member not local; attempting cross-PDS DID-document resolution" 1510 + ); 1511 + let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); 1512 + let http = reqwest::Client::builder() 1513 + .timeout(std::time::Duration::from_secs(10)) 1514 + .user_agent(crate::user_agent()) 1515 + .build() 1516 + .unwrap_or_default(); 1517 + crate::http::space_auth::verify_remote_delegation_token( 1518 + &http, 1519 + grant_jwt, 1520 + &space.space_did, 1521 + &space, 1522 + plc_dir, 1523 + ) 1524 + .await? 1525 + } 1526 + Err(e) => return Err(e), 1527 + }; 1528 + 1529 + // Enforce single-use of the delegation token via its `jti` (spec line 1530 + // 149). Consume it before minting so a replayed token is refused. 1531 + let dt_ttl = std::time::Duration::from_secs(payload.exp.saturating_sub(now_secs())); 1532 + state 1533 + .jti_guard 1534 + .check_and_insert(&payload.jti, dt_ttl) 1535 + .await 892 1536 .map_err(|_| { 893 1537 XrpcError::new( 894 - StatusCode::BAD_REQUEST, 1538 + StatusCode::FORBIDDEN, 895 1539 "InvalidToken", 896 - "grant: payload not base64url", 1540 + "delegation token already used (single-use replay)", 897 1541 ) 898 1542 })?; 899 - let unverified: atproto_space::credential::MemberGrant = serde_json::from_slice(&bytes) 900 - .map_err(|_| { 901 - XrpcError::new( 902 - StatusCode::BAD_REQUEST, 903 - "InvalidToken", 904 - "grant: payload not JSON", 1543 + 1544 + let owner_signing = local_signing_key(manager, &space.space_did).await?; 1545 + 1546 + // ── Mint-time authorization (defs.json: a credential is minted only when 1547 + // the user is authorized by `mintPolicy` AND their app by `appAccess`). 1548 + // 1549 + // The requesting member is the delegation token's issuer. App identity is 1550 + // established solely by the optional client attestation: when one is 1551 + // presented we verify it (which yields the attested client_id) and use 1552 + // that for the APP axis and the credential's `client_id`. When none is 1553 + // presented the credential's `client_id` is omitted (spec lines 221, 228). 1554 + let mint_http = reqwest::Client::builder() 1555 + .timeout(std::time::Duration::from_secs(10)) 1556 + .user_agent(crate::user_agent()) 1557 + .build() 1558 + .unwrap_or_default(); 1559 + 1560 + let attested_client_id: Option<String> = match input.client_attestation.as_deref() { 1561 + Some(att) => Some( 1562 + crate::space::mint_authz::verify_client_attestation( 1563 + &mint_http, 1564 + &state.jti_guard, 1565 + att, 1566 + &space, 905 1567 ) 906 - })?; 1568 + .await 1569 + .map_err(mint_denial_to_xrpc)?, 1570 + ), 1571 + None => None, 1572 + }; 907 1573 908 - let space = parse_space_uri(&unverified.space)?; 909 - // §3.3: try the local path first (fast); on `AccountNotFound` (the 910 - // member is not on this PDS), fall through to the remote path that 911 - // resolves the member's DID document via atproto-identity. 912 - let payload = match verify_local_member_grant( 913 - manager, 914 - &state.jti_guard, 915 - &input.grant, 916 - &space.owner_did, 917 - &space, 918 - &unverified.client_id, 919 - ) 920 - .await 1574 + let svc = space_service(&state)?; 1575 + let inputs = svc 1576 + .load_mint_authz_inputs(&space, &payload.iss) 1577 + .await 1578 + .map_err(XrpcError::from)?; 1579 + if !inputs.found { 1580 + return Err(XrpcError::new( 1581 + StatusCode::NOT_FOUND, 1582 + "SpaceNotFound", 1583 + format!("space not found: {space}"), 1584 + )); 1585 + } 1586 + if inputs.deleted { 1587 + return Err(XrpcError::new( 1588 + StatusCode::NOT_FOUND, 1589 + "SpaceDeleted", 1590 + format!("space deleted: {space}"), 1591 + )); 1592 + } 1593 + 1594 + // USER axis (mintPolicy). 1595 + match crate::space::mint_authz::user_axis_local(inputs.config.mint_policy, inputs.is_member) 1596 + .map_err(mint_denial_to_xrpc)? 921 1597 { 922 - Ok(p) => p, 923 - Err(e) if e.status == StatusCode::NOT_FOUND && e.name == "AccountNotFound" => { 924 - tracing::debug!( 925 - member = %unverified.iss, 926 - "member not local; attempting cross-PDS DID-document resolution" 927 - ); 1598 + Some(()) => {} 1599 + None => { 1600 + // managing-app: ask the managingApp via checkUserAccess. 1601 + let managing_app = inputs.config.managing_app.as_deref().ok_or_else(|| { 1602 + XrpcError::new( 1603 + StatusCode::FORBIDDEN, 1604 + "NotAuthorized", 1605 + "mintPolicy is managing-app but no managingApp is configured", 1606 + ) 1607 + })?; 928 1608 let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); 929 - let http = reqwest::Client::builder() 930 - .timeout(std::time::Duration::from_secs(10)) 931 - .user_agent(crate::user_agent()) 932 - .build() 933 - .unwrap_or_default(); 934 - crate::http::space_auth::verify_remote_member_grant( 935 - &http, 936 - &state.jti_guard, 937 - &input.grant, 938 - &space.owner_did, 1609 + let endpoint = crate::space::recipient::resolve_service_endpoint( 1610 + &mint_http, 1611 + managing_app, 1612 + plc_dir, 1613 + ) 1614 + .await 1615 + .map_err(XrpcError::from)? 1616 + .ok_or_else(|| { 1617 + XrpcError::new( 1618 + StatusCode::FORBIDDEN, 1619 + "NotAuthorized", 1620 + format!("could not resolve managingApp service endpoint: {managing_app}"), 1621 + ) 1622 + })?; 1623 + crate::space::mint_authz::check_user_access( 1624 + &mint_http, 1625 + &endpoint, 1626 + managing_app, 1627 + &owner_signing, 1628 + &space.space_did, 939 1629 &space, 940 - &unverified.client_id, 941 - plc_dir, 1630 + &payload.iss, 1631 + attested_client_id.as_deref(), 942 1632 ) 943 - .await? 1633 + .await 1634 + .map_err(mint_denial_to_xrpc)?; 944 1635 } 945 - Err(e) => return Err(e), 946 - }; 1636 + } 1637 + 1638 + // APP axis (appAccess). 1639 + crate::space::mint_authz::app_axis(&inputs.config.app_access, attested_client_id.as_deref()) 1640 + .map_err(mint_denial_to_xrpc)?; 947 1641 948 - let owner_signing = local_signing_key(manager, &space.owner_did).await?; 1642 + // The credential's `client_id` is the attested application identity, or 1643 + // omitted entirely when the request carried no attestation. 949 1644 let credential_ttl = state.space_credential_ttl_secs; 950 1645 let token = create_space_credential( 951 - &space.owner_did, 1646 + &space.space_did, 952 1647 &space, 953 - &payload.client_id, 1648 + attested_client_id.as_deref(), 954 1649 &owner_signing, 955 1650 credential_ttl, 956 1651 ) ··· 967 1662 // `(space, service_did)` — re-issuing to the same client just bumps 968 1663 // `last_issued_at`. 969 1664 // 970 - // §3.2: discover the consumer's actual `(service_did, service_endpoint)` 971 - // by resolving `<host_of_client_id>/.well-known/atproto-did` and the 972 - // resulting DID document's `AtprotoPersonalDataServer` service. Falls 973 - // back to a documented stub `(grant.iss, client_id-origin)` when any 974 - // step fails — that's the same behavior the §3.2 audit identified, but 975 - // now flagged as `fully_resolved=false` so operators can audit. 1665 + // Recipient discovery is keyed off the *attested* client_id (the 1666 + // consumer's client-metadata URL): we resolve 1667 + // `<host_of_client_id>/.well-known/atproto-did` and the resulting DID 1668 + // document's `AtprotoPersonalDataServer` service, falling back to a 1669 + // documented stub when any step fails. When the request carried no 1670 + // attestation there is no consumer URL to resolve, so we register the 1671 + // member's own DID as the recipient via the stub. 976 1672 let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); 977 1673 let recipient_http = reqwest::Client::builder() 978 1674 .timeout(std::time::Duration::from_secs(10)) 979 1675 .user_agent(crate::user_agent()) 980 1676 .build() 981 1677 .unwrap_or_default(); 982 - let resolved = match crate::space::recipient::resolve_recipient( 983 - &recipient_http, 984 - &payload.iss, 985 - &payload.client_id, 986 - plc_dir, 987 - ) 988 - .await 989 - { 990 - Ok(r) => r, 991 - Err(e) => { 992 - tracing::warn!( 993 - error = ?e, 994 - client_id = %payload.client_id, 995 - "recipient resolution failed; falling back to stub" 996 - ); 997 - crate::space::recipient::stub_recipient(&payload.iss, &payload.client_id) 998 - } 1678 + let resolved = match attested_client_id.as_deref() { 1679 + Some(client_id) => match crate::space::recipient::resolve_recipient( 1680 + &recipient_http, 1681 + &payload.iss, 1682 + client_id, 1683 + plc_dir, 1684 + ) 1685 + .await 1686 + { 1687 + Ok(r) => r, 1688 + Err(e) => { 1689 + tracing::warn!( 1690 + error = ?e, 1691 + client_id = %client_id, 1692 + "recipient resolution failed; falling back to stub" 1693 + ); 1694 + crate::space::recipient::stub_recipient(&payload.iss, client_id) 1695 + } 1696 + }, 1697 + None => crate::space::recipient::stub_recipient(&payload.iss, &payload.iss), 999 1698 }; 1000 1699 if !resolved.fully_resolved { 1001 1700 tracing::warn!( 1002 - client_id = %payload.client_id, 1701 + member = %payload.iss, 1003 1702 stub_did = %resolved.service_did, 1004 1703 stub_endpoint = %resolved.service_endpoint, 1005 1704 "recipient resolved via stub; consumer DID document was unreachable or missing a PDS service entry" 1006 1705 ); 1007 1706 } 1008 1707 1009 - match SqlActorStore::open(manager.data_dir(), &space.owner_did).await { 1708 + match SqlActorStore::open(manager.data_dir(), &space.space_did).await { 1010 1709 Ok(owner_store) => { 1011 1710 if let Err(e) = upsert_recipient( 1012 1711 owner_store.pool(), ··· 1019 1718 tracing::warn!( 1020 1719 error = ?e, 1021 1720 space = %space, 1022 - client_id = %payload.client_id, 1023 - "register space_credential_recipient failed; this consumer will not receive notifyWrite/notifyMembership" 1721 + member = %payload.iss, 1722 + "register space_credential_recipient failed; this consumer will not receive notifyWrite" 1024 1723 ); 1025 1724 } 1026 1725 } ··· 1033 1732 } 1034 1733 } 1035 1734 1036 - Ok(Json(TokenWrapper { 1037 - token, 1038 - expires_at: now_secs() + credential_ttl, 1039 - })) 1735 + Ok(Json(SpaceCredentialResponse { credential: token })) 1040 1736 } 1041 1737 1042 1738 // --------------------------------------------------------------------------- ··· 1053 1749 }) 1054 1750 } 1055 1751 1056 - /// Sync endpoints accept either a session/OAuth access token or a 1057 - /// SpaceCredential. / G35 the PDS does not enforce 1058 - /// membership at sync time — we just require *some* valid token shape. 1059 - /// OAuth tokens with a DPoP `cnf.jkt` binding still trigger the proof 1060 - /// check via the unified helper. 1061 - async fn require_any_authn(parts: &Parts, state: &HttpState) -> Result<(), XrpcError> { 1752 + /// Host/sync read endpoints accept either a session/OAuth access token or a 1753 + /// `SpaceCredential` bound to `space`. The PDS does not enforce membership at 1754 + /// sync time, but a presented credential MUST verify: when the bearer's `typ` 1755 + /// classifies as a `SpaceCredential`, its signature is checked against the 1756 + /// space authority's `#atproto_space` key and its `iss`/`sub`/`exp` are bound 1757 + /// to `space`. A forged, unsigned, expired, or wrong-space credential is 1758 + /// rejected with 401 rather than admitted on its `typ` string alone. 1759 + /// OAuth tokens with a DPoP `cnf.jkt` binding still trigger the proof check via 1760 + /// the unified helper. 1761 + /// 1762 + /// Returns the bearer [`AuthSubject`](crate::http::auth::AuthSubject) for a 1763 + /// session/OAuth access token, or `None` for a verified `SpaceCredential` 1764 + /// (which pre-authorizes whole-space read at the auth layer). Callers gate the 1765 + /// `space:` scope only on the returned subject. 1766 + async fn require_any_authn( 1767 + parts: &Parts, 1768 + state: &HttpState, 1769 + space: &SpaceUri, 1770 + ) -> Result<Option<crate::http::auth::AuthSubject>, XrpcError> { 1062 1771 let raw = bearer_token(parts)?; 1063 1772 if let Some(SpaceTokenKind::SpaceCredential) = classify(raw) { 1064 - // We don't have the space URI here for full verification, but the 1065 - // structural typ check is enough — the reader handlers re-verify 1066 - // signatures against owner keys on every record read. Sync endpoints 1067 - // are intentionally permissive; consumers do inductive verification. 1068 - return Ok(()); 1773 + space_reader(state)? 1774 + .verify_space_credential_for(space, raw) 1775 + .await 1776 + .map_err(|e| { 1777 + XrpcError::new( 1778 + StatusCode::UNAUTHORIZED, 1779 + "Unauthorized", 1780 + format!("invalid space credential: {e}"), 1781 + ) 1782 + })?; 1783 + return Ok(None); 1069 1784 } 1070 1785 let (htm, htu) = request_htm_htu(parts); 1071 - require_authn_sub(parts, state, &htm, &htu) 1786 + require_authn(parts, state, &htm, &htu).await.map(Some) 1787 + } 1788 + 1789 + /// Host/sync read auth restricted to a verified `SpaceCredential` (spec XRPC 1790 + /// table: `getSpace` and `listRepos` are "space credential" only). Rejects an 1791 + /// OAuth/session bearer with 401 — only a credential minted by the space 1792 + /// authority is acceptable — and verifies the credential against `space`. 1793 + async fn require_space_credential( 1794 + parts: &Parts, 1795 + state: &HttpState, 1796 + space: &SpaceUri, 1797 + ) -> Result<(), XrpcError> { 1798 + let raw = bearer_token(parts)?; 1799 + if classify(raw) != Some(SpaceTokenKind::SpaceCredential) { 1800 + return Err(XrpcError::new( 1801 + StatusCode::UNAUTHORIZED, 1802 + "Unauthorized", 1803 + "this method requires a space credential", 1804 + )); 1805 + } 1806 + space_reader(state)? 1807 + .verify_space_credential_for(space, raw) 1072 1808 .await 1073 - .map(|_| ()) 1809 + .map_err(|e| { 1810 + XrpcError::new( 1811 + StatusCode::UNAUTHORIZED, 1812 + "Unauthorized", 1813 + format!("invalid space credential: {e}"), 1814 + ) 1815 + }) 1816 + } 1817 + 1818 + // --------------------------------------------------------------------------- 1819 + // OAuth `space:` scope enforcement. 1820 + // 1821 + // Enforces the 0016 `space:` OAuth scope rules (spec lines 369-419): only 1822 + // OAuth credentials carry granular `space:` permissions. App-password 1823 + // sessions (`access`) and SpaceCredential auth pre-authorize at the auth 1824 + // layer and skip the scope check entirely. A missing scope maps to 403. 1825 + // --------------------------------------------------------------------------- 1826 + 1827 + /// Assert that the OAuth scope set granted to `subject` permits `action` on 1828 + /// the space `uri`. Collection-scoped (`create`/`update`/`delete`) targets 1829 + /// must pass `collection`; `read`/`manage` leave it `None`. 1830 + /// 1831 + /// No-op for non-OAuth subjects (app-password sessions): they carry no 1832 + /// `space:` grants and are authorized at the session layer, matching the 1833 + /// reference `auth.credentials.type !== 'oauth'` early-return. A scope 1834 + /// shortfall on an OAuth subject becomes a 403 `InvalidToken` carrying the 1835 + /// minimal scope that would have satisfied the request. 1836 + /// Gate the `read` action for a sync/read endpoint whose auth was resolved 1837 + /// via [`require_any_authn`]: `Some(subject)` runs the OAuth scope check, 1838 + /// `None` (SpaceCredential auth) skips it. 1839 + async fn assert_space_read_opt( 1840 + state: &HttpState, 1841 + subject: &Option<crate::http::auth::AuthSubject>, 1842 + uri: &SpaceUri, 1843 + ) -> Result<(), XrpcError> { 1844 + match subject { 1845 + Some(s) => { 1846 + assert_space_scope( 1847 + state, 1848 + s, 1849 + uri, 1850 + atproto_oauth::scopes::SpaceAction::Read, 1851 + None, 1852 + ) 1853 + .await 1854 + } 1855 + None => Ok(()), 1856 + } 1857 + } 1858 + 1859 + async fn assert_space_scope( 1860 + state: &HttpState, 1861 + subject: &crate::http::auth::AuthSubject, 1862 + uri: &SpaceUri, 1863 + action: atproto_oauth::scopes::SpaceAction, 1864 + collection: Option<&str>, 1865 + ) -> Result<(), XrpcError> { 1866 + if !subject.is_oauth() { 1867 + return Ok(()); 1868 + } 1869 + let target = match collection { 1870 + Some(c) => atproto_oauth::scopes::SpaceTarget::with_collection( 1871 + uri.space_type.as_str(), 1872 + &uri.space_did, 1873 + uri.space_key.as_str(), 1874 + action, 1875 + c, 1876 + ), 1877 + None => atproto_oauth::scopes::SpaceTarget::new( 1878 + uri.space_type.as_str(), 1879 + &uri.space_did, 1880 + uri.space_key.as_str(), 1881 + action, 1882 + ), 1883 + }; 1884 + // Resolve the space type declaration's collections so a bare grant's 1885 + // omitted-`collection` default matches the declared collections (spec line 1886 + // 413). Whole-space `read` ignores collection, so the lookup is skipped. 1887 + let declared = match action { 1888 + atproto_oauth::scopes::SpaceAction::Read => Vec::new(), 1889 + _ => declared_collections(state, uri.space_type.as_str()).await, 1890 + }; 1891 + subject 1892 + .scopes() 1893 + .assert_space_with(&target, &declared) 1894 + .map_err(|e| { 1895 + tracing::debug!( 1896 + space = %uri, 1897 + action = action.as_str(), 1898 + needed = %e.scope, 1899 + "space scope assertion failed" 1900 + ); 1901 + XrpcError::new( 1902 + StatusCode::FORBIDDEN, 1903 + "InvalidToken", 1904 + format!( 1905 + "insufficient OAuth scope for this space operation; need `{}`", 1906 + e.scope 1907 + ), 1908 + ) 1909 + }) 1910 + } 1911 + 1912 + /// Assert that the OAuth scope set granted to `subject` permits the 1913 + /// space-management `verb` on the space `uri` (spec lines 415-419). The verb 1914 + /// maps onto the management surface at the call site (e.g. `update` authorizes 1915 + /// `updateSpace`/`addMember`/`removeMember`). No-op for non-OAuth subjects. 1916 + fn assert_space_manage( 1917 + subject: &crate::http::auth::AuthSubject, 1918 + uri: &SpaceUri, 1919 + verb: atproto_oauth::scopes::SpaceManageVerb, 1920 + ) -> Result<(), XrpcError> { 1921 + if !subject.is_oauth() { 1922 + return Ok(()); 1923 + } 1924 + let target = atproto_oauth::scopes::SpaceManageTarget::new( 1925 + uri.space_type.as_str(), 1926 + &uri.space_did, 1927 + uri.space_key.as_str(), 1928 + verb, 1929 + ); 1930 + subject.scopes().assert_space_manage(&target).map_err(|e| { 1931 + tracing::debug!( 1932 + space = %uri, 1933 + verb = verb.as_str(), 1934 + needed = %e.scope, 1935 + "space manage scope assertion failed" 1936 + ); 1937 + XrpcError::new( 1938 + StatusCode::FORBIDDEN, 1939 + "InvalidToken", 1940 + format!( 1941 + "insufficient OAuth scope for this space-management operation; need `{}`", 1942 + e.scope 1943 + ), 1944 + ) 1945 + }) 1946 + } 1947 + 1948 + /// Assert that the OAuth scope set granted to `subject` permits reading the 1949 + /// record(s) at `uri` from `target_repo` (spec lines 392-413). 1950 + /// 1951 + /// - Reading the holder's **own** repo (`target_repo == subject.sub()`) is 1952 + /// satisfied by either a whole-space `read` grant or a collection-covering 1953 + /// `read_self` grant. A `read_self` grant is collection-constrained, so a 1954 + /// cross-collection listing (`collection == None`) of the own repo falls 1955 + /// back to requiring whole-space `read`. 1956 + /// - Reading **another** member's repo requires whole-space `read` 1957 + /// (collection-independent). 1958 + /// 1959 + /// No-op for non-OAuth subjects (app-password sessions). 1960 + async fn assert_space_record_read( 1961 + state: &HttpState, 1962 + subject: &crate::http::auth::AuthSubject, 1963 + uri: &SpaceUri, 1964 + target_repo: &str, 1965 + collection: Option<&str>, 1966 + ) -> Result<(), XrpcError> { 1967 + let own_repo = subject.sub() == target_repo; 1968 + match (own_repo, collection) { 1969 + // Own repo, single collection: read_self (also satisfied by read). 1970 + (true, Some(c)) => { 1971 + assert_space_scope( 1972 + state, 1973 + subject, 1974 + uri, 1975 + atproto_oauth::scopes::SpaceAction::ReadSelf, 1976 + Some(c), 1977 + ) 1978 + .await 1979 + } 1980 + // Own repo across all collections, or any other member's repo: the 1981 + // collection-independent whole-space `read` grant is required. 1982 + _ => { 1983 + assert_space_scope( 1984 + state, 1985 + subject, 1986 + uri, 1987 + atproto_oauth::scopes::SpaceAction::Read, 1988 + None, 1989 + ) 1990 + .await 1991 + } 1992 + } 1993 + } 1994 + 1995 + /// Resolve the `collections` declared by the space type's declaration for the 1996 + /// `space_type` NSID, used to expand a bare `space:` grant's 1997 + /// omitted-`collection` default (spec line 413). 1998 + /// 1999 + /// Resolution is delegated to the configured 2000 + /// [`SpaceDeclarationResolver`](crate::space::SpaceDeclarationResolver). It is 2001 + /// **fail-closed**: when no resolver is configured, the spaceType is the `*` 2002 + /// wildcard (no declaration to draw from), or resolution fails, this returns an 2003 + /// empty list — a bare grant then confers no write targets. Explicit 2004 + /// `collection=` grants are unaffected (they never consult the default). 2005 + async fn declared_collections(state: &HttpState, space_type: &str) -> Vec<String> { 2006 + // `spaceType=*` has no declaration (spec line 413); skip resolution. 2007 + if space_type == "*" { 2008 + return Vec::new(); 2009 + } 2010 + let Some(resolver) = state.space_declaration_resolver.as_ref() else { 2011 + return Vec::new(); 2012 + }; 2013 + match resolver.resolve(space_type).await { 2014 + Some(decl) => decl.collections, 2015 + None => { 2016 + tracing::warn!( 2017 + space_type, 2018 + "space-type declaration resolution failed; bare `space:` grant defaults to no write collections (fail-closed)" 2019 + ); 2020 + Vec::new() 2021 + } 2022 + } 1074 2023 } 1075 2024 1076 2025 fn now_secs() -> u64 { ··· 1078 2027 .duration_since(std::time::UNIX_EPOCH) 1079 2028 .map(|d| d.as_secs()) 1080 2029 .unwrap_or(0) 2030 + } 2031 + 2032 + /// Map a mint-authorization denial to its documented `getSpaceCredential` 2033 + /// XRPC error. User/app/not-authorized refusals are `403`; an invalid client 2034 + /// attestation is `400`. 2035 + fn mint_denial_to_xrpc(denial: crate::space::mint_authz::MintDenial) -> XrpcError { 2036 + use crate::space::mint_authz::MintDenial; 2037 + let status = match denial { 2038 + MintDenial::InvalidClientAttestation { .. } => StatusCode::BAD_REQUEST, 2039 + _ => StatusCode::FORBIDDEN, 2040 + }; 2041 + tracing::debug!( 2042 + error_name = denial.error_name(), 2043 + reason = denial.reason(), 2044 + "getSpaceCredential mint authorization denied" 2045 + ); 2046 + XrpcError::new(status, denial.error_name(), denial.reason().to_string()) 1081 2047 } 1082 2048 1083 2049 // --------------------------------------------------------------------------- 1084 - // Inbound notify endpoints (§3.1). 2050 + // Inbound notify endpoints. 1085 2051 // --------------------------------------------------------------------------- 1086 2052 1087 - /// `POST /xrpc/com.atproto.space.notifyWrite` — receive a notify-write 1088 - /// payload from a remote owner PDS. 2053 + /// `POST /xrpc/com.atproto.space.notifyWrite` — receive a contentless 2054 + /// notify-write `{ space, repo, rev }` announcing that `repo` advanced to 2055 + /// `rev` within `space`. 1089 2056 /// 1090 - /// Authentication is structural: the embedded signed `Commit` must 1091 - /// verify against the owner's atproto signing key (resolved from the 1092 - /// owner's DID document). No bearer token is required — the signature 1093 - /// IS the auth, and the `(space, rev)` dedup keeps replays harmless. 2057 + /// Authentication is **service auth**: a bearer JWT signed by the writer's 2058 + /// `#atproto` key, with `iss == repo` and `aud == <space owner DID>`, 2059 + /// scoped to `lxm == com.atproto.space.notifyWrite`. 1094 2060 /// 1095 - /// The `?recipient=<did>` query parameter selects which local account's 1096 - /// per-actor store records the receipt. Typically this is the local 1097 - /// account holding the `SpaceCredential` for the named space. 2061 + /// Behavior implements the spec's two-hop fan-out (lines 343-351): members 2062 + /// notify the authority, which forwards to the endpoints registered for the 2063 + /// space. When this PDS hosts the space owner and the writer is a member, the 2064 + /// notification is forwarded to every registered recipient (registerNotify 2065 + /// subscribers + credential consumers). A lightweight receipt is recorded for 2066 + /// dedup + audit. For a non-owner host (e.g. a syncing service that also holds 2067 + /// a replica) the handler simply records the receipt — there is no fan-out 2068 + /// state. 1098 2069 pub async fn notify_write( 1099 2070 State(state): State<HttpState>, 1100 - Query(q): Query<NotifyRecipientQuery>, 2071 + parts: Parts, 1101 2072 body: axum::body::Bytes, 1102 2073 ) -> Result<StatusCode, XrpcError> { 1103 2074 let manager = account_manager(&state)?; ··· 1107 2078 .user_agent(crate::user_agent()) 1108 2079 .build() 1109 2080 .unwrap_or_default(); 1110 - crate::space::inbound::receive_write( 2081 + 2082 + // Decode first so we know the space/owner the service-auth `aud` must bind. 2083 + let payload: crate::space::notify::NotifyWritePayload = serde_json::from_slice(body.as_ref()) 2084 + .map_err(|e| { 2085 + XrpcError::new( 2086 + StatusCode::BAD_REQUEST, 2087 + "InvalidRequest", 2088 + format!("decode notifyWrite payload: {e}"), 2089 + ) 2090 + })?; 2091 + let space = parse_space_uri(&payload.space)?; 2092 + 2093 + // Service-auth: signature over the writer's key, aud = owner DID, 2094 + // lxm scoped to notifyWrite. 2095 + let token = bearer_token(&parts)?; 2096 + let claims = crate::space::service_auth::verify_service_auth( 1111 2097 &http, 2098 + token, 1112 2099 plc_dir, 1113 - manager.data_dir(), 1114 - &q.recipient, 1115 - body.as_ref(), 2100 + &space.space_did, 2101 + crate::space::notify::NOTIFY_WRITE_NSID, 1116 2102 ) 1117 2103 .await 1118 2104 .map_err(XrpcError::from)?; 1119 - Ok(StatusCode::OK) 1120 - } 2105 + // The JWT issuer must match the claimed writer so a PDS can't deliver a 2106 + // notification on someone else's behalf (reference `notifyWrite.ts`). 2107 + if claims.iss != payload.repo { 2108 + return Err(XrpcError::new( 2109 + StatusCode::FORBIDDEN, 2110 + "Forbidden", 2111 + "notifyWrite iss does not match claimed writer", 2112 + )); 2113 + } 1121 2114 1122 - /// `POST /xrpc/com.atproto.space.notifyMembership` — receive a 1123 - /// notify-membership payload from a remote owner PDS. 1124 - pub async fn notify_membership( 1125 - State(state): State<HttpState>, 1126 - Query(q): Query<NotifyRecipientQuery>, 1127 - body: axum::body::Bytes, 1128 - ) -> Result<StatusCode, XrpcError> { 1129 - let manager = account_manager(&state)?; 1130 - let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); 1131 - let http = reqwest::Client::builder() 1132 - .timeout(std::time::Duration::from_secs(10)) 1133 - .user_agent(crate::user_agent()) 1134 - .build() 1135 - .unwrap_or_default(); 1136 - crate::space::inbound::receive_membership( 2115 + // Owner-side fan-out (HOP 2): only the owner's PDS holds the member list + 2116 + // recipient subscriptions. For a non-owner host this is a best-effort 2117 + // no-op (the receipt below is still recorded). 2118 + let owner_is_local = manager 2119 + .lookup_handle(&space.space_did) 2120 + .await 2121 + .map_err(XrpcError::from)? 2122 + .is_some(); 2123 + if owner_is_local { 2124 + let is_member = space_service(&state)? 2125 + .is_member(&space, &payload.repo) 2126 + .await 2127 + .map_err(XrpcError::from)?; 2128 + if !is_member { 2129 + return Err(XrpcError::new( 2130 + StatusCode::FORBIDDEN, 2131 + "Forbidden", 2132 + "notifyWrite writer is not a member of the space", 2133 + )); 2134 + } 2135 + // Owner signing key to mint per-recipient service-auth tokens for the 2136 + // outbound fan-out. If it's unavailable we log and skip fan-out (the 2137 + // receipt below is still recorded). 2138 + match local_signing_key(manager, &space.space_did).await { 2139 + Ok(owner_key) => { 2140 + if let Err(e) = crate::space::notify::enqueue_writes( 2141 + manager.pool(), 2142 + manager.data_dir(), 2143 + &space, 2144 + &payload, 2145 + &owner_key, 2146 + ) 2147 + .await 2148 + { 2149 + tracing::warn!( 2150 + error = ?e, 2151 + space = %space, 2152 + repo = %payload.repo, 2153 + "notifyWrite fan-out enqueue failed; recipients may miss this revision" 2154 + ); 2155 + } 2156 + } 2157 + Err(e) => { 2158 + tracing::warn!( 2159 + error = ?e, 2160 + space = %space, 2161 + "notifyWrite fan-out skipped: owner signing key unavailable" 2162 + ); 2163 + } 2164 + } 2165 + } 2166 + 2167 + // Record a lightweight receipt (dedup + audit) pinned to the owner DID. 2168 + crate::space::inbound::receive_write( 1137 2169 &http, 1138 2170 plc_dir, 1139 2171 manager.data_dir(), 1140 - &q.recipient, 2172 + &space.space_did, 1141 2173 body.as_ref(), 1142 2174 ) 1143 2175 .await ··· 1145 2177 Ok(StatusCode::OK) 1146 2178 } 1147 2179 1148 - /// Query params for the inbound notify-* endpoints. 1149 - #[derive(Debug, Deserialize)] 1150 - pub struct NotifyRecipientQuery { 1151 - /// DID of the local account this PDS hosts that should record the 1152 - /// receipt. The notifying peer learned this DID from the 1153 - /// `getSpaceCredential` flow. 1154 - pub recipient: String, 1155 - } 1156 - 1157 2180 // --------------------------------------------------------------------------- 1158 - // Export / import endpoints (§3.4). 2181 + // getBlob — permissioned blob fetch (com.atproto.space.getBlob). 1159 2182 // --------------------------------------------------------------------------- 1160 2183 1161 - /// Query params for `exportSpaces`. 2184 + /// Query params for `com.atproto.space.getBlob`. 1162 2185 #[derive(Debug, Deserialize)] 1163 - pub struct ExportSpacesQuery { 1164 - /// Space URI (`ats://owner/type/key`). 2186 + pub struct GetSpaceBlobQuery { 2187 + /// Space URI. 1165 2188 pub space: String, 1166 - /// DID whose record-set should be exported. When omitted the export 1167 - /// emits a member-list-only manifest (owner-side migration shape). 1168 - pub member: Option<String>, 2189 + /// DID of the account whose repo holds the blob. 2190 + pub repo: String, 2191 + /// CID of the blob to fetch. 2192 + pub cid: String, 1169 2193 } 1170 2194 1171 - /// `GET /xrpc/com.atproto.space.exportSpaces` — stream a CARv1 of the 1172 - /// requested `(member, space)` records + member list. 2195 + /// `GET /xrpc/com.atproto.space.getBlob`. 1173 2196 /// 1174 - /// Auth model: the caller must be the space owner or the named member. 1175 - /// This bounds the export to actors who are authorized to read that 1176 - /// data via the existing `getRecord` / `getMembers` paths. 1177 - pub async fn export_spaces( 2197 + /// Serves the full blob as originally uploaded from `repo`'s regular 2198 + /// blobstore, gated by the same auth as `getRecord` / `listRecords` 2199 + /// (space-credential-space-match OR OAuth/session). Distinct from the public 2200 + /// `com.atproto.sync.getBlob`, which has no permissioned gate. Response carries 2201 + /// the standard atproto blob security headers (`x-content-type-options: 2202 + /// nosniff`, `content-disposition: attachment`, restrictive 2203 + /// `content-security-policy`). 2204 + pub async fn get_blob( 1178 2205 State(state): State<HttpState>, 1179 2206 parts: Parts, 1180 - Query(q): Query<ExportSpacesQuery>, 2207 + Query(q): Query<GetSpaceBlobQuery>, 1181 2208 ) -> Result<axum::response::Response, XrpcError> { 2209 + use axum::body::Body; 2210 + use axum::http::HeaderValue; 1182 2211 use axum::http::header; 1183 - use axum::response::IntoResponse; 1184 2212 1185 - let caller = require_session_subject(&parts, &state).await?; 1186 - let uri = parse_space_uri(&q.space)?; 2213 + let space = parse_space_uri(&q.space)?; 2214 + // `repo` is required by the lexicon; ignore the auth-resolver default by 2215 + // always passing the explicit repo param. 2216 + let resolved = resolve_record_auth(&parts, &state, Some(q.repo.as_str())).await?; 2217 + if let Some(subject) = &resolved.subject { 2218 + assert_space_scope( 2219 + &state, 2220 + subject, 2221 + &space, 2222 + atproto_oauth::scopes::SpaceAction::Read, 2223 + None, 2224 + ) 2225 + .await?; 2226 + } 2227 + space_reader(&state)? 2228 + .verify_read_auth(&space, &resolved.auth) 2229 + .await 2230 + .map_err(XrpcError::from)?; 2231 + 2232 + let manager = account_manager(&state)?; 2233 + let pair = if let Some(backend) = state.public_realm_backend.as_ref() { 2234 + backend 2235 + .blob 2236 + .get(&q.repo, &q.cid) 2237 + .await 2238 + .map_err(XrpcError::from)? 2239 + } else { 2240 + let store = SqlActorStore::open(manager.data_dir(), &q.repo) 2241 + .await 2242 + .map_err(XrpcError::from)?; 2243 + crate::blob::get_blob(&store, &q.cid) 2244 + .await 2245 + .map_err(XrpcError::from)? 2246 + }; 2247 + let (data, mime) = pair.ok_or_else(|| { 2248 + XrpcError::new( 2249 + StatusCode::NOT_FOUND, 2250 + "BlobNotFound", 2251 + format!("no blob {} for {}", q.cid, q.repo), 2252 + ) 2253 + })?; 2254 + 2255 + let mut resp = axum::response::Response::new(Body::from(data)); 2256 + let headers = resp.headers_mut(); 2257 + headers.insert( 2258 + header::CONTENT_TYPE, 2259 + HeaderValue::from_str(&mime) 2260 + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), 2261 + ); 2262 + headers.insert( 2263 + "x-content-type-options", 2264 + HeaderValue::from_static("nosniff"), 2265 + ); 2266 + headers.insert( 2267 + header::CONTENT_DISPOSITION, 2268 + HeaderValue::from_str(&format!("attachment; filename=\"{}\"", q.cid)) 2269 + .unwrap_or_else(|_| HeaderValue::from_static("attachment")), 2270 + ); 2271 + headers.insert( 2272 + header::CONTENT_SECURITY_POLICY, 2273 + HeaderValue::from_static("default-src 'none'; sandbox"), 2274 + ); 2275 + Ok(resp) 2276 + } 2277 + 2278 + // --------------------------------------------------------------------------- 2279 + // listRepos — the writer set (com.atproto.space.listRepos). 2280 + // --------------------------------------------------------------------------- 2281 + 2282 + /// Query params for `com.atproto.space.listRepos`. 2283 + #[derive(Debug, Deserialize)] 2284 + pub struct ListReposQuery { 2285 + /// Space URI. 2286 + pub space: String, 2287 + /// Maximum number of repos to return (1..1000, default 100). 2288 + pub limit: Option<u32>, 2289 + /// Cursor (last `did` from the prior page). 2290 + pub cursor: Option<String>, 2291 + } 2292 + 2293 + /// One repo in `listRepos` — `{ did, rev }` per `com.atproto.space.listRepos#repo`. 2294 + /// 2295 + /// Per the 0016 Permissioned Data draft (line 357), the writer set conveys each 2296 + /// repo together with its current `rev` so a syncer can resume per repo without 2297 + /// a separate probe. 2298 + #[derive(Debug, Serialize)] 2299 + pub struct RepoRef { 2300 + /// DID of a repo that holds data in the space. 2301 + pub did: String, 2302 + /// The repo's current `rev` (the latest observed in this space's 2303 + /// write-receipt log for that issuer). 2304 + pub rev: String, 2305 + } 2306 + 2307 + /// Output of `listRepos`. 2308 + #[derive(Debug, Serialize)] 2309 + pub struct ListReposResponse { 2310 + /// Cursor for the next page, when more may remain. 2311 + #[serde(skip_serializing_if = "Option::is_none")] 2312 + pub cursor: Option<String>, 2313 + /// Page of writer repos. 2314 + pub repos: Vec<RepoRef>, 2315 + } 2316 + 2317 + /// `GET /xrpc/com.atproto.space.listRepos`. 2318 + /// 2319 + /// The writer set: distinct issuer DIDs observed in the owner's inbound 2320 + /// write-receipt log (`space_received_op`), each paired with its current `rev` 2321 + /// (`MAX(rev)` over that issuer's receipts), ordered by DID, paginated by 2322 + /// `did > cursor`. Output is `{ did, rev }` per writer (spec line 357). 2323 + /// `SpaceNotFound` when the space row is absent. 2324 + /// 2325 + /// Auth is **space credential only** (spec XRPC table: `listRepos` is "space 2326 + /// credential"). An OAuth/session bearer is rejected with 401; the presented 2327 + /// credential is verified against the space authority's `#atproto_space` key. 2328 + pub async fn list_repos( 2329 + State(state): State<HttpState>, 2330 + parts: Parts, 2331 + Query(q): Query<ListReposQuery>, 2332 + ) -> Result<Json<ListReposResponse>, XrpcError> { 2333 + let space = parse_space_uri(&q.space)?; 2334 + // listRepos is space-credential-only (spec XRPC table line 483 + 394): an 2335 + // OAuth/session token is rejected; only a verified credential is accepted. 2336 + require_space_credential(&parts, &state, &space).await?; 1187 2337 let manager = account_manager(&state)?; 1188 2338 let _ = space_service(&state)?; // gate on Spaces being enabled 2339 + let limit = q.limit.unwrap_or(100).clamp(1, 1000); 1189 2340 1190 - // Authorize: caller must be owner OR the named member. 1191 - let caller_is_owner = caller == uri.owner_did; 1192 - let caller_is_named_member = q.member.as_deref() == Some(caller.as_str()); 1193 - if !caller_is_owner && !caller_is_named_member { 2341 + let store = SqlActorStore::open(manager.data_dir(), &space.space_did) 2342 + .await 2343 + .map_err(XrpcError::from)?; 2344 + 2345 + // SpaceNotFound when the owner's space row is absent. 2346 + let space_exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM space WHERE uri = ? LIMIT 1") 2347 + .bind(space.to_string()) 2348 + .fetch_optional(store.pool()) 2349 + .await 2350 + .map_err(|e| { 2351 + XrpcError::new( 2352 + StatusCode::INTERNAL_SERVER_ERROR, 2353 + "InternalError", 2354 + format!("listRepos space lookup: {e}"), 2355 + ) 2356 + })?; 2357 + if space_exists.is_none() { 1194 2358 return Err(XrpcError::new( 1195 - StatusCode::FORBIDDEN, 1196 - "Forbidden", 1197 - "exportSpaces requires owner or named-member auth", 2359 + StatusCode::NOT_FOUND, 2360 + "SpaceNotFound", 2361 + format!("space not found: {space}"), 1198 2362 )); 1199 2363 } 1200 2364 1201 - // Buffer the CAR in memory. CARv1 export size for a space scales 1202 - // with record count — the simple buffered shape ships today; a 1203 - // future streaming bridge would use `axum::body::Body::from_stream`. 1204 - let mut car_bytes: Vec<u8> = Vec::new(); 1205 - crate::space::export::export_to_writer( 1206 - manager.data_dir(), 1207 - &uri, 1208 - q.member.as_deref(), 1209 - &mut car_bytes, 2365 + let cursor = q.cursor.clone().unwrap_or_default(); 2366 + let rows: Vec<(String, String)> = sqlx::query_as( 2367 + "SELECT issuer_did, MAX(rev) FROM space_received_op 2368 + WHERE space = ? AND issuer_did > ? 2369 + GROUP BY issuer_did 2370 + ORDER BY issuer_did ASC 2371 + LIMIT ?", 1210 2372 ) 2373 + .bind(space.to_string()) 2374 + .bind(&cursor) 2375 + .bind(limit as i64) 2376 + .fetch_all(store.pool()) 1211 2377 .await 1212 - .map_err(XrpcError::from)?; 2378 + .map_err(|e| { 2379 + XrpcError::new( 2380 + StatusCode::INTERNAL_SERVER_ERROR, 2381 + "InternalError", 2382 + format!("listRepos query: {e}"), 2383 + ) 2384 + })?; 1213 2385 1214 - let mut response = (StatusCode::OK, car_bytes).into_response(); 1215 - response.headers_mut().insert( 1216 - header::CONTENT_TYPE, 1217 - "application/vnd.ipld.car".parse().unwrap(), 1218 - ); 1219 - Ok(response) 2386 + let repos: Vec<RepoRef> = rows 2387 + .into_iter() 2388 + .map(|(did, rev)| RepoRef { did, rev }) 2389 + .collect(); 2390 + let next_cursor = if repos.len() as u32 == limit { 2391 + repos.last().map(|r| r.did.clone()) 2392 + } else { 2393 + None 2394 + }; 2395 + Ok(Json(ListReposResponse { 2396 + cursor: next_cursor, 2397 + repos, 2398 + })) 1220 2399 } 1221 2400 1222 - /// Query params for `importSpaces`. 2401 + // --------------------------------------------------------------------------- 2402 + // registerNotify — subscribe an endpoint to write notifications. 2403 + // --------------------------------------------------------------------------- 2404 + 2405 + /// Inputs for `com.atproto.space.registerNotify`. 1223 2406 #[derive(Debug, Deserialize)] 1224 - pub struct ImportSpacesQuery { 1225 - /// Optional override for the manifest-declared space URI. 1226 - pub space: Option<String>, 1227 - /// Optional override for the manifest-declared member DID. 1228 - pub member: Option<String>, 2407 + pub struct RegisterNotifyInput { 2408 + /// Space URI. 2409 + pub space: String, 2410 + /// DID of a specific repo to subscribe to (repo host). Omit for whole-space. 2411 + #[serde(default)] 2412 + pub repo: Option<String>, 2413 + /// Endpoint to which `notifyWrite` events should be delivered. 2414 + pub endpoint: String, 1229 2415 } 1230 2416 1231 - /// Output of `importSpaces`. 2417 + /// Output of `registerNotify`. 1232 2418 #[derive(Debug, Serialize)] 1233 - pub struct ImportSpacesResponse { 1234 - /// Space URI restored. 1235 - pub space: String, 1236 - /// Imported member DID, when the export carried records for one. 1237 - #[serde(rename = "memberDid", skip_serializing_if = "Option::is_none")] 1238 - pub member_did: Option<String>, 1239 - /// Number of records inserted. 1240 - #[serde(rename = "recordsInserted")] 1241 - pub records_inserted: usize, 1242 - /// Number of members inserted. 1243 - #[serde(rename = "membersInserted")] 1244 - pub members_inserted: usize, 2419 + pub struct RegisterNotifyResponse { 2420 + /// When the registration expires (RFC 3339). 2421 + #[serde(rename = "expiresAt")] 2422 + pub expires_at: String, 1245 2423 } 1246 2424 1247 - /// `POST /xrpc/com.atproto.space.importSpaces` — restore a previously 1248 - /// exported CARv1 onto this PDS. 2425 + /// Registration window for `registerNotify` (24h). 2426 + const REGISTER_NOTIFY_TTL_SECS: i64 = 24 * 60 * 60; 2427 + 2428 + /// `POST /xrpc/com.atproto.space.registerNotify`. 1249 2429 /// 1250 - /// Auth model: the caller must be the destination space owner OR the 1251 - /// destination member DID. Override query params let callers retarget 1252 - /// the import (useful after handle/DID migration); the override DID 1253 - /// must match the auth subject. 1254 - pub async fn import_spaces( 2430 + /// Authenticated with a **space credential** (`typ = space_credential`): the 2431 + /// presented JWT is verified against the space owner's `#atproto` signing key 2432 + /// and must bind to `space`. Persists a subscription keyed 2433 + /// `(space, repo-or-null, service)` with a 24h expiry and returns `expiresAt`. 2434 + pub async fn register_notify( 1255 2435 State(state): State<HttpState>, 1256 2436 parts: Parts, 1257 - Query(q): Query<ImportSpacesQuery>, 1258 - body: axum::body::Bytes, 1259 - ) -> Result<Json<ImportSpacesResponse>, XrpcError> { 1260 - let caller = require_session_subject(&parts, &state).await?; 2437 + Json(input): Json<RegisterNotifyInput>, 2438 + ) -> Result<Json<RegisterNotifyResponse>, XrpcError> { 2439 + let space = parse_space_uri(&input.space)?; 1261 2440 let manager = account_manager(&state)?; 1262 2441 let _ = space_service(&state)?; // gate on Spaces being enabled 1263 2442 1264 - // Resolve target overrides up-front so we can authorize. 1265 - let target_space = match q.space.as_deref() { 1266 - Some(s) => Some(parse_space_uri(s)?), 1267 - None => None, 1268 - }; 1269 - let target_member = q.member.as_deref(); 1270 - 1271 - // Authorize: when targeting a specific member DID, caller must equal 1272 - // that member. When targeting only a space, caller must be its 1273 - // owner. When neither override is provided, defer the auth check 1274 - // until we've decoded the manifest below (we pull the manifest's 1275 - // claimed (space, member) and re-check). 1276 - if let Some(m) = target_member 1277 - && m != caller 1278 - { 2443 + // Require a space-credential bearer. 2444 + let token = bearer_token(&parts)?; 2445 + if classify(token) != Some(SpaceTokenKind::SpaceCredential) { 1279 2446 return Err(XrpcError::new( 1280 - StatusCode::FORBIDDEN, 1281 - "Forbidden", 1282 - "importSpaces member override must match the authenticated subject", 2447 + StatusCode::UNAUTHORIZED, 2448 + "AuthenticationRequired", 2449 + "registerNotify requires a space credential", 1283 2450 )); 1284 2451 } 1285 - if let Some(ref s) = target_space 1286 - && target_member.is_none() 1287 - && s.owner_did != caller 1288 - { 2452 + 2453 + // SpaceNotFound when the owner's space row is absent. 2454 + let owner_store = SqlActorStore::open(manager.data_dir(), &space.space_did) 2455 + .await 2456 + .map_err(XrpcError::from)?; 2457 + let space_exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM space WHERE uri = ? LIMIT 1") 2458 + .bind(space.to_string()) 2459 + .fetch_optional(owner_store.pool()) 2460 + .await 2461 + .map_err(|e| { 2462 + XrpcError::new( 2463 + StatusCode::INTERNAL_SERVER_ERROR, 2464 + "InternalError", 2465 + format!("registerNotify space lookup: {e}"), 2466 + ) 2467 + })?; 2468 + if space_exists.is_none() { 1289 2469 return Err(XrpcError::new( 1290 - StatusCode::FORBIDDEN, 1291 - "Forbidden", 1292 - "importSpaces requires owner auth when no member override is set", 2470 + StatusCode::NOT_FOUND, 2471 + "SpaceNotFound", 2472 + format!("space not found: {space}"), 1293 2473 )); 1294 2474 } 1295 2475 1296 - let outcome = crate::space::export::import_from_reader( 1297 - manager.data_dir(), 1298 - std::io::Cursor::new(body.to_vec()), 1299 - target_space.as_ref(), 1300 - target_member, 2476 + // Verify the space credential: signature over the authority's 2477 + // #atproto_space key, bound to this space, not expired. The authority is 2478 + // local to this host PDS, and per 0016 line 92 #atproto_space coincides 2479 + // with the account's #atproto signing key (resolved via local_public_key). 2480 + let owner_pub = crate::http::space_auth::local_public_key(manager, &space.space_did).await?; 2481 + let credential = atproto_space::credential::verify_space_credential( 2482 + token, 2483 + &space.space_did, 2484 + &space, 2485 + &owner_pub, 2486 + ) 2487 + .map_err(|e| { 2488 + XrpcError::new( 2489 + StatusCode::FORBIDDEN, 2490 + "InvalidToken", 2491 + format!("SpaceCredential verification: {e}"), 2492 + ) 2493 + })?; 2494 + 2495 + // The credential's advisory `client_id` (the attested application) 2496 + // identifies the subscribing service. When the credential carried no 2497 + // attestation we key the subscription on the credential issuer (the space 2498 + // authority) instead, so registration still succeeds. 2499 + let service_did = credential 2500 + .client_id 2501 + .clone() 2502 + .unwrap_or_else(|| credential.iss.clone()); 2503 + let expires_at = 2504 + (chrono::Utc::now() + chrono::Duration::seconds(REGISTER_NOTIFY_TTL_SECS)).to_rfc3339(); 2505 + crate::space::notify::upsert_subscription( 2506 + owner_store.pool(), 2507 + &space, 2508 + input.repo.as_deref(), 2509 + &service_did, 2510 + &input.endpoint, 2511 + Some(&expires_at), 1301 2512 ) 1302 2513 .await 1303 2514 .map_err(XrpcError::from)?; 1304 2515 1305 - // Post-decode authorization: if no overrides were supplied, the 1306 - // manifest's claimed (space, member) must square with the caller. 1307 - if target_space.is_none() && target_member.is_none() { 1308 - let restored_space = SpaceUri::parse(&outcome.space).map_err(PdsError::Space)?; 1309 - let manifest_member = outcome.member_did.as_deref(); 1310 - let caller_is_owner = caller == restored_space.owner_did; 1311 - let caller_is_named_member = manifest_member == Some(caller.as_str()); 1312 - if !caller_is_owner && !caller_is_named_member { 1313 - return Err(XrpcError::new( 1314 - StatusCode::FORBIDDEN, 1315 - "Forbidden", 1316 - "importSpaces caller is neither owner nor the manifest's member", 1317 - )); 2516 + Ok(Json(RegisterNotifyResponse { expires_at })) 2517 + } 2518 + 2519 + // --------------------------------------------------------------------------- 2520 + // notifySpaceDeleted — space-deletion lifecycle notification. 2521 + // --------------------------------------------------------------------------- 2522 + 2523 + /// Inputs for `com.atproto.space.notifySpaceDeleted`. 2524 + #[derive(Debug, Deserialize)] 2525 + pub struct NotifySpaceDeletedInput { 2526 + /// Space URI of the deleted space. 2527 + pub space: String, 2528 + } 2529 + 2530 + /// `POST /xrpc/com.atproto.space.notifySpaceDeleted`. 2531 + /// 2532 + /// Service-auth: the JWT `iss` must equal the space's `spaceDid` (the 2533 + /// authority) and `aud` the recipient (a repo host or syncing service hosted 2534 + /// here). Marks the recipient-side space row as deleted (`deleted_at`). 2535 + /// Best-effort: a no-op when the recipient is not local or the space row is 2536 + /// unknown. 2537 + /// 2538 + /// This PDS acts as a **repo host** here, so it implements the repo-host 2539 + /// behavior of the 0016 draft (line 365): flag the member's repo as belonging 2540 + /// to a deleted space rather than erase it (the data is the user's own). The 2541 + /// **syncer** behavior of line 367 — "delete every copy of the space's data it 2542 + /// holds, both the repos it pulled and any derived state" — is a syncer-role 2543 + /// responsibility and does not apply to the PDS-as-repo-host; this handler 2544 + /// therefore tombstones rather than purges. 2545 + pub async fn notify_space_deleted( 2546 + State(state): State<HttpState>, 2547 + parts: Parts, 2548 + Json(input): Json<NotifySpaceDeletedInput>, 2549 + ) -> Result<StatusCode, XrpcError> { 2550 + let space = parse_space_uri(&input.space)?; 2551 + let manager = account_manager(&state)?; 2552 + let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); 2553 + let http = reqwest::Client::builder() 2554 + .timeout(std::time::Duration::from_secs(10)) 2555 + .user_agent(crate::user_agent()) 2556 + .build() 2557 + .unwrap_or_default(); 2558 + 2559 + // Service-auth verification. `iss` must be the space authority; `aud` is 2560 + // the recipient hosted here. We don't know the recipient ahead of time, so 2561 + // we peek the unverified `aud`, then verify with that expected audience. 2562 + let token = bearer_token(&parts)?; 2563 + let recipient_did = peek_jwt_aud(token).ok_or_else(|| { 2564 + XrpcError::new( 2565 + StatusCode::BAD_REQUEST, 2566 + "InvalidToken", 2567 + "notifySpaceDeleted: missing aud claim", 2568 + ) 2569 + })?; 2570 + let claims = crate::space::service_auth::verify_service_auth( 2571 + &http, 2572 + token, 2573 + plc_dir, 2574 + &recipient_did, 2575 + "com.atproto.space.notifySpaceDeleted", 2576 + ) 2577 + .await 2578 + .map_err(XrpcError::from)?; 2579 + if claims.iss != space.space_did { 2580 + return Err(XrpcError::new( 2581 + StatusCode::UNAUTHORIZED, 2582 + "UntrustedIss", 2583 + "notifySpaceDeleted JWT issuer must be the space DID", 2584 + )); 2585 + } 2586 + 2587 + // aud must be a DID/handle; best-effort no-op otherwise. 2588 + if !recipient_did.starts_with("did:") { 2589 + return Ok(StatusCode::OK); 2590 + } 2591 + // Recipient must be hosted here; otherwise best-effort no-op. 2592 + if manager 2593 + .lookup_handle(&recipient_did) 2594 + .await 2595 + .map_err(XrpcError::from)? 2596 + .is_none() 2597 + { 2598 + return Ok(StatusCode::OK); 2599 + } 2600 + 2601 + // Mark the recipient-side space row deleted; no-op if unknown. 2602 + let store = SqlActorStore::open(manager.data_dir(), &recipient_did) 2603 + .await 2604 + .map_err(XrpcError::from)?; 2605 + let now = chrono::Utc::now().to_rfc3339(); 2606 + sqlx::query("UPDATE space SET deleted_at = ? WHERE uri = ? AND deleted_at IS NULL") 2607 + .bind(&now) 2608 + .bind(space.to_string()) 2609 + .execute(store.pool()) 2610 + .await 2611 + .map_err(|e| { 2612 + XrpcError::new( 2613 + StatusCode::INTERNAL_SERVER_ERROR, 2614 + "InternalError", 2615 + format!("notifySpaceDeleted mark deleted: {e}"), 2616 + ) 2617 + })?; 2618 + Ok(StatusCode::OK) 2619 + } 2620 + 2621 + /// Best-effort extraction of the `aud` claim from a JWT *without* signature 2622 + /// verification — used only to learn the expected audience before the full 2623 + /// service-auth verification. 2624 + fn peek_jwt_aud(token: &str) -> Option<String> { 2625 + let payload_b64 = token.split('.').nth(1)?; 2626 + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD 2627 + .decode(payload_b64.as_bytes()) 2628 + .ok()?; 2629 + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; 2630 + Some(value.get("aud")?.as_str()?.to_string()) 2631 + } 2632 + 2633 + #[cfg(test)] 2634 + mod scope_gate_tests { 2635 + use super::*; 2636 + use crate::account::session::SessionClaims; 2637 + use crate::http::auth::AuthSubject; 2638 + use crate::oauth::token::OAuthClaims; 2639 + use crate::space::declaration::{SpaceDeclaration, StubSpaceDeclarationResolver}; 2640 + use atproto_oauth::scopes::{SpaceAction, SpaceManageVerb}; 2641 + use std::collections::HashMap; 2642 + use std::sync::Arc; 2643 + 2644 + fn space_uri() -> SpaceUri { 2645 + parse_space_uri("ats://did:plc:owner/app.bsky.group/default").unwrap() 2646 + } 2647 + 2648 + /// Minimal `HttpState` with no declaration resolver configured (the 2649 + /// fail-closed default: bare grants confer no write targets). 2650 + async fn test_state() -> HttpState { 2651 + let tmp = tempfile::tempdir().unwrap(); 2652 + let dir = tmp.path().to_path_buf(); 2653 + let accounts = crate::account::AccountDirectory::open_memory() 2654 + .await 2655 + .unwrap(); 2656 + let reader = Arc::new(crate::repo::RepoReader::new(accounts, dir)); 2657 + HttpState::new(reader) 2658 + } 2659 + 2660 + /// `HttpState` whose declaration resolver maps `app.bsky.group` to a 2661 + /// declaration listing `app.bsky.feed.post` as its sole collection. 2662 + async fn test_state_with_declaration() -> HttpState { 2663 + let mut map = HashMap::new(); 2664 + map.insert( 2665 + "app.bsky.group".to_string(), 2666 + SpaceDeclaration { 2667 + name: "Group".to_string(), 2668 + key: "tid".to_string(), 2669 + collections: vec!["app.bsky.feed.post".to_string()], 2670 + }, 2671 + ); 2672 + let resolver = Arc::new(StubSpaceDeclarationResolver::new(map)); 2673 + test_state().await.with_space_declaration_resolver(resolver) 2674 + } 2675 + 2676 + fn oauth_subject(scope: &str) -> AuthSubject { 2677 + AuthSubject::OAuth(OAuthClaims { 2678 + sub: "did:plc:member".to_string(), 2679 + iss: "did:web:pds".to_string(), 2680 + aud: "did:web:pds".to_string(), 2681 + client_id: "https://app.example/cm".to_string(), 2682 + scope: scope.to_string(), 2683 + cnf: None, 2684 + iat: 0, 2685 + exp: u64::MAX, 2686 + jti: "jti".to_string(), 2687 + }) 2688 + } 2689 + 2690 + fn session_subject() -> AuthSubject { 2691 + AuthSubject::AppPassword(SessionClaims { 2692 + sub: "did:plc:member".to_string(), 2693 + iss: "did:web:pds".to_string(), 2694 + apw: "apw".to_string(), 2695 + privileged: true, 2696 + iat: 0, 2697 + exp: u64::MAX, 2698 + jti: "jti".to_string(), 2699 + }) 2700 + } 2701 + 2702 + #[tokio::test] 2703 + async fn oauth_read_scope_matching_space_allows_read() { 2704 + let state = test_state().await; 2705 + let subject = 2706 + oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read"); 2707 + assert!( 2708 + assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) 2709 + .await 2710 + .is_ok() 2711 + ); 2712 + } 2713 + 2714 + #[tokio::test] 2715 + async fn oauth_without_space_scope_denied_403() { 2716 + let state = test_state().await; 2717 + let subject = oauth_subject("atproto"); 2718 + let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) 2719 + .await 2720 + .unwrap_err(); 2721 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2722 + assert_eq!(err.name, "InvalidToken"); 2723 + } 2724 + 2725 + #[tokio::test] 2726 + async fn oauth_manage_scope_does_not_imply_read() { 2727 + // A grant with only `manage` and no record `action` confers no record 2728 + // read (manage and action are orthogonal axes per the 0016 spec). 2729 + let state = test_state().await; 2730 + let subject = oauth_subject( 2731 + "space:app.bsky.group?did=did:plc:owner&skey=default&action=create&manage=update", 2732 + ); 2733 + let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) 2734 + .await 2735 + .unwrap_err(); 2736 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2737 + } 2738 + 2739 + #[test] 2740 + fn oauth_manage_verb_gated_per_verb() { 2741 + // `manage=update` authorizes the update verb but not create/delete. 2742 + let subject = 2743 + oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&manage=update"); 2744 + assert!(assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Update).is_ok()); 2745 + let err = assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Create).unwrap_err(); 2746 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2747 + assert_eq!(err.name, "InvalidToken"); 2748 + } 2749 + 2750 + #[test] 2751 + fn oauth_bare_grant_confers_no_manage() { 2752 + // A bare record-access grant must not authorize any management op. 2753 + let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default"); 2754 + for verb in SpaceManageVerb::ALL { 2755 + let err = assert_space_manage(&subject, &space_uri(), verb).unwrap_err(); 2756 + assert_eq!(err.status, StatusCode::FORBIDDEN); 1318 2757 } 1319 2758 } 1320 2759 1321 - Ok(Json(ImportSpacesResponse { 1322 - space: outcome.space, 1323 - member_did: outcome.member_did, 1324 - records_inserted: outcome.records_inserted, 1325 - members_inserted: outcome.members_inserted, 1326 - })) 2760 + #[tokio::test] 2761 + async fn oauth_read_self_own_repo_collection_constrained() { 2762 + // read_self on the holder's own repo is collection-constrained. 2763 + let state = test_state().await; 2764 + let subject = oauth_subject( 2765 + "space:app.bsky.group?did=did:plc:owner&skey=default&action=read_self&collection=app.bsky.feed.post", 2766 + ); 2767 + // sub() == "did:plc:member"; reading own repo + covered collection. 2768 + assert!( 2769 + assert_space_record_read( 2770 + &state, 2771 + &subject, 2772 + &space_uri(), 2773 + "did:plc:member", 2774 + Some("app.bsky.feed.post"), 2775 + ) 2776 + .await 2777 + .is_ok() 2778 + ); 2779 + // Uncovered collection denied. 2780 + let err = assert_space_record_read( 2781 + &state, 2782 + &subject, 2783 + &space_uri(), 2784 + "did:plc:member", 2785 + Some("app.bsky.feed.like"), 2786 + ) 2787 + .await 2788 + .unwrap_err(); 2789 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2790 + // Reading ANOTHER member's repo requires whole-space read → denied. 2791 + let err = assert_space_record_read( 2792 + &state, 2793 + &subject, 2794 + &space_uri(), 2795 + "did:plc:other", 2796 + Some("app.bsky.feed.post"), 2797 + ) 2798 + .await 2799 + .unwrap_err(); 2800 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2801 + } 2802 + 2803 + #[tokio::test] 2804 + async fn oauth_read_grant_reads_any_repo() { 2805 + // A whole-space `read` grant reads any member's repo, any collection. 2806 + let state = test_state().await; 2807 + let subject = 2808 + oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read"); 2809 + assert!( 2810 + assert_space_record_read( 2811 + &state, 2812 + &subject, 2813 + &space_uri(), 2814 + "did:plc:other", 2815 + Some("any.collection"), 2816 + ) 2817 + .await 2818 + .is_ok() 2819 + ); 2820 + // And cross-collection (collection=None) own-repo listing. 2821 + assert!( 2822 + assert_space_record_read(&state, &subject, &space_uri(), "did:plc:member", None) 2823 + .await 2824 + .is_ok() 2825 + ); 2826 + } 2827 + 2828 + #[tokio::test] 2829 + async fn oauth_wildcard_type_and_did_allows_any_space() { 2830 + let state = test_state().await; 2831 + let subject = oauth_subject("space:*?action=read"); 2832 + assert!( 2833 + assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) 2834 + .await 2835 + .is_ok() 2836 + ); 2837 + } 2838 + 2839 + #[tokio::test] 2840 + async fn oauth_tuple_mismatch_denied() { 2841 + // Scope is for a different owner DID — tuple gate fails. 2842 + let state = test_state().await; 2843 + let subject = 2844 + oauth_subject("space:app.bsky.group?did=did:plc:other&skey=default&action=read"); 2845 + let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) 2846 + .await 2847 + .unwrap_err(); 2848 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2849 + } 2850 + 2851 + #[tokio::test] 2852 + async fn oauth_create_requires_covered_collection() { 2853 + // `create` action but collection list does not cover the target. 2854 + let state = test_state().await; 2855 + let subject = oauth_subject( 2856 + "space:app.bsky.group?did=did:plc:owner&skey=default&collection=app.bsky.feed.post&action=create", 2857 + ); 2858 + // Covered collection → allowed. 2859 + assert!( 2860 + assert_space_scope( 2861 + &state, 2862 + &subject, 2863 + &space_uri(), 2864 + SpaceAction::Create, 2865 + Some("app.bsky.feed.post"), 2866 + ) 2867 + .await 2868 + .is_ok() 2869 + ); 2870 + // Uncovered collection → denied. 2871 + let err = assert_space_scope( 2872 + &state, 2873 + &subject, 2874 + &space_uri(), 2875 + SpaceAction::Create, 2876 + Some("app.bsky.feed.like"), 2877 + ) 2878 + .await 2879 + .unwrap_err(); 2880 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2881 + } 2882 + 2883 + #[tokio::test] 2884 + async fn read_scope_does_not_grant_write() { 2885 + let state = test_state().await; 2886 + let subject = 2887 + oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read"); 2888 + let err = assert_space_scope( 2889 + &state, 2890 + &subject, 2891 + &space_uri(), 2892 + SpaceAction::Create, 2893 + Some("app.bsky.feed.post"), 2894 + ) 2895 + .await 2896 + .unwrap_err(); 2897 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2898 + } 2899 + 2900 + #[test] 2901 + fn app_password_session_skips_scope_gate() { 2902 + // Non-OAuth subjects are authorized at the session layer and skip the 2903 + // `space:` scope check entirely. `assert_space_manage` is sync; the 2904 + // record-scope gate is covered by the async tests above. 2905 + let subject = session_subject(); 2906 + assert!(assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Update).is_ok()); 2907 + } 2908 + 2909 + #[tokio::test] 2910 + async fn app_password_session_skips_record_scope_gate() { 2911 + // Non-OAuth subjects skip the `space:` record-scope check entirely 2912 + // (only OAuth grants carry `space:` scopes per the 0016 spec, lines 2913 + // 369-419). 2914 + let state = test_state().await; 2915 + let subject = session_subject(); 2916 + assert!( 2917 + assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) 2918 + .await 2919 + .is_ok() 2920 + ); 2921 + } 2922 + 2923 + #[tokio::test] 2924 + async fn read_opt_none_skips_gate() { 2925 + // SpaceCredential auth (None subject) always passes the read gate. 2926 + let state = test_state().await; 2927 + assert!( 2928 + assert_space_read_opt(&state, &None, &space_uri()) 2929 + .await 2930 + .is_ok() 2931 + ); 2932 + } 2933 + 2934 + #[tokio::test] 2935 + async fn bare_grant_defaults_to_declared_collections() { 2936 + // A bare `space:app.bsky.group` grant (omits `collection` and `action`) 2937 + // must default its write targets to the declaration's `collections` 2938 + // (spec line 413). With a resolver mapping the type to a declaration 2939 + // listing `app.bsky.feed.post`, a create on that collection is allowed. 2940 + let state = test_state_with_declaration().await; 2941 + let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default"); 2942 + assert!( 2943 + assert_space_scope( 2944 + &state, 2945 + &subject, 2946 + &space_uri(), 2947 + SpaceAction::Create, 2948 + Some("app.bsky.feed.post"), 2949 + ) 2950 + .await 2951 + .is_ok() 2952 + ); 2953 + // A collection NOT in the declaration is not conferred by the default. 2954 + let err = assert_space_scope( 2955 + &state, 2956 + &subject, 2957 + &space_uri(), 2958 + SpaceAction::Create, 2959 + Some("app.bsky.feed.like"), 2960 + ) 2961 + .await 2962 + .unwrap_err(); 2963 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2964 + } 2965 + 2966 + #[tokio::test] 2967 + async fn bare_grant_without_resolver_confers_no_write_targets() { 2968 + // Fail-closed: with no declaration resolver configured, a bare grant's 2969 + // omitted-`collection` default resolves to empty, so no write target is 2970 + // conferred (the pre-F4 behavior, now explicit and documented). 2971 + let state = test_state().await; 2972 + let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default"); 2973 + let err = assert_space_scope( 2974 + &state, 2975 + &subject, 2976 + &space_uri(), 2977 + SpaceAction::Create, 2978 + Some("app.bsky.feed.post"), 2979 + ) 2980 + .await 2981 + .unwrap_err(); 2982 + assert_eq!(err.status, StatusCode::FORBIDDEN); 2983 + } 1327 2984 }
+24 -14
crates/atproto-pds/src/http/state.rs
··· 8 8 use crate::repo::{RepoReader, RepoWriter}; 9 9 use crate::security::{JtiReplayGuard, SlidingWindowLimiter}; 10 10 use crate::sequencer::EventBus; 11 - use crate::space::{SpaceReader, SpaceService, SpaceSync, SpaceWriter}; 11 + use crate::space::{SpaceDeclarationResolver, SpaceReader, SpaceService, SpaceSync, SpaceWriter}; 12 12 use atproto_identity::key::KeyData; 13 13 use atproto_identity::traits::DnsResolver; 14 14 use std::sync::Arc; ··· 43 43 pub space_reader: Option<Arc<SpaceReader>>, 44 44 /// Spaces sync (state + oplog) reader. 45 45 pub space_sync: Option<Arc<SpaceSync>>, 46 + /// Resolver for space-type declarations (NSID → declared `collections`), 47 + /// used to expand a bare `space:` grant's omitted-`collection` default 48 + /// (spec line 413). `None` disables the default (bare grants confer no 49 + /// write targets); typically a TTL-cached network resolver. 50 + pub space_declaration_resolver: Option<Arc<dyn SpaceDeclarationResolver>>, 46 51 /// PLC genesis service (None disables PLC-managed DID creation). 47 52 pub plc_service: Option<Arc<PlcService>>, 48 53 /// JWT-jti replay guard (always populated; in-memory by default). ··· 93 98 /// `/oauth/jwks` so consumers verifying older tokens see them. 94 99 pub pds_extra_signing_keys: Vec<Arc<KeyData>>, 95 100 /// SpaceCredential TTL in seconds. Default 96 - /// `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS` (3h); 101 + /// `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS` (7200 / 2h); 97 102 /// operators tighten/loosen via `PDS_SPACE_CREDENTIAL_TTL_SECONDS`. 98 103 pub space_credential_ttl_secs: u64, 99 104 /// Allowed handle suffix domains. Empty means 100 105 /// any handle is accepted (back-compat). Set via 101 106 /// `PDS_SERVICE_HANDLE_DOMAINS`. 102 107 pub service_handle_domains: Vec<String>, 103 - /// Whether to send a notification email on space membership changes 104 - /// Set via `PDS_NOTIFY_MEMBERSHIP_EMAIL`. 105 - pub notify_membership_email: bool, 106 108 /// Crawler hostnames notified by `requestCrawl`. 107 109 /// Comma-separated `PDS_CRAWLERS`. 108 110 pub crawlers: Vec<String>, ··· 131 133 space_writer: None, 132 134 space_reader: None, 133 135 space_sync: None, 136 + space_declaration_resolver: None, 134 137 plc_service: None, 135 138 jti_guard: JtiReplayGuard::new(100_000), 136 139 rate_limiter: SlidingWindowLimiter::new(300, Duration::from_secs(60), 100_000), ··· 146 149 pds_extra_signing_keys: Vec::new(), 147 150 space_credential_ttl_secs: atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS, 148 151 service_handle_domains: Vec::new(), 149 - notify_membership_email: false, 150 152 crawlers: Vec::new(), 151 153 bsky_app_view_did: None, 152 154 bsky_app_view_url: None, ··· 174 176 space_writer: None, 175 177 space_reader: None, 176 178 space_sync: None, 179 + space_declaration_resolver: None, 177 180 plc_service: None, 178 181 jti_guard: JtiReplayGuard::new(100_000), 179 182 rate_limiter: SlidingWindowLimiter::new(300, Duration::from_secs(60), 100_000), ··· 189 192 pds_extra_signing_keys: Vec::new(), 190 193 space_credential_ttl_secs: atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS, 191 194 service_handle_domains: Vec::new(), 192 - notify_membership_email: false, 193 195 crawlers: Vec::new(), 194 196 bsky_app_view_did: None, 195 197 bsky_app_view_url: None, ··· 268 270 self 269 271 } 270 272 273 + /// Attach a space-type declaration resolver. When set, a bare `space:` 274 + /// grant's omitted-`collection` default expands to the declaration's 275 + /// `collections` (spec line 413). Typically a 276 + /// [`CachingSpaceDeclarationResolver`](crate::space::CachingSpaceDeclarationResolver) 277 + /// wrapping a 278 + /// [`NetworkSpaceDeclarationResolver`](crate::space::NetworkSpaceDeclarationResolver). 279 + #[must_use] 280 + pub fn with_space_declaration_resolver( 281 + mut self, 282 + resolver: Arc<dyn SpaceDeclarationResolver>, 283 + ) -> Self { 284 + self.space_declaration_resolver = Some(resolver); 285 + self 286 + } 287 + 271 288 /// Attach moderation-service forwarding configuration. When both `did` and `url` are set, `createReport` mints a 272 289 /// service-auth token (`aud=did`, `lxm=com.atproto.moderation.createReport`) 273 290 /// and POSTs the report payload to `<url>/xrpc/...`. Without these ··· 330 347 #[must_use] 331 348 pub fn with_service_handle_domains(mut self, domains: Vec<String>) -> Self { 332 349 self.service_handle_domains = domains; 333 - self 334 - } 335 - 336 - /// Enable membership-change email notifications. 337 - #[must_use] 338 - pub fn with_notify_membership_email(mut self, enabled: bool) -> Self { 339 - self.notify_membership_email = enabled; 340 350 self 341 351 } 342 352
+70 -35
crates/atproto-pds/src/notifier.rs
··· 1 - //! Spaces notifier — `notifyWrite` / `notifyMembership` outbound delivery. 1 + //! Spaces notifier — `notifyWrite` outbound delivery. 2 2 //! 3 - //! Spaces 4 - //! writes don't fan out via the public firehose; instead the owner's PDS 5 - //! POSTs the signed `notifyWrite` (records) and `notifyMembership` (member 6 - //! list) payloads to each consumer service that holds a current 7 - //! `SpaceCredential`. The set of recipient services lives in the per-actor 8 - //! `space_credential_recipient` table; delivery attempts (with retry + 9 - //! exponential backoff) live in the shared `notify_attempt` DLQ. 3 + //! Spaces writes don't fan out via the public firehose; instead the owner's 4 + //! PDS POSTs the contentless `notifyWrite` payload to each consumer service 5 + //! that holds a current `SpaceCredential`. The set of recipient services lives 6 + //! in the per-actor `space_credential_recipient` table; delivery attempts 7 + //! (with retry + exponential backoff) live in the shared `notify_attempt` DLQ. 10 8 //! 11 9 //! This module ships: 12 10 //! ··· 56 54 57 55 /// Append a notify-attempt row. 58 56 /// 59 - /// `payload_cbor` is the dag-cbor-encoded request body the consumer will see; 60 - /// `nsid` distinguishes `com.atproto.space.notifyWrite` vs 61 - /// `com.atproto.space.notifyMembership` for the worker's POST. 57 + /// `payload` is the request body the consumer will see (JSON for the 58 + /// contentless notifyWrite); `nsid` selects the worker's POST URL; 59 + /// `content_type` is the request `Content-Type`; `auth_token`, when present, 60 + /// is delivered as a `Bearer` service-auth header. 62 61 pub async fn enqueue_notification( 63 62 pool: &SqlitePool, 64 63 target_service_did: &str, 65 64 target_endpoint: &str, 66 - payload_cbor: Vec<u8>, 65 + payload: Vec<u8>, 67 66 nsid: &str, 67 + content_type: &str, 68 + auth_token: Option<&str>, 68 69 ) -> PdsResult<String> { 69 70 let id = format!("n-{}-{}", Utc::now().timestamp_millis(), random_suffix(8)); 70 71 let now = Utc::now().to_rfc3339(); 71 72 sqlx::query( 72 73 "INSERT INTO notify_attempt 73 74 (id, target_service_did, target_endpoint, payload_cbor, nsid, 74 - attempt_count, next_attempt_at, state) 75 - VALUES (?, ?, ?, ?, ?, 0, ?, 'pending')", 75 + content_type, auth_token, attempt_count, next_attempt_at, state) 76 + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 'pending')", 76 77 ) 77 78 .bind(&id) 78 79 .bind(target_service_did) 79 80 .bind(target_endpoint) 80 - .bind(&payload_cbor) 81 + .bind(&payload) 81 82 .bind(nsid) 83 + .bind(content_type) 84 + .bind(auth_token) 82 85 .bind(&now) 83 86 .execute(pool) 84 87 .await ··· 97 100 pub target_service_did: String, 98 101 /// HTTPS endpoint base of the receiving service. 99 102 pub target_endpoint: String, 100 - /// DAG-CBOR-encoded request body. 103 + /// Request body bytes (DAG-CBOR or JSON depending on `content_type`). 101 104 pub payload_cbor: Vec<u8>, 102 105 /// XRPC NSID — appended to the endpoint to compose the POST URL. 103 106 pub nsid: String, 107 + /// Request `Content-Type` header. 108 + pub content_type: String, 109 + /// Optional `Bearer` service-auth token. 110 + pub auth_token: Option<String>, 104 111 /// How many times we've already tried this row. 105 112 pub attempt_count: u32, 106 113 } 107 114 115 + /// Raw row shape returned by the `due_now` query (id, service_did, endpoint, 116 + /// payload, nsid, content_type, auth_token, attempt_count). 117 + type DueRow = ( 118 + String, 119 + String, 120 + String, 121 + Vec<u8>, 122 + String, 123 + String, 124 + Option<String>, 125 + i64, 126 + ); 127 + 108 128 /// Read all `pending` rows whose `next_attempt_at <= now`. Caller is 109 129 /// responsible for marking each row as delivered/failed/retry after the POST. 110 130 pub async fn due_now(pool: &SqlitePool, limit: u32) -> PdsResult<Vec<DueAttempt>> { 111 131 let now = Utc::now().to_rfc3339(); 112 132 let limit = limit.clamp(1, 1000); 113 - let rows: Vec<(String, String, String, Vec<u8>, String, i64)> = sqlx::query_as( 114 - "SELECT id, target_service_did, target_endpoint, payload_cbor, nsid, attempt_count 115 - FROM notify_attempt 116 - WHERE state = 'pending' AND next_attempt_at <= ? 117 - ORDER BY next_attempt_at ASC 118 - LIMIT ?", 133 + let rows: Vec<DueRow> = sqlx::query_as( 134 + "SELECT id, target_service_did, target_endpoint, payload_cbor, nsid, 135 + content_type, auth_token, attempt_count 136 + FROM notify_attempt 137 + WHERE state = 'pending' AND next_attempt_at <= ? 138 + ORDER BY next_attempt_at ASC 139 + LIMIT ?", 119 140 ) 120 141 .bind(&now) 121 142 .bind(limit as i64) ··· 126 147 })?; 127 148 Ok(rows 128 149 .into_iter() 129 - .map(|(id, did, endpoint, payload, nsid, attempts)| DueAttempt { 130 - id, 131 - target_service_did: did, 132 - target_endpoint: endpoint, 133 - payload_cbor: payload, 134 - nsid, 135 - attempt_count: attempts as u32, 136 - }) 150 + .map( 151 + |(id, did, endpoint, payload, nsid, content_type, auth_token, attempts)| DueAttempt { 152 + id, 153 + target_service_did: did, 154 + target_endpoint: endpoint, 155 + payload_cbor: payload, 156 + nsid, 157 + content_type, 158 + auth_token, 159 + attempt_count: attempts as u32, 160 + }, 161 + ) 137 162 .collect()) 138 163 } 139 164 ··· 250 275 let mut delivered = 0u32; 251 276 for attempt in due { 252 277 let endpoint = format!("{}/xrpc/{}", attempt.target_endpoint, attempt.nsid); 253 - let result = self 278 + let mut req = self 254 279 .client 255 280 .post(&endpoint) 256 - .header(reqwest::header::CONTENT_TYPE, "application/cbor") 257 - .body(attempt.payload_cbor.clone()) 258 - .send() 259 - .await; 281 + .header(reqwest::header::CONTENT_TYPE, attempt.content_type.clone()) 282 + .body(attempt.payload_cbor.clone()); 283 + if let Some(token) = attempt.auth_token.as_deref() { 284 + req = req.bearer_auth(token); 285 + } 286 + let result = req.send().await; 260 287 match result { 261 288 Ok(resp) if resp.status().is_success() => { 262 289 mark_delivered(pool, &attempt.id).await?; ··· 320 347 "https://appview.example", 321 348 b"payload-bytes".to_vec(), 322 349 "com.atproto.space.notifyWrite", 350 + "application/json", 351 + None, 323 352 ) 324 353 .await 325 354 .unwrap(); ··· 339 368 "https://x.example", 340 369 b"data".to_vec(), 341 370 "com.atproto.space.notifyWrite", 371 + "application/json", 372 + None, 342 373 ) 343 374 .await 344 375 .unwrap(); ··· 356 387 "https://x.example", 357 388 b"data".to_vec(), 358 389 "com.atproto.space.notifyWrite", 390 + "application/json", 391 + None, 359 392 ) 360 393 .await 361 394 .unwrap(); ··· 378 411 "https://x.example", 379 412 b"data".to_vec(), 380 413 "com.atproto.space.notifyWrite", 414 + "application/json", 415 + None, 381 416 ) 382 417 .await 383 418 .unwrap();
+444 -42
crates/atproto-pds/src/oauth/consent.rs
··· 7 7 //! endpoint with `approve=true|false`. 8 8 //! 9 9 //! Hand-rolled HTML (no Askama dep) — same approach as the admin dashboard. 10 - //! the page now ships **friendly scope 11 - //! descriptions** so users see "Allow `app.example` to read your 12 - //! `app.bsky.group/default` records" instead of opaque scope strings like 13 - //! `space:read:app.bsky.group/default`. The `describe_scope` helper is 14 - //! the single source of truth — a future Askama refactor (deferred polish) 15 - //! drops it into a template directly. 10 + //! The page ships **friendly scope descriptions** so users see readable text 11 + //! instead of opaque scope strings. Space scopes follow the 0016 spec grammar 12 + //! (`space:<spaceType>[?did&skey&collection&action&manage]`): the space type 13 + //! renders its declaration `name` resolved from its `com.atproto.lexicon.schema` 14 + //! record (NSID fallback; spec line 434), the owner DID renders its 15 + //! bidirectionally-verified handle (DID fallback), and a prominent warning is 16 + //! shown when an app requests access to every space on the network 17 + //! (`type=* && did=*`). The `describe_scope` helper is the single source of 18 + //! truth — a future Askama refactor (deferred polish) drops it into a 19 + //! template directly. 16 20 17 21 use crate::http::errors::XrpcError; 18 22 use crate::http::state::HttpState; 23 + use atproto_oauth::scopes::{Scope, SpaceCollection, SpaceDid, SpacePermission, SpaceType}; 19 24 use axum::extract::{Query, State}; 20 25 use axum::http::StatusCode; 21 26 use axum::response::{Html, IntoResponse, Response}; 22 27 use serde::Deserialize; 28 + use std::collections::BTreeMap; 23 29 24 30 /// Query params accepted by the consent page. 25 31 #[derive(Debug, Deserialize)] ··· 51 57 ) 52 58 })?; 53 59 54 - let html = render_consent(&q.request_uri, &request.client_id, &request.scope); 60 + // Best-effort: resolve the space-owner DIDs named in any `space:` scope to 61 + // their bidirectionally-verified handles, so the consent screen can render 62 + // a human-readable owner instead of an opaque DID. Failures fall back to 63 + // the raw DID. 64 + let handles = resolve_space_owner_handles(&state, &request.scope).await; 65 + 66 + // Best-effort: resolve the space-type NSIDs named in any `space:` scope to 67 + // their declaration `name` (spec line 434). Failures fall back to the NSID. 68 + let type_names = resolve_space_type_names(&state, &request.scope).await; 69 + 70 + let html = render_consent( 71 + &q.request_uri, 72 + &request.client_id, 73 + &request.scope, 74 + &handles, 75 + &type_names, 76 + ); 55 77 Ok(Html(html).into_response()) 56 78 } 57 79 58 - fn render_consent(request_uri: &str, client_id: &str, scope: &str) -> String { 80 + /// Collect the distinct, concrete owner DIDs referenced by `space:` scopes in 81 + /// `scope`, resolve each to a bidirectionally-verified handle, and return the 82 + /// DID→handle map. DIDs that fail verification are simply omitted (callers 83 + /// fall back to the raw DID). 84 + async fn resolve_space_owner_handles(state: &HttpState, scope: &str) -> BTreeMap<String, String> { 85 + let mut dids: Vec<String> = Vec::new(); 86 + for token in scope.split_whitespace() { 87 + if let Ok(Scope::Space(perm)) = Scope::parse(token) 88 + && let SpaceDid::Did(did) = &perm.did 89 + && !dids.contains(did) 90 + { 91 + dids.push(did.clone()); 92 + } 93 + } 94 + 95 + let mut out = BTreeMap::new(); 96 + for did in dids { 97 + if let Some(handle) = verify_bidirectional_handle(state, &did).await { 98 + out.insert(did, handle); 99 + } 100 + } 101 + out 102 + } 103 + 104 + /// Collect the distinct, concrete space-type NSIDs referenced by `space:` 105 + /// scopes in `scope`, resolve each to its declaration `name` (spec line 434), 106 + /// and return the NSID→name map. NSIDs that fail to resolve are omitted 107 + /// (callers fall back to the raw NSID). 108 + async fn resolve_space_type_names(state: &HttpState, scope: &str) -> BTreeMap<String, String> { 109 + let mut nsids: Vec<String> = Vec::new(); 110 + for token in scope.split_whitespace() { 111 + if let Ok(Scope::Space(perm)) = Scope::parse(token) 112 + && let SpaceType::Nsid(nsid) = &perm.space_type 113 + && !nsids.contains(nsid) 114 + { 115 + nsids.push(nsid.clone()); 116 + } 117 + } 118 + 119 + let mut out = BTreeMap::new(); 120 + for nsid in nsids { 121 + if let Some(name) = resolve_space_declaration_name(state, &nsid).await { 122 + out.insert(nsid, name); 123 + } 124 + } 125 + out 126 + } 127 + 128 + /// Resolve a space-type NSID to its declaration `name` for the consent screen 129 + /// (spec lines 126, 434), via the shared 130 + /// [`SpaceDeclarationResolver`](crate::space::SpaceDeclarationResolver) 131 + /// configured on [`HttpState`]. 132 + /// 133 + /// This delegates to the same NSID → `com.atproto.lexicon.schema` resolution 134 + /// path (with shared TTL caching) used by the OAuth `space:` scope gate, so the 135 + /// consent UI and the gate never diverge. Returns `None` when no resolver is 136 + /// configured, resolution fails, or the declaration has an empty `name` 137 + /// (callers fall back to the raw NSID). 138 + async fn resolve_space_declaration_name(state: &HttpState, nsid: &str) -> Option<String> { 139 + let declaration = state 140 + .space_declaration_resolver 141 + .as_ref()? 142 + .resolve(nsid) 143 + .await?; 144 + (!declaration.name.is_empty()).then_some(declaration.name) 145 + } 146 + 147 + /// Resolve `did` to its handle and verify the binding bidirectionally: 148 + /// resolve the DID document, take its first `at://` `alsoKnownAs`, re-resolve 149 + /// that handle, and require the result to equal `did`. Returns the handle on 150 + /// success, `None` on any failure (network, missing handle, mismatch, or no 151 + /// DNS resolver configured). 152 + async fn verify_bidirectional_handle(state: &HttpState, did: &str) -> Option<String> { 153 + let dns_resolver = state.dns_resolver.clone()?; 154 + let plc_hostname = state 155 + .plc_service 156 + .as_ref() 157 + .map(|p| p.directory_hostname().to_string())?; 158 + let http_client = reqwest::Client::builder() 159 + .user_agent(crate::user_agent()) 160 + .timeout(std::time::Duration::from_secs(5)) 161 + .build() 162 + .ok()?; 163 + 164 + let resolver = atproto_identity::resolve::InnerIdentityResolver { 165 + dns_resolver, 166 + http_client: http_client.clone(), 167 + plc_hostname, 168 + }; 169 + 170 + // DID document → first at:// handle. 171 + let document = resolver.resolve(did).await.ok()?; 172 + let handle = document 173 + .also_known_as 174 + .iter() 175 + .find_map(|aka| aka.strip_prefix("at://"))? 176 + .to_string(); 177 + 178 + // Re-resolve the handle and require it to round-trip back to `did`. 179 + let resolved = 180 + atproto_identity::resolve::resolve_handle(&http_client, dns_resolver_ref(state)?, &handle) 181 + .await 182 + .ok()?; 183 + if resolved == did { 184 + Some(handle) 185 + } else { 186 + tracing::debug!( 187 + did, 188 + handle, 189 + resolved, 190 + "consent: bidirectional handle verification mismatch; rendering DID" 191 + ); 192 + None 193 + } 194 + } 195 + 196 + /// Borrow the configured DNS resolver, if any. 197 + fn dns_resolver_ref(state: &HttpState) -> Option<&dyn atproto_identity::traits::DnsResolver> { 198 + state.dns_resolver.as_deref() 199 + } 200 + 201 + fn render_consent( 202 + request_uri: &str, 203 + client_id: &str, 204 + scope: &str, 205 + handles: &BTreeMap<String, String>, 206 + type_names: &BTreeMap<String, String>, 207 + ) -> String { 59 208 let scopes_list: String = scope 60 209 .split_whitespace() 61 210 .map(|s| { 62 211 let raw = html_escape(s); 63 - let description = describe_scope(s); 212 + let description = describe_scope(s, handles, type_names); 64 213 format!( 65 214 "<li><code>{raw}</code><div class=\"scope-desc\">{}</div></li>", 66 215 html_escape(&description), ··· 68 217 }) 69 218 .collect(); 70 219 220 + // Loud warning when any space scope grants access to *every* space on the 221 + // network (`type=* && did=*`) — the prominent-warning requirement of spec 222 + // lines 437-438. 223 + let universal_warning = if has_universal_space_scope(scope) { 224 + r#"<div class="space-warning"><strong>Warning:</strong> this application is requesting access to <b>every space on the network</b>. This is an extremely broad permission. Only grant it to applications you deeply trust.</div>"# 225 + } else { 226 + "" 227 + }; 228 + 71 229 format!( 72 230 r#"<!doctype html> 73 231 <html lang="en"> ··· 93 251 .scopes ul {{ margin: 0.3em 0 0 1.2em; padding: 0; }} 94 252 .scopes li {{ margin-bottom: 0.4em; }} 95 253 .scope-desc {{ color: #555; font-size: 0.85em; margin-left: 0.2em; }} 254 + .space-warning {{ 255 + background: #fff4f4; border: 1px solid #e0b4b4; color: #7a1f1f; 256 + border-radius: 6px; padding: 0.8em 1em; margin: 1em 0; font-size: 0.9em; 257 + }} 96 258 button {{ 97 259 padding: 0.6em 1.2em; border: 0; border-radius: 4px; 98 260 font-size: 1em; cursor: pointer; margin-right: 0.5em; ··· 106 268 <body> 107 269 <h1>Authorize {client_id_safe}</h1> 108 270 <p>Sign in to grant access to your account.</p> 271 + 272 + {universal_warning} 109 273 110 274 <div class="scopes"> 111 275 Requested scopes: ··· 176 340 client_id_safe = html_escape(client_id), 177 341 request_uri_safe = html_escape(request_uri), 178 342 scopes_list = scopes_list, 343 + universal_warning = universal_warning, 179 344 ) 180 345 } 181 346 347 + /// `true` when any `space:` scope in `scope` grants access to every space on 348 + /// the network — i.e. its `type` is `*` **and** its `did` is `*`. This is the 349 + /// broad-grant condition that requires a prominent consent warning (spec lines 350 + /// 437-438). 351 + fn has_universal_space_scope(scope: &str) -> bool { 352 + scope.split_whitespace().any(|token| { 353 + matches!( 354 + Scope::parse(token), 355 + Ok(Scope::Space(perm)) 356 + if perm.space_type == SpaceType::All && perm.did == SpaceDid::All 357 + ) 358 + }) 359 + } 360 + 182 361 /// Convert a raw OAuth scope token into a human-readable description for the 183 362 /// consent page. Recognizes: 184 363 /// 185 364 /// - `atproto` → "core atproto session — sign in to your account" 186 365 /// - `transition:generic` → "all atproto records (legacy transition scope)" 187 366 /// - `transition:chat.bsky` → "Bluesky chat records (legacy transition scope)" 188 - /// - `space:read:<type>/<key>` → "read your Spaces records under <type>/<key>" 189 - /// - `space:write:<type>/<key>` → "create and update your Spaces records under <type>/<key>" 190 - /// - `space:admin:<type>/<key>` → "manage members of your Spaces under <type>/<key>" 367 + /// - `space:<type>[?did&skey&collection&action]` → a description built from the 368 + /// real space-scope grammar via [`describe_space_scope`]. 191 369 /// 192 370 /// Unknown scopes fall back to a generic "request access to scope <s>" 193 371 /// string so the user still sees something readable. 194 - pub fn describe_scope(scope: &str) -> String { 372 + /// 373 + /// `handles` maps space-owner DIDs to their bidirectionally-verified handles; 374 + /// space scopes render the handle when present, the DID otherwise. 375 + /// `type_names` maps space-type NSIDs to their resolved declaration names 376 + /// (spec line 434); absent entries fall back to the raw NSID. 377 + pub fn describe_scope( 378 + scope: &str, 379 + handles: &BTreeMap<String, String>, 380 + type_names: &BTreeMap<String, String>, 381 + ) -> String { 195 382 if scope == "atproto" { 196 383 return "core atproto session — sign in to your account".to_string(); 197 384 } ··· 202 389 other => format!("legacy transition scope `{other}`"), 203 390 }; 204 391 } 205 - if let Some(rest) = scope.strip_prefix("space:") { 206 - // Format: space:<verb>:<type>/<key> e.g. space:read:app.bsky.group/default 207 - let mut parts = rest.splitn(2, ':'); 208 - let verb = parts.next().unwrap_or(""); 209 - let target = parts.next().unwrap_or(""); 210 - let target_friendly = if target.is_empty() { 211 - "all your Spaces" 212 - } else { 213 - target 214 - }; 215 - return match verb { 216 - "read" => format!("read your Spaces records under `{target_friendly}`"), 217 - "write" => format!("create and update your Spaces records under `{target_friendly}`"), 218 - "admin" => format!("manage members of your Spaces under `{target_friendly}`"), 219 - other => format!("`{other}` Spaces access under `{target_friendly}`"), 392 + if scope == "space" || scope.starts_with("space:") || scope.starts_with("space?") { 393 + return match Scope::parse(scope) { 394 + Ok(Scope::Space(perm)) => describe_space_scope(&perm, handles, type_names), 395 + // A `space:` prefix that fails to parse is a malformed scope; show 396 + // it verbatim rather than a misleading description. 397 + _ => format!("malformed Spaces scope `{scope}`"), 220 398 }; 221 399 } 222 400 format!("request access to scope `{scope}`") 223 401 } 224 402 403 + /// Build a human-readable description of a parsed `space:` permission, using 404 + /// the real grammar (`type`, `did`, `skey`, `collection`, `action`). 405 + /// 406 + /// - The space *type* renders its declaration name when resolvable, falling 407 + /// back to the raw NSID (or "any space type" for the `*` wildcard). 408 + /// - The *owner* renders its verified handle (from `handles`) when available, 409 + /// the raw DID otherwise, or "any owner" for `*`. 410 + /// - The *actions* render as a friendly verb list (read / create / update / 411 + /// delete / manage). 412 + pub fn describe_space_scope( 413 + perm: &SpacePermission, 414 + handles: &BTreeMap<String, String>, 415 + type_names: &BTreeMap<String, String>, 416 + ) -> String { 417 + let type_label = match &perm.space_type { 418 + SpaceType::All => "any space type".to_string(), 419 + SpaceType::Nsid(nsid) => space_type_declaration_name(nsid, type_names), 420 + }; 421 + 422 + let owner_label = match &perm.did { 423 + SpaceDid::All => "any owner".to_string(), 424 + SpaceDid::Did(did) => handles 425 + .get(did) 426 + .map(|h| format!("@{h}")) 427 + .unwrap_or_else(|| did.clone()), 428 + }; 429 + 430 + // Record action verbs in canonical order (BTreeSet iteration is sorted by 431 + // the SpaceAction Ord: read_self, read, create, update, delete). 432 + let actions: Vec<&str> = perm.action.iter().map(|a| a.as_str()).collect(); 433 + let actions_label = if actions.is_empty() { 434 + "no".to_string() 435 + } else { 436 + actions.join(", ") 437 + }; 438 + 439 + // Collections constrain write and read_self actions. `Default` defers to the 440 + // declaration's collections (not enumerable here); an explicit empty list 441 + // means no write targets. 442 + let collections_label = match &perm.collection { 443 + atproto_oauth::scopes::SpaceCollections::Default => { 444 + Some("the space type's declared collections".to_string()) 445 + } 446 + atproto_oauth::scopes::SpaceCollections::Explicit(set) if set.is_empty() => None, 447 + atproto_oauth::scopes::SpaceCollections::Explicit(set) => { 448 + let names: Vec<String> = set 449 + .iter() 450 + .map(|c| match c { 451 + SpaceCollection::All => "any collection".to_string(), 452 + SpaceCollection::Nsid(nsid) => nsid.clone(), 453 + }) 454 + .collect(); 455 + Some(names.join(", ")) 456 + } 457 + }; 458 + 459 + let mut out = format!("{actions_label} access to {type_label} spaces owned by {owner_label}"); 460 + match &perm.skey { 461 + atproto_oauth::scopes::SpaceSkey::Key(skey) => { 462 + out.push_str(&format!(" (space key `{skey}`)")); 463 + } 464 + atproto_oauth::scopes::SpaceSkey::All => {} 465 + } 466 + if let Some(cols) = collections_label { 467 + out.push_str(&format!(", collections: {cols}")); 468 + } 469 + // Space-management verbs are a separate axis; surface them prominently. 470 + if !perm.manage.is_empty() { 471 + let verbs: Vec<&str> = perm.manage.iter().map(|m| m.as_str()).collect(); 472 + out.push_str(&format!("; manage the spaces ({})", verbs.join(", "))); 473 + } 474 + out 475 + } 476 + 477 + /// Render a space-type NSID's declaration `name` (spec line 434), falling back 478 + /// to the raw NSID when the declaration could not be resolved. 479 + /// 480 + /// `type_names` is the NSID→declaration-name map produced by 481 + /// [`resolve_space_type_names`]; absent entries fall back to the NSID. 482 + fn space_type_declaration_name(nsid: &str, type_names: &BTreeMap<String, String>) -> String { 483 + type_names 484 + .get(nsid) 485 + .cloned() 486 + .unwrap_or_else(|| nsid.to_string()) 487 + } 488 + 225 489 fn html_escape(s: &str) -> String { 226 490 let mut out = String::with_capacity(s.len()); 227 491 for c in s.chars() { ··· 241 505 mod tests { 242 506 use super::*; 243 507 508 + fn no_handles() -> BTreeMap<String, String> { 509 + BTreeMap::new() 510 + } 511 + 512 + fn no_names() -> BTreeMap<String, String> { 513 + BTreeMap::new() 514 + } 515 + 244 516 #[test] 245 517 fn render_includes_client_id_and_scopes() { 246 518 let html = render_consent( 247 519 "urn:ietf:params:oauth:request_uri:abcd", 248 520 "https://app.example/client-metadata.json", 249 521 "atproto transition:generic", 522 + &no_handles(), 523 + &no_names(), 250 524 ); 251 525 assert!(html.contains("https://app.example/client-metadata.json")); 252 526 assert!(html.contains("atproto")); ··· 258 532 259 533 #[test] 260 534 fn render_escapes_client_id_html() { 261 - let html = render_consent("uri", "https://evil/<script>", "atproto"); 535 + let html = render_consent( 536 + "uri", 537 + "https://evil/<script>", 538 + "atproto", 539 + &no_handles(), 540 + &no_names(), 541 + ); 262 542 // The injected `<script>` substring must show up escaped in the 263 543 // attacker-controlled positions (page title + body header). The page 264 544 // legitimately contains its own `<script>` block for the form submit ··· 272 552 273 553 #[test] 274 554 fn render_handles_empty_scope() { 275 - let html = render_consent("uri", "client", ""); 555 + let html = render_consent("uri", "client", "", &no_handles(), &no_names()); 276 556 assert!(html.contains("Requested scopes")); 277 557 } 278 558 279 559 #[test] 280 560 fn describe_scope_atproto_core() { 281 - let d = describe_scope("atproto"); 561 + let d = describe_scope("atproto", &no_handles(), &no_names()); 282 562 assert!(d.contains("core atproto session")); 283 563 } 284 564 285 565 #[test] 286 566 fn describe_scope_transition_generic() { 287 - let d = describe_scope("transition:generic"); 567 + let d = describe_scope("transition:generic", &no_handles(), &no_names()); 288 568 assert!(d.contains("all atproto records")); 289 569 } 290 570 291 571 #[test] 292 - fn describe_scope_space_read() { 293 - let d = describe_scope("space:read:app.bsky.group/default"); 294 - assert!(d.contains("read your Spaces records")); 295 - assert!(d.contains("app.bsky.group/default")); 572 + fn describe_scope_space_read_only() { 573 + // `action=read` on a concrete type; no did/skey → "any owner". 574 + let d = describe_scope( 575 + "space:app.bsky.group?action=read", 576 + &no_handles(), 577 + &no_names(), 578 + ); 579 + assert!(d.contains("read access to"), "got: {d}"); 580 + assert!(d.contains("app.bsky.group"), "got: {d}"); 581 + assert!(d.contains("any owner"), "got: {d}"); 582 + } 583 + 584 + #[test] 585 + fn describe_scope_space_default_actions() { 586 + // Bare `space:<type>` defaults to {read, create, update, delete} — no 587 + // manage (manage is a separate, none-by-default parameter). 588 + let d = describe_scope("space:app.bsky.group", &no_handles(), &no_names()); 589 + assert!(d.contains("read"), "got: {d}"); 590 + assert!(d.contains("create"), "got: {d}"); 591 + assert!( 592 + !d.contains("manage"), 593 + "default grant confers no manage, got: {d}" 594 + ); 595 + assert!(d.contains("app.bsky.group"), "got: {d}"); 596 + } 597 + 598 + #[test] 599 + fn describe_scope_space_manage_surfaced() { 600 + let d = describe_scope( 601 + "space:app.bsky.group?manage=update&manage=delete", 602 + &no_handles(), 603 + &no_names(), 604 + ); 605 + assert!(d.contains("manage the spaces"), "got: {d}"); 606 + assert!(d.contains("update"), "got: {d}"); 607 + assert!(d.contains("delete"), "got: {d}"); 608 + } 609 + 610 + #[test] 611 + fn describe_scope_space_read_self() { 612 + let d = describe_scope( 613 + "space:app.bsky.group?action=read_self&collection=app.bsky.feed.post", 614 + &no_handles(), 615 + &no_names(), 616 + ); 617 + assert!(d.contains("read_self"), "got: {d}"); 618 + assert!(d.contains("app.bsky.feed.post"), "got: {d}"); 619 + } 620 + 621 + #[test] 622 + fn describe_scope_space_renders_declaration_name() { 623 + // A resolved declaration name replaces the raw NSID (spec line 434). 624 + let mut names = BTreeMap::new(); 625 + names.insert("app.bsky.group".to_string(), "Bluesky Group".to_string()); 626 + let d = describe_scope("space:app.bsky.group?action=read", &no_handles(), &names); 627 + assert!(d.contains("Bluesky Group"), "got: {d}"); 628 + assert!( 629 + !d.contains("app.bsky.group"), 630 + "should prefer name, got: {d}" 631 + ); 632 + } 633 + 634 + #[test] 635 + fn describe_scope_space_renders_handle_when_known() { 636 + let mut handles = BTreeMap::new(); 637 + handles.insert("did:plc:abc".to_string(), "alice.example".to_string()); 638 + let d = describe_scope( 639 + "space:app.bsky.group?did=did:plc:abc&action=read", 640 + &handles, 641 + &no_names(), 642 + ); 643 + assert!(d.contains("@alice.example"), "got: {d}"); 644 + assert!(!d.contains("did:plc:abc"), "should prefer handle, got: {d}"); 645 + } 646 + 647 + #[test] 648 + fn describe_scope_space_falls_back_to_did() { 649 + let d = describe_scope( 650 + "space:app.bsky.group?did=did:plc:xyz&action=read", 651 + &no_handles(), 652 + &no_names(), 653 + ); 654 + assert!(d.contains("did:plc:xyz"), "got: {d}"); 296 655 } 297 656 298 657 #[test] 299 - fn describe_scope_space_write() { 300 - let d = describe_scope("space:write:app.bsky.group/default"); 301 - assert!(d.contains("create and update")); 658 + fn describe_scope_space_skey_and_collection() { 659 + let d = describe_scope( 660 + "space:app.bsky.group?skey=team&collection=app.bsky.feed.post&action=create", 661 + &no_handles(), 662 + &no_names(), 663 + ); 664 + assert!(d.contains("team"), "got: {d}"); 665 + assert!(d.contains("app.bsky.feed.post"), "got: {d}"); 666 + assert!(d.contains("create"), "got: {d}"); 667 + } 668 + 669 + #[test] 670 + fn describe_scope_space_malformed() { 671 + // A `space:` prefix with no positional type is invalid. 672 + let d = describe_scope("space:", &no_handles(), &no_names()); 673 + assert!(d.contains("malformed"), "got: {d}"); 302 674 } 303 675 304 676 #[test] 305 677 fn describe_scope_unknown_falls_back() { 306 - let d = describe_scope("custom:something:weird"); 678 + let d = describe_scope("custom:something:weird", &no_handles(), &no_names()); 307 679 assert!(d.contains("custom:something:weird")); 308 680 } 309 681 310 682 #[test] 683 + fn universal_warning_triggers_on_type_and_did_wildcard() { 684 + // `space:*` defaults did=* skey=* → universal. 685 + assert!(has_universal_space_scope("space:*")); 686 + let html = render_consent("uri", "client", "space:*", &no_handles(), &no_names()); 687 + assert!(html.contains("every space on the network")); 688 + assert!(html.contains("space-warning")); 689 + } 690 + 691 + #[test] 692 + fn universal_warning_absent_when_did_anchored() { 693 + // A concrete did anchors the scope → not universal. 694 + assert!(!has_universal_space_scope("space:*?did=did:plc:abc")); 695 + let html = render_consent( 696 + "uri", 697 + "client", 698 + "space:*?did=did:plc:abc", 699 + &no_handles(), 700 + &no_names(), 701 + ); 702 + assert!(!html.contains("every space on the network")); 703 + } 704 + 705 + #[test] 706 + fn universal_warning_absent_for_concrete_type() { 707 + assert!(!has_universal_space_scope("space:app.bsky.group")); 708 + } 709 + 710 + #[test] 311 711 fn render_includes_friendly_descriptions() { 312 712 let html = render_consent( 313 713 "uri", 314 714 "https://app.example/cm", 315 - "atproto space:read:app.bsky.group/default", 715 + "atproto space:app.bsky.group?action=read", 716 + &no_handles(), 717 + &no_names(), 316 718 ); 317 719 assert!(html.contains("core atproto session")); 318 - assert!(html.contains("read your Spaces records")); 720 + assert!(html.contains("read access to")); 319 721 } 320 722 }
+70 -2
crates/atproto-pds/src/plc.rs
··· 164 164 endpoint: self.config.service_endpoint.clone(), 165 165 }, 166 166 ); 167 + // 0016 §"Space authority" (lines 87-92): the authority DID MUST expose 168 + // a `#atproto_space_host` service. Per line 92 it MAY resolve to the 169 + // same endpoint as `#atproto_pds`; reuse this PDS's endpoint. 170 + services.insert( 171 + "atproto_space_host".to_string(), 172 + ServiceEndpoint { 173 + service_type: "AtprotoPersonalDataServer".to_string(), 174 + endpoint: self.config.service_endpoint.clone(), 175 + }, 176 + ); 167 177 let handle_uri = if handle.starts_with("at://") { 168 178 handle.to_string() 169 179 } else { ··· 178 188 if let Some(extra) = self.config.external_rotation_key.as_ref() { 179 189 builder = builder.add_rotation_key(extra.clone()); 180 190 } 191 + // 0016 §"Space authority" (lines 87-92): the authority DID MUST expose 192 + // a `#atproto_space` verification method carrying the credential-signing 193 + // key. Per line 92 it MAY coincide with `#atproto`; reuse the account's 194 + // atproto signing key. 181 195 let (did, signed_op, _builder_keys) = builder 182 - .add_verification_method("atproto".to_string(), signing_pub) 196 + .add_verification_method("atproto".to_string(), signing_pub.clone()) 197 + .add_verification_method("atproto_space".to_string(), signing_pub) 183 198 .add_also_known_as(handle_uri) 184 199 .add_service("atproto_pds".to_string(), services["atproto_pds"].clone()) 200 + .add_service( 201 + "atproto_space_host".to_string(), 202 + services["atproto_space_host"].clone(), 203 + ) 185 204 .build() 186 205 .map_err(|e| PdsError::PlcRotationKey { 187 206 reason: format!("build PLC op: {e}"), ··· 257 276 format!("at://{handle}") 258 277 }; 259 278 let svc = ServiceEndpoint { 279 + service_type: "AtprotoPersonalDataServer".to_string(), 280 + endpoint: self.config.service_endpoint.clone(), 281 + }; 282 + // 0016 §"Space authority" (lines 87-92): expose `#atproto_space_host` 283 + // alongside `#atproto_pds`, reusing this PDS's endpoint (line 92 MAY). 284 + let space_host_svc = ServiceEndpoint { 260 285 service_type: "AtprotoPersonalDataServer".to_string(), 261 286 endpoint: self.config.service_endpoint.clone(), 262 287 }; 263 288 264 289 let (did, op, _) = DidBuilder::new() 265 290 .add_rotation_key(rotation_priv.clone()) 266 - .add_verification_method("atproto".to_string(), signing_pub) 291 + .add_verification_method("atproto".to_string(), signing_pub.clone()) 292 + .add_verification_method("atproto_space".to_string(), signing_pub) 267 293 .add_also_known_as(handle_uri) 268 294 .add_service("atproto_pds".to_string(), svc) 295 + .add_service("atproto_space_host".to_string(), space_host_svc) 269 296 .build() 270 297 .map_err(|e| PdsError::PlcRotationKey { 271 298 reason: format!("build PLC op: {e}"), ··· 331 358 let services = &json["services"]["atproto_pds"]; 332 359 assert_eq!(services["type"], "AtprotoPersonalDataServer"); 333 360 assert_eq!(services["endpoint"], "https://pds.example.com"); 361 + } 362 + 363 + #[tokio::test(flavor = "multi_thread")] 364 + async fn genesis_dry_run_exposes_atproto_space_verification_method() { 365 + // 0016 §"Space authority" (lines 87-92): the authority DID MUST expose 366 + // an `#atproto_space` verification method. Per line 92 it MAY coincide 367 + // in value with `#atproto`; this PDS reuses the atproto signing key, so 368 + // the two entries carry the same did:key. 369 + let svc = fresh_service(); 370 + let (_did, op, _, _) = svc.genesis_dry_run("alice.example").await.unwrap(); 371 + let json = serde_json::to_value(&op).unwrap(); 372 + let vms = &json["verificationMethods"]; 373 + let atproto = vms["atproto"] 374 + .as_str() 375 + .expect("#atproto verification method present"); 376 + let atproto_space = vms["atproto_space"] 377 + .as_str() 378 + .expect("#atproto_space verification method present"); 379 + assert!(atproto.starts_with("did:key:")); 380 + assert_eq!( 381 + atproto, atproto_space, 382 + "#atproto_space coincides with #atproto per line 92" 383 + ); 384 + } 385 + 386 + #[tokio::test(flavor = "multi_thread")] 387 + async fn genesis_dry_run_exposes_atproto_space_host_service() { 388 + // 0016 §"Space authority" (lines 87-92): the authority DID MUST expose 389 + // an `#atproto_space_host` service. Per line 92 it MAY resolve to the 390 + // same endpoint as `#atproto_pds`. 391 + let svc = fresh_service(); 392 + let (_did, op, _, _) = svc.genesis_dry_run("alice.example").await.unwrap(); 393 + let json = serde_json::to_value(&op).unwrap(); 394 + let host = &json["services"]["atproto_space_host"]; 395 + assert_eq!(host["type"], "AtprotoPersonalDataServer"); 396 + assert_eq!(host["endpoint"], "https://pds.example.com"); 397 + // Coincides with the #atproto_pds endpoint. 398 + assert_eq!( 399 + host["endpoint"], 400 + json["services"]["atproto_pds"]["endpoint"] 401 + ); 334 402 } 335 403 }
+17 -93
crates/atproto-pds/src/realm.rs
··· 1 - //! Realm enum — distinguishes the public repo realm from permissioned-data spaces. 2 - //! 3 - //! The PDS handles two realms: 4 - //! 5 - //! - **Public**: the user's standard atproto repo (MST, signed commits, firehose). 6 - //! - **Space(SpaceUri)**: a permissioned-data space — separate storage, separate 7 - //! commit primitive (SetHash + signed HMAC), separate sync (oplog + setHash), 8 - //! separate notification path (`notifyWrite` push, not firehose). 1 + //! SetHash dispatch for the PDS Spaces subsystem. 9 2 //! 10 - //! Many subsystems (storage, auth, takedown, observability) need to dispatch on 11 - //! realm. This enum is the canonical discriminator. 12 - 13 - use atproto_space::SpaceUri; 14 - 15 - /// Discriminator for which realm a request, write, or read targets. 16 - #[derive(Debug, Clone, PartialEq, Eq, Hash)] 17 - pub enum Realm { 18 - /// The public atproto repo realm. 19 - Public, 20 - /// A permissioned-data space realm, identified by its `ats://` URI. 21 - Space(SpaceUri), 22 - } 23 - 24 - impl Realm { 25 - /// Returns `true` if this is the public realm. 26 - #[must_use] 27 - pub fn is_public(&self) -> bool { 28 - matches!(self, Realm::Public) 29 - } 30 - 31 - /// Returns `Some(SpaceUri)` if this is a space realm. 32 - #[must_use] 33 - pub fn as_space(&self) -> Option<&SpaceUri> { 34 - match self { 35 - Realm::Space(uri) => Some(uri), 36 - Realm::Public => None, 37 - } 38 - } 39 - } 40 - 41 - impl std::fmt::Display for Realm { 42 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 43 - match self { 44 - Realm::Public => f.write_str("public"), 45 - Realm::Space(uri) => write!(f, "{}", uri), 46 - } 47 - } 48 - } 3 + //! Binds the SetHash algorithm used for all permissioned-data Spaces 4 + //! commitments in one place, and exposes its operator-facing name. 49 5 50 6 // --------------------------------------------------------------------------- 51 7 // SetHash dispatch 52 8 // --------------------------------------------------------------------------- 53 9 // 54 10 // The PDS instantiates `SpaceRepo<S, PdsSetHash>` and `SpaceMembers<S, 55 - // PdsSetHash>` everywhere, so swapping the SetHash algorithm is a single 56 - // Cargo-feature decision. Two PDS deployments running different builds 57 - // **cannot interop on Spaces** (digests are not comparable across impls); 58 - // federations must align this flag. 11 + // PdsSetHash>` everywhere, binding the SetHash algorithm in one place. 59 12 60 13 /// The SetHash impl this build of the PDS uses for all Spaces commitments. 61 14 /// 62 - /// Aliases either `XorSha256SetHash` (default — placeholder per the upstream 63 - /// Spaces Design Spec) or `EcmhSetHash` over secp256k1 (when the `ecmh` 64 - /// Cargo feature is enabled — the production target). 65 - #[cfg(not(feature = "ecmh"))] 66 - pub type PdsSetHash = atproto_space::set_hash::XorSha256SetHash; 67 - 68 - /// The SetHash impl this build of the PDS uses for all Spaces commitments 69 - /// (ECMH variant — secp256k1 multiset hash). 70 - #[cfg(feature = "ecmh")] 71 - pub type PdsSetHash = atproto_space::set_hash_ecmh::EcmhSetHash; 72 - 73 - /// Constant identifying the SetHash impl in use. Surfaced in `_health` so 74 - /// operators + federation peers can compare. 75 - #[cfg(not(feature = "ecmh"))] 76 - pub const SET_HASH_NAME: &str = "xor-sha256"; 15 + /// Aliases [`atproto_space::LtHash`] — the **production** primitive per 16 + /// [0016 Permissioned Data] (§ "Commit digest", lines 263-282). 17 + /// 18 + /// `LtHash` persists a 2048-byte lattice **state** (via 19 + /// [`SetHash::state_bytes`](atproto_space::set_hash::SetHash::state_bytes)); 20 + /// its 32-byte commitment (`sha256(state)`) is the `hash` field of a signed 21 + /// [`Commit`](atproto_space::Commit). 22 + /// 23 + /// [0016 Permissioned Data]: https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md 24 + pub type PdsSetHash = atproto_space::set_hash::LtHash; 77 25 78 26 /// Constant identifying the SetHash impl in use. Surfaced in `_health` so 79 27 /// operators + federation peers can compare. 80 - #[cfg(feature = "ecmh")] 81 - pub const SET_HASH_NAME: &str = "ecmh-secp256k1"; 28 + pub const SET_HASH_NAME: &str = "lthash"; 82 29 83 30 #[cfg(test)] 84 31 mod tests { 85 32 use super::*; 86 33 use atproto_space::set_hash::SetHash; 87 - use atproto_space::{SpaceKey, SpaceType}; 88 - 89 - #[test] 90 - fn realm_public_is_public() { 91 - assert!(Realm::Public.is_public()); 92 - assert!(Realm::Public.as_space().is_none()); 93 - } 94 - 95 - #[test] 96 - fn realm_space_is_not_public() { 97 - let space = SpaceUri::new( 98 - "did:plc:owner".to_string(), 99 - SpaceType::new("app.bsky.group").unwrap(), 100 - SpaceKey::new("default").unwrap(), 101 - ); 102 - let realm = Realm::Space(space.clone()); 103 - assert!(!realm.is_public()); 104 - assert_eq!(realm.as_space(), Some(&space)); 105 - } 106 34 107 35 #[test] 108 36 fn pds_set_hash_can_be_constructed() { ··· 113 41 } 114 42 115 43 #[test] 116 - fn set_hash_name_matches_feature() { 117 - if cfg!(feature = "ecmh") { 118 - assert_eq!(SET_HASH_NAME, "ecmh-secp256k1"); 119 - } else { 120 - assert_eq!(SET_HASH_NAME, "xor-sha256"); 121 - } 44 + fn set_hash_name_is_lthash() { 45 + assert_eq!(SET_HASH_NAME, "lthash"); 122 46 } 123 47 }
+2 -2
crates/atproto-pds/src/security.rs
··· 1 1 //! Production-hardening primitives: JTI replay guard, sliding-window 2 2 //! rate limiter, and supporting plumbing. 3 3 //! 4 - //! Every JWT-verify step in OAuth / DPoP / MemberGrant / 5 - //! SpaceCredential / service-auth checks the token's `jti` against a replay 4 + //! Every JWT-verify step in OAuth / DPoP / delegation-token / 5 + //! client-attestation / service-auth checks the token's `jti` against a replay 6 6 //! filter; every authenticated XRPC call passes through a rate limiter. 7 7 //! 8 8 //! Two backends per primitive:
+450
crates/atproto-pds/src/space/config.rs
··· 1 + //! Simplespace configuration model. 2 + //! 3 + //! Persists and surfaces the `com.atproto.simplespace.defs#spaceConfig` 4 + //! shape: a `mintPolicy` (how the authority decides whether to authorize a 5 + //! requesting *user*), an `appAccess` open union (how it decides whether to 6 + //! authorize a requesting *app*), and an optional `managingApp` service 7 + //! identifier. 8 + //! 9 + //! Two serializations are provided: 10 + //! - **Storage form** — compact JSON stored in the `space.app_access` column 11 + //! (`{"type":"open"}` / `{"type":"allowList","allowed":[...]}`), plus the 12 + //! scalar `space.mint_policy` and `space.managing_app` columns. 13 + //! - **Wire form** — the lexicon open-union shape returned by 14 + //! `com.atproto.space.getSpace`, with `$type` discriminators referencing 15 + //! `com.atproto.simplespace.defs#spaceConfig` / `#open` / `#allowList`. 16 + 17 + use crate::actor_store::sql::{SqlActorStore, actor_db_path}; 18 + use crate::errors::PdsError; 19 + use atproto_space::types::SpaceUri; 20 + use serde::{Deserialize, Serialize}; 21 + use sqlx::SqlitePool; 22 + use std::path::Path; 23 + 24 + /// `$type` of the space-config union variant in `getSpace` output. 25 + pub const SPACE_CONFIG_TYPE: &str = "com.atproto.simplespace.defs#spaceConfig"; 26 + /// `$type` of the `#open` app-access union variant. 27 + pub const APP_ACCESS_OPEN_TYPE: &str = "com.atproto.simplespace.defs#open"; 28 + /// `$type` of the `#allowList` app-access union variant. 29 + pub const APP_ACCESS_ALLOW_LIST_TYPE: &str = "com.atproto.simplespace.defs#allowList"; 30 + 31 + /// How the authority decides whether to authorize a requesting user. 32 + /// 33 + /// Serializes to the lexicon `knownValues` strings 34 + /// (`public` | `member-list` | `managing-app`). `member-list` is the default. 35 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] 36 + pub enum MintPolicy { 37 + /// Authorize anyone. 38 + #[serde(rename = "public")] 39 + Public, 40 + /// Consult the member list (default). 41 + #[default] 42 + #[serde(rename = "member-list")] 43 + MemberList, 44 + /// Ask the `managingApp` via `checkUserAccess`. 45 + #[serde(rename = "managing-app")] 46 + ManagingApp, 47 + } 48 + 49 + impl MintPolicy { 50 + /// The `knownValues` string form persisted in the `mint_policy` column. 51 + #[must_use] 52 + pub fn as_str(self) -> &'static str { 53 + match self { 54 + Self::Public => "public", 55 + Self::MemberList => "member-list", 56 + Self::ManagingApp => "managing-app", 57 + } 58 + } 59 + 60 + /// Parse the `knownValues` string form. Unknown values are rejected. 61 + pub fn from_str_value(value: &str) -> Result<Self, PdsError> { 62 + match value { 63 + "public" => Ok(Self::Public), 64 + "member-list" => Ok(Self::MemberList), 65 + "managing-app" => Ok(Self::ManagingApp), 66 + other => Err(PdsError::Storage { 67 + reason: format!("invalid mintPolicy {other}"), 68 + }), 69 + } 70 + } 71 + } 72 + 73 + /// How the authority decides whether to authorize a requesting app. 74 + /// 75 + /// Mirrors the `com.atproto.simplespace.defs` open union of `#open` / 76 + /// `#allowList`. 77 + #[derive(Debug, Clone, PartialEq, Eq, Default)] 78 + pub enum AppAccess { 79 + /// `#open` — any app may access the space. 80 + #[default] 81 + Open, 82 + /// `#allowList` — only the named OAuth client IDs may access the space. 83 + AllowList { 84 + /// Permitted OAuth client IDs. 85 + allowed: Vec<String>, 86 + }, 87 + } 88 + 89 + /// Internal compact storage representation of [`AppAccess`], tagged on a 90 + /// plain `type` field (no `$type`) so the value is self-describing inside 91 + /// the `space.app_access` JSON column. 92 + #[derive(Debug, Serialize, Deserialize)] 93 + #[serde(tag = "type")] 94 + enum AppAccessStored { 95 + #[serde(rename = "open")] 96 + Open, 97 + #[serde(rename = "allowList")] 98 + AllowList { allowed: Vec<String> }, 99 + } 100 + 101 + impl AppAccess { 102 + /// Serialize to the compact JSON storage form for the `app_access` 103 + /// column. 104 + pub fn to_storage_json(&self) -> Result<String, PdsError> { 105 + let stored = match self { 106 + Self::Open => AppAccessStored::Open, 107 + Self::AllowList { allowed } => AppAccessStored::AllowList { 108 + allowed: allowed.clone(), 109 + }, 110 + }; 111 + serde_json::to_string(&stored).map_err(|e| PdsError::Storage { 112 + reason: format!("serialize appAccess: {e}"), 113 + }) 114 + } 115 + 116 + /// Parse the compact JSON storage form from the `app_access` column. 117 + pub fn from_storage_json(json: &str) -> Result<Self, PdsError> { 118 + let stored: AppAccessStored = 119 + serde_json::from_str(json).map_err(|e| PdsError::Storage { 120 + reason: format!("parse appAccess {json}: {e}"), 121 + })?; 122 + Ok(match stored { 123 + AppAccessStored::Open => Self::Open, 124 + AppAccessStored::AllowList { allowed } => Self::AllowList { allowed }, 125 + }) 126 + } 127 + 128 + /// Build from a `getSpace`/`updateSpace` wire-form union value (tagged on 129 + /// `$type` with the `#open` / `#allowList` refs). 130 + pub fn from_wire(value: &serde_json::Value) -> Result<Self, PdsError> { 131 + let ty = value 132 + .get("$type") 133 + .and_then(serde_json::Value::as_str) 134 + .ok_or_else(|| PdsError::Storage { 135 + reason: "appAccess union missing $type".to_string(), 136 + })?; 137 + match ty { 138 + APP_ACCESS_OPEN_TYPE => Ok(Self::Open), 139 + APP_ACCESS_ALLOW_LIST_TYPE => { 140 + let allowed = value 141 + .get("allowed") 142 + .and_then(serde_json::Value::as_array) 143 + .ok_or_else(|| PdsError::Storage { 144 + reason: "appAccess #allowList missing allowed[]".to_string(), 145 + })? 146 + .iter() 147 + .map(|v| { 148 + v.as_str() 149 + .map(str::to_string) 150 + .ok_or_else(|| PdsError::Storage { 151 + reason: "appAccess #allowList allowed[] entry not a string" 152 + .to_string(), 153 + }) 154 + }) 155 + .collect::<Result<Vec<_>, _>>()?; 156 + Ok(Self::AllowList { allowed }) 157 + } 158 + other => Err(PdsError::Storage { 159 + reason: format!("unknown appAccess $type {other}"), 160 + }), 161 + } 162 + } 163 + 164 + /// Render the lexicon wire-form union value (with `$type`). 165 + #[must_use] 166 + pub fn to_wire(&self) -> serde_json::Value { 167 + match self { 168 + Self::Open => serde_json::json!({ "$type": APP_ACCESS_OPEN_TYPE }), 169 + Self::AllowList { allowed } => serde_json::json!({ 170 + "$type": APP_ACCESS_ALLOW_LIST_TYPE, 171 + "allowed": allowed, 172 + }), 173 + } 174 + } 175 + } 176 + 177 + /// In-memory model of a space's simplespace configuration. 178 + #[derive(Debug, Clone, PartialEq, Eq, Default)] 179 + pub struct SpaceConfig { 180 + /// User-authorization policy. 181 + pub mint_policy: MintPolicy, 182 + /// App-authorization policy. 183 + pub app_access: AppAccess, 184 + /// Optional managing-app service identifier. 185 + pub managing_app: Option<String>, 186 + } 187 + 188 + impl SpaceConfig { 189 + /// Reconstruct from the three persisted columns. 190 + pub fn from_columns( 191 + mint_policy: &str, 192 + app_access: &str, 193 + managing_app: Option<String>, 194 + ) -> Result<Self, PdsError> { 195 + Ok(Self { 196 + mint_policy: MintPolicy::from_str_value(mint_policy)?, 197 + app_access: AppAccess::from_storage_json(app_access)?, 198 + managing_app, 199 + }) 200 + } 201 + 202 + /// Parse the `config` ref carried on a `createSpace` input. Missing fields 203 + /// fall back to the host defaults (`member-list` / `#open` / no managing 204 + /// app). The `appAccess` union is in wire form (`$type`-tagged). 205 + pub fn from_create_input(value: &serde_json::Value) -> Result<Self, PdsError> { 206 + let mint_policy = match value.get("mintPolicy").and_then(serde_json::Value::as_str) { 207 + Some(s) => MintPolicy::from_str_value(s)?, 208 + None => MintPolicy::default(), 209 + }; 210 + let app_access = match value.get("appAccess") { 211 + Some(v) if !v.is_null() => AppAccess::from_wire(v)?, 212 + _ => AppAccess::default(), 213 + }; 214 + let managing_app = value 215 + .get("managingApp") 216 + .and_then(serde_json::Value::as_str) 217 + .filter(|s| !s.is_empty()) 218 + .map(str::to_string); 219 + Ok(Self { 220 + mint_policy, 221 + app_access, 222 + managing_app, 223 + }) 224 + } 225 + 226 + /// Render the `getSpace` wire-form `config` union value (with the 227 + /// `#spaceConfig` `$type` discriminator). 228 + #[must_use] 229 + pub fn to_wire(&self) -> serde_json::Value { 230 + let mut obj = serde_json::json!({ 231 + "$type": SPACE_CONFIG_TYPE, 232 + "mintPolicy": self.mint_policy.as_str(), 233 + "appAccess": self.app_access.to_wire(), 234 + }); 235 + if let Some(ref app) = self.managing_app { 236 + obj["managingApp"] = serde_json::Value::String(app.clone()); 237 + } 238 + obj 239 + } 240 + } 241 + 242 + /// A field-level patch applied by `updateSpace`. Each `Option::None` leaves 243 + /// the corresponding column unchanged; `managing_app == Some("")` clears the 244 + /// column to `NULL`. 245 + #[derive(Debug, Default)] 246 + pub struct SpaceConfigPatch { 247 + /// New mint policy, if provided. 248 + pub mint_policy: Option<MintPolicy>, 249 + /// New app-access policy, if provided (replaces wholesale). 250 + pub app_access: Option<AppAccess>, 251 + /// New managing-app identifier. `Some("")` clears to `NULL`; `Some(id)` 252 + /// sets; `None` leaves unchanged. 253 + pub managing_app: Option<String>, 254 + } 255 + 256 + impl SpaceConfigPatch { 257 + /// Parse an `updateSpace` input object into a patch. The `space` field is 258 + /// handled by the caller; only the config fields are read here. 259 + pub fn from_update_input(value: &serde_json::Value) -> Result<Self, PdsError> { 260 + let mint_policy = match value.get("mintPolicy").and_then(serde_json::Value::as_str) { 261 + Some(s) => Some(MintPolicy::from_str_value(s)?), 262 + None => None, 263 + }; 264 + let app_access = match value.get("appAccess") { 265 + Some(v) if !v.is_null() => Some(AppAccess::from_wire(v)?), 266 + _ => None, 267 + }; 268 + let managing_app = value 269 + .get("managingApp") 270 + .and_then(serde_json::Value::as_str) 271 + .map(str::to_string); 272 + Ok(Self { 273 + mint_policy, 274 + app_access, 275 + managing_app, 276 + }) 277 + } 278 + } 279 + 280 + /// Fail with `SpaceNotFound` when the space is tombstoned 281 + /// (`deleted_at IS NOT NULL`) in the given store. A missing row is treated as 282 + /// *not deleted* here so callers that operate on spaces seeded lazily 283 + /// (notify-inbound, cross-PDS reads) keep working; the tombstone gate only 284 + /// fires once a row exists and carries a `deleted_at`. The query is a no-op 285 + /// (returns `Ok`) when the `space` row is absent. 286 + /// 287 + /// # Errors 288 + /// Returns [`PdsError::SpaceNotFound`] when the space is tombstoned, or 289 + /// [`PdsError::Storage`] on a query failure. 290 + pub async fn ensure_not_deleted(pool: &SqlitePool, uri: &SpaceUri) -> Result<(), PdsError> { 291 + let deleted_at: Option<Option<String>> = 292 + sqlx::query_scalar("SELECT deleted_at FROM space WHERE uri = ?") 293 + .bind(uri.to_string()) 294 + .fetch_optional(pool) 295 + .await 296 + .map_err(|e| PdsError::Storage { 297 + reason: format!("ensure_not_deleted: {e}"), 298 + })?; 299 + if matches!(deleted_at, Some(Some(_))) { 300 + return Err(PdsError::SpaceNotFound { 301 + uri: uri.to_string(), 302 + }); 303 + } 304 + Ok(()) 305 + } 306 + 307 + /// Fail with `SpaceNotFound` when the space identified by `space` is 308 + /// tombstoned in the space-authority's per-actor store. 309 + /// 310 + /// The space row (and its `deleted_at` tombstone) is owned by 311 + /// `space.space_did`. This opens that authority's store *only when it already 312 + /// exists locally*; for cross-PDS spaces whose authority lives elsewhere the 313 + /// check is skipped (the remote authority enforces deletion on its side, and 314 + /// `getSpaceCredential` minting is gated separately). 315 + /// 316 + /// # Errors 317 + /// Returns [`PdsError::SpaceNotFound`] when the authority's row is tombstoned, 318 + /// or [`PdsError::Storage`] on a query failure. 319 + pub async fn ensure_space_live(data_dir: &Path, space: &SpaceUri) -> Result<(), PdsError> { 320 + // Skip when the authority's store does not exist locally. 321 + if !actor_db_path(data_dir, &space.space_did).exists() { 322 + return Ok(()); 323 + } 324 + let store = SqlActorStore::open(data_dir, &space.space_did).await?; 325 + ensure_not_deleted(store.pool(), space).await 326 + } 327 + 328 + #[cfg(test)] 329 + mod tests { 330 + use super::*; 331 + 332 + #[test] 333 + fn app_access_storage_round_trip() { 334 + let open = AppAccess::Open; 335 + let json = open.to_storage_json().unwrap(); 336 + assert_eq!(json, r#"{"type":"open"}"#); 337 + assert_eq!(AppAccess::from_storage_json(&json).unwrap(), open); 338 + 339 + let list = AppAccess::AllowList { 340 + allowed: vec!["https://app.example/client".to_string()], 341 + }; 342 + let json = list.to_storage_json().unwrap(); 343 + assert_eq!(AppAccess::from_storage_json(&json).unwrap(), list); 344 + } 345 + 346 + #[test] 347 + fn app_access_default_matches_migration_default() { 348 + assert_eq!( 349 + AppAccess::from_storage_json(r#"{"type":"open"}"#).unwrap(), 350 + AppAccess::Open 351 + ); 352 + } 353 + 354 + #[test] 355 + fn app_access_wire_round_trip() { 356 + let list = AppAccess::AllowList { 357 + allowed: vec!["c1".to_string(), "c2".to_string()], 358 + }; 359 + let wire = list.to_wire(); 360 + assert_eq!(wire["$type"], APP_ACCESS_ALLOW_LIST_TYPE); 361 + assert_eq!(AppAccess::from_wire(&wire).unwrap(), list); 362 + 363 + let open = AppAccess::Open; 364 + assert_eq!(AppAccess::from_wire(&open.to_wire()).unwrap(), open); 365 + } 366 + 367 + #[test] 368 + fn mint_policy_round_trip() { 369 + for p in [ 370 + MintPolicy::Public, 371 + MintPolicy::MemberList, 372 + MintPolicy::ManagingApp, 373 + ] { 374 + assert_eq!(MintPolicy::from_str_value(p.as_str()).unwrap(), p); 375 + } 376 + assert!(MintPolicy::from_str_value("bogus").is_err()); 377 + } 378 + 379 + #[test] 380 + fn config_default_is_member_list_open() { 381 + let cfg = SpaceConfig::default(); 382 + assert_eq!(cfg.mint_policy, MintPolicy::MemberList); 383 + assert_eq!(cfg.app_access, AppAccess::Open); 384 + assert!(cfg.managing_app.is_none()); 385 + } 386 + 387 + #[test] 388 + fn config_to_wire_carries_type() { 389 + let cfg = SpaceConfig { 390 + mint_policy: MintPolicy::Public, 391 + app_access: AppAccess::Open, 392 + managing_app: Some("did:web:example.com#forum".to_string()), 393 + }; 394 + let wire = cfg.to_wire(); 395 + assert_eq!(wire["$type"], SPACE_CONFIG_TYPE); 396 + assert_eq!(wire["mintPolicy"], "public"); 397 + assert_eq!(wire["managingApp"], "did:web:example.com#forum"); 398 + assert_eq!(wire["appAccess"]["$type"], APP_ACCESS_OPEN_TYPE); 399 + } 400 + 401 + #[test] 402 + fn config_from_create_input_defaults() { 403 + let cfg = SpaceConfig::from_create_input(&serde_json::json!({})).unwrap(); 404 + assert_eq!(cfg, SpaceConfig::default()); 405 + } 406 + 407 + #[test] 408 + fn config_from_create_input_full() { 409 + let cfg = SpaceConfig::from_create_input(&serde_json::json!({ 410 + "mintPolicy": "managing-app", 411 + "appAccess": { "$type": APP_ACCESS_ALLOW_LIST_TYPE, "allowed": ["c1"] }, 412 + "managingApp": "did:web:m.example#svc", 413 + })) 414 + .unwrap(); 415 + assert_eq!(cfg.mint_policy, MintPolicy::ManagingApp); 416 + assert_eq!( 417 + cfg.app_access, 418 + AppAccess::AllowList { 419 + allowed: vec!["c1".to_string()] 420 + } 421 + ); 422 + assert_eq!(cfg.managing_app.as_deref(), Some("did:web:m.example#svc")); 423 + } 424 + 425 + #[test] 426 + fn create_input_empty_managing_app_is_none() { 427 + let cfg = 428 + SpaceConfig::from_create_input(&serde_json::json!({ "managingApp": "" })).unwrap(); 429 + assert!(cfg.managing_app.is_none()); 430 + } 431 + 432 + #[test] 433 + fn update_patch_partial() { 434 + let patch = SpaceConfigPatch::from_update_input(&serde_json::json!({ 435 + "space": "ignored", 436 + "mintPolicy": "public", 437 + })) 438 + .unwrap(); 439 + assert_eq!(patch.mint_policy, Some(MintPolicy::Public)); 440 + assert!(patch.app_access.is_none()); 441 + assert!(patch.managing_app.is_none()); 442 + } 443 + 444 + #[test] 445 + fn update_patch_clear_managing_app() { 446 + let patch = 447 + SpaceConfigPatch::from_update_input(&serde_json::json!({ "managingApp": "" })).unwrap(); 448 + assert_eq!(patch.managing_app.as_deref(), Some("")); 449 + } 450 + }
+391
crates/atproto-pds/src/space/declaration.rs
··· 1 + //! Space-type **declaration** resolution — maps a space-type NSID to the 2 + //! `com.atproto.lexicon.schema` record that publishes it and extracts the 3 + //! `defs.main` space declaration (spec lines 100-130, 407-413). 4 + //! 5 + //! A bare `space:<concreteType>` OAuth grant (one that omits `collection`) 6 + //! defaults its write targets to the **declared** `collections` of the space 7 + //! type (spec line 413). To honor that default the PDS must resolve the 8 + //! declaration at request time. The resolution path mirrors AT Protocol 9 + //! lexicon resolution: 10 + //! 11 + //! 1. NSID → `_lexicon.<name>.<reversed-authority>` DNS TXT → authority DID. 12 + //! 2. Authority DID → DID document → `AtprotoPersonalDataServer` endpoint. 13 + //! 3. `com.atproto.repo.getRecord` of `com.atproto.lexicon.schema` keyed by the 14 + //! NSID → parse `defs.main` as a [`SpaceSchema`]. 15 + //! 16 + //! Resolution is **fail-closed**: any failure yields `None`, which the scope 17 + //! gate treats as an empty collection set (a bare grant then confers no write 18 + //! targets). Results are cached with a short TTL via 19 + //! [`CachingSpaceDeclarationResolver`] so a hot path does not re-resolve on 20 + //! every request. 21 + //! 22 + //! ## Operational caveat 23 + //! 24 + //! [`NetworkSpaceDeclarationResolver`] requires a configured DNS resolver and 25 + //! PLC directory hostname (the same prerequisites as handle resolution). When 26 + //! the PDS runs without a DNS resolver, declaration resolution is disabled and 27 + //! bare `space:` grants confer no write targets until the operator configures 28 + //! one. The resolver is expressed as a trait so it can be exercised in unit 29 + //! tests with a stub (see [`StubSpaceDeclarationResolver`]). 30 + 31 + use atproto_lexicon::validation::schema::SchemaDef; 32 + use atproto_lexicon::validation::schema_file::SchemaFile; 33 + use std::collections::HashMap; 34 + use std::sync::Arc; 35 + use std::sync::Mutex; 36 + use std::time::{Duration, Instant}; 37 + 38 + /// A resolved space-type declaration (`defs.main` with `"type": "space"`). 39 + #[derive(Debug, Clone, PartialEq, Eq)] 40 + pub struct SpaceDeclaration { 41 + /// Human-readable name for the space type (consent-screen label). 42 + pub name: String, 43 + /// Recommended space-key (`skey`) type for spaces of this type. 44 + pub key: String, 45 + /// Declared record-collection NSIDs — the default `collection` set for a 46 + /// bare `space:` grant of this type (spec line 413). 47 + pub collections: Vec<String>, 48 + } 49 + 50 + /// Resolves a space-type NSID to its [`SpaceDeclaration`]. 51 + /// 52 + /// Implementations are fail-closed: a `None` result is treated by the scope 53 + /// gate as "no declared collections", so a bare grant confers no write 54 + /// targets. The trait exists so the resolver can be swapped for a stub in 55 + /// tests where full network resolution is infeasible. 56 + #[async_trait::async_trait] 57 + pub trait SpaceDeclarationResolver: Send + Sync { 58 + /// Resolve `nsid` to its declaration, or `None` on any failure. 59 + async fn resolve(&self, nsid: &str) -> Option<SpaceDeclaration>; 60 + } 61 + 62 + /// Network-backed resolver following the AT Protocol lexicon-resolution path. 63 + /// 64 + /// Holds the same dependencies as handle resolution (a DNS resolver, the PLC 65 + /// directory hostname, and an HTTP client). Construct one only when a DNS 66 + /// resolver is available; otherwise leave declaration resolution unconfigured. 67 + pub struct NetworkSpaceDeclarationResolver { 68 + /// DNS resolver for the `_lexicon.` authority TXT lookup. 69 + dns_resolver: Arc<dyn atproto_identity::traits::DnsResolver>, 70 + /// PLC directory hostname for `did:plc` document resolution. 71 + plc_hostname: String, 72 + /// HTTP client for DID-document and record retrieval. 73 + http_client: reqwest::Client, 74 + } 75 + 76 + impl NetworkSpaceDeclarationResolver { 77 + /// Construct a network resolver from its dependencies. 78 + #[must_use] 79 + pub fn new( 80 + dns_resolver: Arc<dyn atproto_identity::traits::DnsResolver>, 81 + plc_hostname: String, 82 + http_client: reqwest::Client, 83 + ) -> Self { 84 + Self { 85 + dns_resolver, 86 + plc_hostname, 87 + http_client, 88 + } 89 + } 90 + } 91 + 92 + #[async_trait::async_trait] 93 + impl SpaceDeclarationResolver for NetworkSpaceDeclarationResolver { 94 + async fn resolve(&self, nsid: &str) -> Option<SpaceDeclaration> { 95 + let dns_name = nsid_to_lexicon_dns_name(nsid)?; 96 + let did = lexicon_authority_did(self.dns_resolver.as_ref(), &dns_name).await?; 97 + 98 + let resolver = atproto_identity::resolve::InnerIdentityResolver { 99 + dns_resolver: self.dns_resolver.clone(), 100 + http_client: self.http_client.clone(), 101 + plc_hostname: self.plc_hostname.clone(), 102 + }; 103 + let document = resolver.resolve(&did).await.ok()?; 104 + let pds = document 105 + .service 106 + .iter() 107 + .find(|s| s.r#type == "AtprotoPersonalDataServer") 108 + .map(|s| s.service_endpoint.clone())?; 109 + 110 + let url = format!( 111 + "{}/xrpc/com.atproto.repo.getRecord", 112 + pds.trim_end_matches('/') 113 + ); 114 + let resp = self 115 + .http_client 116 + .get(&url) 117 + .query(&[ 118 + ("repo", did.as_str()), 119 + ("collection", "com.atproto.lexicon.schema"), 120 + ("rkey", nsid), 121 + ]) 122 + .send() 123 + .await 124 + .ok()?; 125 + if !resp.status().is_success() { 126 + return None; 127 + } 128 + let body: serde_json::Value = resp.json().await.ok()?; 129 + // The lexicon doc lives under `value`; parse it and read `defs.main`. 130 + let value = body.get("value")?; 131 + parse_space_declaration(value) 132 + } 133 + } 134 + 135 + /// Parse a `com.atproto.lexicon.schema` record `value` into a 136 + /// [`SpaceDeclaration`] when its `defs.main` is a `space` declaration. 137 + /// 138 + /// Returns `None` when the value is not a valid lexicon document or its main 139 + /// definition is not a space declaration. 140 + fn parse_space_declaration(value: &serde_json::Value) -> Option<SpaceDeclaration> { 141 + let file = SchemaFile::from_value(value.clone()).ok()?; 142 + match file.main()? { 143 + SchemaDef::Space(space) => Some(SpaceDeclaration { 144 + name: space.name.clone(), 145 + key: space.key.clone(), 146 + collections: space.collections.clone(), 147 + }), 148 + _ => None, 149 + } 150 + } 151 + 152 + /// Convert a lexicon NSID to its DNS authority name 153 + /// (`_lexicon.<name>.<reversed-authority>`), per the AT Protocol lexicon 154 + /// resolution scheme. Returns `None` for NSIDs with fewer than three segments. 155 + fn nsid_to_lexicon_dns_name(nsid: &str) -> Option<String> { 156 + let parts: Vec<&str> = nsid.split('.').collect(); 157 + if parts.len() < 3 { 158 + return None; 159 + } 160 + let name_idx = parts.len() - 2; 161 + let mut dns_parts = vec!["_lexicon".to_string(), parts[name_idx].to_string()]; 162 + for i in (0..name_idx).rev() { 163 + dns_parts.push(parts[i].to_string()); 164 + } 165 + Some(dns_parts.join(".")) 166 + } 167 + 168 + /// Look up the single authority DID published in the `_lexicon.` TXT record(s) 169 + /// for `dns_name`. Returns `None` when no record, no `did=`/`did:` prefix, or 170 + /// more than one distinct DID is found. 171 + async fn lexicon_authority_did( 172 + dns_resolver: &dyn atproto_identity::traits::DnsResolver, 173 + dns_name: &str, 174 + ) -> Option<String> { 175 + let records = dns_resolver.resolve_txt(dns_name).await.ok()?; 176 + let mut dids: Vec<String> = records 177 + .iter() 178 + .filter_map(|r| { 179 + r.strip_prefix("did=") 180 + .map(|d| d.to_string()) 181 + .or_else(|| r.starts_with("did:").then(|| r.to_string())) 182 + }) 183 + .collect(); 184 + dids.dedup(); 185 + if dids.len() == 1 { dids.pop() } else { None } 186 + } 187 + 188 + /// One cache slot: the resolution result (positive or fail-closed `None`) and 189 + /// when it was stored. 190 + #[derive(Clone)] 191 + struct CacheEntry { 192 + stored_at: Instant, 193 + result: Option<SpaceDeclaration>, 194 + } 195 + 196 + /// TTL cache wrapper over any [`SpaceDeclarationResolver`]. 197 + /// 198 + /// Caches both successful resolutions and fail-closed `None` results (negative 199 + /// caching) for `ttl`, so a hot authorization path does not re-resolve — or 200 + /// re-fail — on every request. The cache is unbounded; the space-type NSID 201 + /// keyspace is small and operator-controlled in practice. 202 + pub struct CachingSpaceDeclarationResolver { 203 + inner: Arc<dyn SpaceDeclarationResolver>, 204 + ttl: Duration, 205 + cache: Mutex<HashMap<String, CacheEntry>>, 206 + } 207 + 208 + impl CachingSpaceDeclarationResolver { 209 + /// Wrap `inner` with a TTL cache. 210 + #[must_use] 211 + pub fn new(inner: Arc<dyn SpaceDeclarationResolver>, ttl: Duration) -> Self { 212 + Self { 213 + inner, 214 + ttl, 215 + cache: Mutex::new(HashMap::new()), 216 + } 217 + } 218 + 219 + /// Return a cached, non-expired result for `nsid`, if any. 220 + fn cached(&self, nsid: &str) -> Option<Option<SpaceDeclaration>> { 221 + let guard = self.cache.lock().ok()?; 222 + let entry = guard.get(nsid)?; 223 + if entry.stored_at.elapsed() < self.ttl { 224 + Some(entry.result.clone()) 225 + } else { 226 + None 227 + } 228 + } 229 + 230 + /// Store a resolution result for `nsid`. 231 + fn store(&self, nsid: &str, result: Option<SpaceDeclaration>) { 232 + if let Ok(mut guard) = self.cache.lock() { 233 + guard.insert( 234 + nsid.to_string(), 235 + CacheEntry { 236 + stored_at: Instant::now(), 237 + result, 238 + }, 239 + ); 240 + } 241 + } 242 + } 243 + 244 + #[async_trait::async_trait] 245 + impl SpaceDeclarationResolver for CachingSpaceDeclarationResolver { 246 + async fn resolve(&self, nsid: &str) -> Option<SpaceDeclaration> { 247 + if let Some(hit) = self.cached(nsid) { 248 + return hit; 249 + } 250 + let result = self.inner.resolve(nsid).await; 251 + self.store(nsid, result.clone()); 252 + result 253 + } 254 + } 255 + 256 + /// In-memory stub resolver for tests. Maps NSID → [`SpaceDeclaration`] 257 + /// directly, with no network access. 258 + #[cfg(test)] 259 + pub struct StubSpaceDeclarationResolver { 260 + map: HashMap<String, SpaceDeclaration>, 261 + } 262 + 263 + #[cfg(test)] 264 + impl StubSpaceDeclarationResolver { 265 + /// Build a stub from an NSID → declaration map. 266 + pub fn new(map: HashMap<String, SpaceDeclaration>) -> Self { 267 + Self { map } 268 + } 269 + } 270 + 271 + #[cfg(test)] 272 + #[async_trait::async_trait] 273 + impl SpaceDeclarationResolver for StubSpaceDeclarationResolver { 274 + async fn resolve(&self, nsid: &str) -> Option<SpaceDeclaration> { 275 + self.map.get(nsid).cloned() 276 + } 277 + } 278 + 279 + #[cfg(test)] 280 + mod tests { 281 + use super::*; 282 + use std::sync::atomic::{AtomicUsize, Ordering}; 283 + 284 + #[test] 285 + fn nsid_to_lexicon_dns_name_reverses_authority() { 286 + assert_eq!( 287 + nsid_to_lexicon_dns_name("com.atmoboards.forum").as_deref(), 288 + Some("_lexicon.atmoboards.com") 289 + ); 290 + assert_eq!( 291 + nsid_to_lexicon_dns_name("app.bsky.feed.post").as_deref(), 292 + Some("_lexicon.feed.bsky.app") 293 + ); 294 + assert_eq!(nsid_to_lexicon_dns_name("too.short").as_deref(), None); 295 + } 296 + 297 + #[test] 298 + fn parse_space_declaration_reads_collections() { 299 + let value = serde_json::json!({ 300 + "lexicon": 1, 301 + "id": "com.atmoboards.forum", 302 + "defs": { 303 + "main": { 304 + "type": "space", 305 + "key": "any", 306 + "name": "AtmoBoards Forum", 307 + "collections": ["com.atmoboards.thread", "com.atmoboards.reply"] 308 + } 309 + } 310 + }); 311 + let decl = parse_space_declaration(&value).expect("space declaration"); 312 + assert_eq!(decl.name, "AtmoBoards Forum"); 313 + assert_eq!(decl.key, "any"); 314 + assert_eq!( 315 + decl.collections, 316 + vec![ 317 + "com.atmoboards.thread".to_string(), 318 + "com.atmoboards.reply".to_string() 319 + ] 320 + ); 321 + } 322 + 323 + #[test] 324 + fn parse_space_declaration_rejects_non_space_main() { 325 + let value = serde_json::json!({ 326 + "lexicon": 1, 327 + "id": "com.example.thing", 328 + "defs": { "main": { "type": "record", "key": "tid", "record": {"type": "object"} } } 329 + }); 330 + assert!(parse_space_declaration(&value).is_none()); 331 + } 332 + 333 + /// Counts inner resolutions so the cache's hit/miss behavior is observable. 334 + struct CountingResolver { 335 + calls: AtomicUsize, 336 + decl: SpaceDeclaration, 337 + } 338 + 339 + #[async_trait::async_trait] 340 + impl SpaceDeclarationResolver for CountingResolver { 341 + async fn resolve(&self, _nsid: &str) -> Option<SpaceDeclaration> { 342 + self.calls.fetch_add(1, Ordering::SeqCst); 343 + Some(self.decl.clone()) 344 + } 345 + } 346 + 347 + #[tokio::test] 348 + async fn caching_resolver_serves_within_ttl_and_refetches_after() { 349 + let inner = Arc::new(CountingResolver { 350 + calls: AtomicUsize::new(0), 351 + decl: SpaceDeclaration { 352 + name: "Forum".to_string(), 353 + key: "any".to_string(), 354 + collections: vec!["com.atmoboards.thread".to_string()], 355 + }, 356 + }); 357 + let resolver = 358 + CachingSpaceDeclarationResolver::new(inner.clone(), Duration::from_millis(40)); 359 + 360 + // First call resolves; second is served from cache. 361 + let a = resolver.resolve("com.atmoboards.forum").await; 362 + let b = resolver.resolve("com.atmoboards.forum").await; 363 + assert_eq!(a, b); 364 + assert_eq!(inner.calls.load(Ordering::SeqCst), 1); 365 + 366 + // After the TTL elapses the entry expires and we re-resolve. 367 + tokio::time::sleep(Duration::from_millis(60)).await; 368 + let _ = resolver.resolve("com.atmoboards.forum").await; 369 + assert_eq!(inner.calls.load(Ordering::SeqCst), 2); 370 + } 371 + 372 + #[tokio::test] 373 + async fn stub_resolver_returns_declared_collections() { 374 + let mut map = HashMap::new(); 375 + map.insert( 376 + "com.atmoboards.forum".to_string(), 377 + SpaceDeclaration { 378 + name: "Forum".to_string(), 379 + key: "any".to_string(), 380 + collections: vec![ 381 + "com.atmoboards.thread".to_string(), 382 + "com.atmoboards.reply".to_string(), 383 + ], 384 + }, 385 + ); 386 + let resolver = StubSpaceDeclarationResolver::new(map); 387 + let decl = resolver.resolve("com.atmoboards.forum").await.unwrap(); 388 + assert_eq!(decl.collections.len(), 2); 389 + assert!(resolver.resolve("com.unknown.type").await.is_none()); 390 + } 391 + }
+45 -216
crates/atproto-pds/src/space/inbound.rs
··· 1 - //! Inbound `notifyWrite` / `notifyMembership` receipt + verification. 1 + //! Inbound `notifyWrite` receipt. 2 2 //! 3 - //! When a remote peer pushes a `notifyWrite` or `notifyMembership` POST 4 - //! to this PDS, we: 3 + //! `notifyWrite` is contentless `{ space, repo, rev }` (`application/json`) 4 + //! authenticated by service auth at the HTTP layer; this module simply records 5 + //! a lightweight receipt for dedup + audit. There is no commit to verify in the 6 + //! payload — consumers PULL the actual ops via `listRepoOps`. 5 7 //! 6 - //! 1. Decode the DAG-CBOR body into the payload shape 7 - //! (`crate::space::notify::NotifyWritePayload` / `NotifyMembershipPayload`). 8 - //! 2. Resolve the issuer's atproto signing key from their DID document 9 - //! via `atproto-identity::plc::query` or `atproto-identity::web::query`. 10 - //! 3. Verify the embedded `Commit` (HMAC + ECDSA) using 11 - //! `atproto_space::commit::verify_commit`. 12 - //! 4. INSERT-OR-IGNORE into the local per-actor `space_received_op` 13 - //! table — the row is keyed on `(space, rev, nsid)` so re-delivery of 14 - //! the same op is idempotent. 8 + //! The 0016 Permissioned Data draft has no membership-notification flow, so 9 + //! there is no inbound member-commit verification here. 15 10 //! 16 - //! Verification failure → 401 / 403; replay (already-seen `(space, rev)`) 17 - //! → 200 (idempotent ack). 18 - //! 19 - //! Today the module ships the security-critical verification + audit 20 - //! trail; "apply received op into a local read-only mirror" is a 21 - //! future enhancement that hooks the same dispatch path. 11 + //! Replay (already-seen `(space, rev)`) → 200 (idempotent ack). 22 12 23 13 use crate::actor_store::sql::SqlActorStore; 24 14 use crate::errors::{PdsError, PdsResult}; 25 - use crate::space::notify::{NotifyMembershipPayload, NotifyWritePayload}; 26 - use atproto_identity::key::{KeyData, identify_key}; 27 - use atproto_identity::model::VerificationMethod; 28 - use atproto_space::commit::{CommitScope, SpaceContext, verify_commit}; 15 + use crate::space::notify::NotifyWritePayload; 29 16 use atproto_space::types::SpaceUri; 30 17 31 - /// Decode + verify + persist an inbound `notifyWrite`. Returns Ok(()) on 32 - /// the happy path including the dedup-already-seen path. 18 + /// Decode + persist an inbound contentless `notifyWrite` `{ space, repo, rev }`. 19 + /// 20 + /// The payload carries no commit (the write itself is not replicated through 21 + /// the notification — consumers PULL ops via `listRepoOps`), so there is 22 + /// nothing cryptographic to verify here. Authentication is enforced by the 23 + /// service-auth bearer at the HTTP handler before this is called. The receipt 24 + /// is keyed `(space, rev, nsid)` so re-delivery is idempotent. 33 25 pub async fn receive_write( 34 - http: &reqwest::Client, 35 - plc_directory_hostname: Option<&str>, 26 + _http: &reqwest::Client, 27 + _plc_directory_hostname: Option<&str>, 36 28 data_dir: &std::path::Path, 37 29 recipient_did: &str, 38 30 body: &[u8], 39 31 ) -> PdsResult<()> { 40 32 let payload: NotifyWritePayload = 41 - atproto_dasl::from_slice(body).map_err(|e| PdsError::Storage { 33 + serde_json::from_slice(body).map_err(|e| PdsError::Storage { 42 34 reason: format!("decode notifyWrite payload: {e}"), 43 35 })?; 44 36 let space = SpaceUri::parse(&payload.space).map_err(PdsError::Space)?; 45 - verify_with_owner_key( 46 - http, 47 - plc_directory_hostname, 48 - &space.owner_did, 49 - &SpaceContext { 50 - space_did: space.owner_did.clone(), 51 - space_type: space.space_type.to_string(), 52 - space_key: space.space_key.to_string(), 53 - user_did: payload.member.clone(), 54 - scope: CommitScope::Records, 55 - rev: payload.commit.rev.clone(), 56 - }, 57 - &payload.commit, 58 - ) 59 - .await?; 60 37 persist_receipt( 61 38 data_dir, 62 39 recipient_did, 63 40 &space, 64 - &payload.commit.rev, 41 + &payload.rev, 65 42 "notifyWrite", 66 - &payload.member, 67 - &payload.commit.set_hash, 43 + &payload.repo, 44 + &[], 68 45 ) 69 46 .await 70 47 } 71 48 72 - /// Decode + verify + persist an inbound `notifyMembership`. 73 - pub async fn receive_membership( 74 - http: &reqwest::Client, 75 - plc_directory_hostname: Option<&str>, 76 - data_dir: &std::path::Path, 77 - recipient_did: &str, 78 - body: &[u8], 79 - ) -> PdsResult<()> { 80 - let payload: NotifyMembershipPayload = 81 - atproto_dasl::from_slice(body).map_err(|e| PdsError::Storage { 82 - reason: format!("decode notifyMembership payload: {e}"), 83 - })?; 84 - let space = SpaceUri::parse(&payload.space).map_err(PdsError::Space)?; 85 - verify_with_owner_key( 86 - http, 87 - plc_directory_hostname, 88 - &space.owner_did, 89 - &SpaceContext { 90 - space_did: space.owner_did.clone(), 91 - space_type: space.space_type.to_string(), 92 - space_key: space.space_key.to_string(), 93 - user_did: space.owner_did.clone(), 94 - scope: CommitScope::Members, 95 - rev: payload.commit.rev.clone(), 96 - }, 97 - &payload.commit, 98 - ) 99 - .await?; 100 - persist_receipt( 101 - data_dir, 102 - recipient_did, 103 - &space, 104 - &payload.commit.rev, 105 - "notifyMembership", 106 - &payload.member, 107 - &payload.commit.set_hash, 108 - ) 109 - .await 110 - } 111 - 112 - /// Resolve the owner's atproto signing key from their DID document and 113 - /// run [`verify_commit`] against the supplied context + commit. 114 - async fn verify_with_owner_key( 115 - http: &reqwest::Client, 116 - plc_directory_hostname: Option<&str>, 117 - owner_did: &str, 118 - context: &SpaceContext, 119 - commit: &atproto_space::commit::Commit, 120 - ) -> PdsResult<()> { 121 - let key = atproto_signing_key(http, owner_did, plc_directory_hostname) 122 - .await 123 - .map_err(|e| PdsError::Storage { 124 - reason: format!("resolve owner signing key: {e}"), 125 - })?; 126 - verify_commit(context, commit, &key).map_err(PdsError::Space)?; 127 - Ok(()) 128 - } 129 - 130 - /// Resolve a DID's atproto signing key (the `#atproto` Multikey 131 - /// verification method) via the configured DID method. 132 - async fn atproto_signing_key( 133 - http: &reqwest::Client, 134 - did: &str, 135 - plc_directory_hostname: Option<&str>, 136 - ) -> anyhow::Result<KeyData> { 137 - use atproto_identity::plc::query as plc_query; 138 - use atproto_identity::web::query as web_query; 139 - let document = if did.starts_with("did:plc:") { 140 - let host = plc_directory_hostname.unwrap_or("plc.directory"); 141 - plc_query(http, host, did).await? 142 - } else if did.starts_with("did:web:") { 143 - web_query(http, did).await? 144 - } else { 145 - anyhow::bail!("unsupported DID method for inbound notify verification: {did}"); 146 - }; 147 - for method in &document.verification_method { 148 - if let VerificationMethod::Multikey { 149 - id, 150 - public_key_multibase, 151 - .. 152 - } = method 153 - && id.ends_with("#atproto") 154 - { 155 - let did_key = if public_key_multibase.starts_with("did:key:") { 156 - public_key_multibase.clone() 157 - } else { 158 - format!("did:key:{}", public_key_multibase) 159 - }; 160 - return Ok(identify_key(&did_key)?); 161 - } 162 - } 163 - anyhow::bail!("DID document for {did} has no #atproto Multikey verification method") 164 - } 165 - 166 49 async fn persist_receipt( 167 50 data_dir: &std::path::Path, 168 51 recipient_did: &str, ··· 196 79 #[cfg(test)] 197 80 mod tests { 198 81 use super::*; 199 - use crate::space::notify::NotifyOp; 200 - use atproto_identity::key::{KeyType, generate_key, to_public}; 201 - use atproto_space::commit::create_commit; 202 - use atproto_space::set_hash::SetHash; 203 82 use atproto_space::types::{SpaceKey, SpaceType}; 204 83 205 84 fn test_space() -> SpaceUri { ··· 210 89 ) 211 90 } 212 91 213 - /// Round-trip: build a real signed commit, decode + verify it. 214 - /// Skips the DID-doc fetch by calling `verify_commit` directly. 215 - #[tokio::test(flavor = "multi_thread")] 216 - async fn verify_commit_round_trip() { 217 - let owner_priv = generate_key(KeyType::P256Private).unwrap(); 218 - let owner_pub = to_public(&owner_priv).unwrap(); 219 - let space = test_space(); 220 - let mut sh = atproto_space::set_hash::XorSha256SetHash::empty(); 221 - sh.add(b"hello"); 222 - let context = SpaceContext { 223 - space_did: space.owner_did.clone(), 224 - space_type: space.space_type.to_string(), 225 - space_key: space.space_key.to_string(), 226 - user_did: "did:plc:alice".to_string(), 227 - scope: CommitScope::Records, 228 - rev: "3kmev1".to_string(), 229 - }; 230 - let commit = create_commit(&sh, &context, &owner_priv).unwrap(); 231 - verify_commit(&context, &commit, &owner_pub).unwrap(); 232 - } 233 - 234 - /// A tampered commit body must fail verification. 235 - #[tokio::test(flavor = "multi_thread")] 236 - async fn tampered_commit_rejected() { 237 - let owner_priv = generate_key(KeyType::P256Private).unwrap(); 238 - let owner_pub = to_public(&owner_priv).unwrap(); 239 - let space = test_space(); 240 - let mut sh = atproto_space::set_hash::XorSha256SetHash::empty(); 241 - sh.add(b"orig"); 242 - let context = SpaceContext { 243 - space_did: space.owner_did.clone(), 244 - space_type: space.space_type.to_string(), 245 - space_key: space.space_key.to_string(), 246 - user_did: "did:plc:alice".to_string(), 247 - scope: CommitScope::Records, 248 - rev: "3kmev1".to_string(), 249 - }; 250 - let mut commit = create_commit(&sh, &context, &owner_priv).unwrap(); 251 - // Tamper with the set_hash so the HMAC tag no longer matches. 252 - commit.set_hash[0] ^= 0xFF; 253 - let result = verify_commit(&context, &commit, &owner_pub); 254 - assert!(result.is_err()); 255 - } 256 - 257 92 /// `persist_receipt` is idempotent on `(space, rev, nsid)`. 258 93 #[tokio::test(flavor = "multi_thread")] 259 94 async fn persist_is_idempotent() { ··· 282 117 assert_eq!(count.0, 1); 283 118 } 284 119 285 - /// Serialize a NotifyWritePayload + decode through receive_write's body 286 - /// codec to confirm the wire format round-trips. 120 + /// The contentless `notifyWrite` payload round-trips through JSON and is 121 + /// persisted as a receipt keyed `(space, rev, nsid)`. 287 122 #[tokio::test(flavor = "multi_thread")] 288 - async fn payload_codec_round_trip() { 289 - let owner_priv = generate_key(KeyType::P256Private).unwrap(); 123 + async fn notify_write_receipt_round_trip() { 124 + let tmp = tempfile::TempDir::new().unwrap(); 125 + let dir = tmp.path().to_path_buf(); 290 126 let space = test_space(); 291 - let mut sh = atproto_space::set_hash::XorSha256SetHash::empty(); 292 - sh.add(b"k"); 293 - let context = SpaceContext { 294 - space_did: space.owner_did.clone(), 295 - space_type: space.space_type.to_string(), 296 - space_key: space.space_key.to_string(), 297 - user_did: "did:plc:alice".to_string(), 298 - scope: CommitScope::Records, 299 - rev: "3kmev1".to_string(), 300 - }; 301 - let commit = create_commit(&sh, &context, &owner_priv).unwrap(); 302 127 let payload = NotifyWritePayload { 303 128 space: space.to_string(), 304 - member: "did:plc:alice".to_string(), 305 - commit: commit.clone(), 306 - ops: vec![NotifyOp { 307 - action: "create".to_string(), 308 - collection: "c".to_string(), 309 - rkey: "k".to_string(), 310 - cid: Some("bafy...".to_string()), 311 - value: Some(b"v".to_vec()), 312 - }], 129 + repo: "did:plc:alice".to_string(), 130 + rev: "3kmev1".to_string(), 313 131 }; 314 - let body = atproto_dasl::to_vec(&payload).unwrap(); 315 - let decoded: NotifyWritePayload = atproto_dasl::from_slice(&body).unwrap(); 316 - assert_eq!(decoded.space, payload.space); 317 - assert_eq!(decoded.member, payload.member); 318 - assert_eq!(decoded.commit.rev, commit.rev); 132 + let body = serde_json::to_vec(&payload).unwrap(); 133 + let http = reqwest::Client::new(); 134 + receive_write(&http, None, &dir, "did:plc:peer", &body) 135 + .await 136 + .unwrap(); 137 + 138 + let store = SqlActorStore::open(&dir, "did:plc:peer").await.unwrap(); 139 + let row: (String, String, String) = 140 + sqlx::query_as("SELECT rev, nsid, issuer_did FROM space_received_op WHERE space = ?") 141 + .bind(space.to_string()) 142 + .fetch_one(store.pool()) 143 + .await 144 + .unwrap(); 145 + assert_eq!(row.0, "3kmev1"); 146 + assert_eq!(row.1, "notifyWrite"); 147 + assert_eq!(row.2, "did:plc:alice"); 319 148 } 320 149 }
+643
crates/atproto-pds/src/space/mint_authz.rs
··· 1 + //! Mint-time authorization for `com.atproto.space.getSpaceCredential`. 2 + //! 3 + //! A space credential is minted only when **both** axes of the space's 4 + //! `com.atproto.simplespace.defs#spaceConfig` authorize the request: 5 + //! 6 + //! - **USER axis** ([`MintPolicy`]): `public` authorizes anyone; `member-list` 7 + //! (the default) requires the requesting member DID to be in the space's 8 + //! member list; `managing-app` delegates the decision to the configured 9 + //! `managingApp` via [`com.atproto.simplespace.checkUserAccess`]. 10 + //! - **APP axis** ([`AppAccess`]): `#open` authorizes any app; `#allowList` 11 + //! requires the *attested* OAuth `client_id` to be in the `allowed` list. 12 + //! 13 + //! The attested `client_id` is established by verifying the optional 14 + //! `clientAttestation` JWT presented on the request: its `iss`/`sub` is the 15 + //! client-metadata URL, which is fetched, its JWKS resolved, and the key 16 + //! selected by `kid` to verify the JWT signature. When `appAccess` is 17 + //! `#allowList` and no valid attestation is presented the mint is refused with 18 + //! [`MintDenial::AppNotAuthorized`]; under `#open` an attestation is optional. 19 + //! 20 + //! The pure decision helpers ([`user_axis_local`] and [`app_axis`]) carry the 21 + //! decision matrix and are unit-tested directly; the async helpers 22 + //! ([`verify_client_attestation`], [`check_user_access`]) perform the network 23 + //! I/O for the managing-app and attestation paths. 24 + 25 + use crate::space::config::{AppAccess, MintPolicy}; 26 + use atproto_identity::key::{KeyData, validate as identity_validate}; 27 + use atproto_oauth::jwk::WrappedJsonWebKeySet; 28 + use atproto_space::types::SpaceUri; 29 + use base64::Engine as _; 30 + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; 31 + use serde::Deserialize; 32 + use std::time::{SystemTime, UNIX_EPOCH}; 33 + 34 + /// Why a credential mint was refused. Each variant maps to one of the 35 + /// documented `getSpaceCredential` lexicon errors. 36 + #[derive(Debug, Clone, PartialEq, Eq)] 37 + pub enum MintDenial { 38 + /// `UserNotAuthorized` — refused on the basis of the requesting user 39 + /// (`member-list` miss, or `managing-app` returned `authorized=false`). 40 + UserNotAuthorized { 41 + /// Human-readable reason for diagnostics. 42 + reason: String, 43 + }, 44 + /// `AppNotAuthorized` — refused on the basis of the requesting app 45 + /// (`#allowList` miss, or no/invalid attestation under `#allowList`). 46 + AppNotAuthorized { 47 + /// Human-readable reason for diagnostics. 48 + reason: String, 49 + }, 50 + /// `InvalidClientAttestation` — a `clientAttestation` was presented but 51 + /// could not be verified. 52 + InvalidClientAttestation { 53 + /// Human-readable reason for diagnostics. 54 + reason: String, 55 + }, 56 + /// `NotAuthorized` — refused for a reason not attributable to a single 57 + /// axis (e.g. the managing-app could not be reached). 58 + NotAuthorized { 59 + /// Human-readable reason for diagnostics. 60 + reason: String, 61 + }, 62 + } 63 + 64 + impl MintDenial { 65 + /// The lexicon error name to surface to clients. 66 + #[must_use] 67 + pub fn error_name(&self) -> &'static str { 68 + match self { 69 + Self::UserNotAuthorized { .. } => "UserNotAuthorized", 70 + Self::AppNotAuthorized { .. } => "AppNotAuthorized", 71 + Self::InvalidClientAttestation { .. } => "InvalidClientAttestation", 72 + Self::NotAuthorized { .. } => "NotAuthorized", 73 + } 74 + } 75 + 76 + /// The diagnostic reason carried alongside the error name. 77 + #[must_use] 78 + pub fn reason(&self) -> &str { 79 + match self { 80 + Self::UserNotAuthorized { reason } 81 + | Self::AppNotAuthorized { reason } 82 + | Self::InvalidClientAttestation { reason } 83 + | Self::NotAuthorized { reason } => reason, 84 + } 85 + } 86 + } 87 + 88 + /// USER-axis decision for the locally-evaluable policies (`public` and 89 + /// `member-list`). The `managing-app` policy is *not* decided here — it 90 + /// requires a network call ([`check_user_access`]) and is signalled by an 91 + /// `Ok(None)` return so the caller knows to consult the managing app. 92 + /// 93 + /// - `public` → authorized (returns `Ok(Some(()))`). 94 + /// - `member-list` → authorized iff `is_member`, else 95 + /// [`MintDenial::UserNotAuthorized`]. 96 + /// - `managing-app` → `Ok(None)` (defer to [`check_user_access`]). 97 + /// 98 + /// # Errors 99 + /// Returns [`MintDenial::UserNotAuthorized`] when a `member-list` space does 100 + /// not contain the requesting member. 101 + pub fn user_axis_local(policy: MintPolicy, is_member: bool) -> Result<Option<()>, MintDenial> { 102 + match policy { 103 + MintPolicy::Public => Ok(Some(())), 104 + MintPolicy::MemberList => { 105 + if is_member { 106 + Ok(Some(())) 107 + } else { 108 + Err(MintDenial::UserNotAuthorized { 109 + reason: "requesting member is not in the space member list".to_string(), 110 + }) 111 + } 112 + } 113 + MintPolicy::ManagingApp => Ok(None), 114 + } 115 + } 116 + 117 + /// APP-axis decision. `attested_client_id` is the *verified* client_id from a 118 + /// validated client attestation, or `None` when no valid attestation was 119 + /// presented. 120 + /// 121 + /// - `#open` → authorized regardless of attestation. 122 + /// - `#allowList` → authorized iff a verified `client_id` was presented and is 123 + /// in `allowed`; otherwise [`MintDenial::AppNotAuthorized`]. 124 + /// 125 + /// # Errors 126 + /// Returns [`MintDenial::AppNotAuthorized`] when an `#allowList` space is not 127 + /// satisfied by the attested client_id. 128 + pub fn app_axis(access: &AppAccess, attested_client_id: Option<&str>) -> Result<(), MintDenial> { 129 + match access { 130 + AppAccess::Open => Ok(()), 131 + AppAccess::AllowList { allowed } => match attested_client_id { 132 + Some(client_id) if allowed.iter().any(|c| c == client_id) => Ok(()), 133 + Some(client_id) => Err(MintDenial::AppNotAuthorized { 134 + reason: format!("attested client_id {client_id} is not on the allow list"), 135 + }), 136 + None => Err(MintDenial::AppNotAuthorized { 137 + reason: "space gates on app identity (#allowList) but no valid client \ 138 + attestation was presented" 139 + .to_string(), 140 + }), 141 + }, 142 + } 143 + } 144 + 145 + /// `typ` header value a client attestation MUST carry (spec line 184). 146 + pub const TYP_CLIENT_ATTESTATION: &str = "atproto-client-attestation+jwt"; 147 + 148 + /// Header of a client-attestation JWT (spec lines 183-187). We read `typ` 149 + /// (to reject token-type confusion) and `kid` (to select the verifying key 150 + /// from the resolved JWKS). 151 + #[derive(Debug, Deserialize)] 152 + struct AttestationHeader { 153 + /// Token type — MUST be `atproto-client-attestation+jwt`. 154 + #[serde(default)] 155 + typ: Option<String>, 156 + /// Key id used to select the JWK from the client's JWKS. 157 + kid: Option<String>, 158 + } 159 + 160 + /// Payload of a client-attestation JWT (spec lines 188-195). Per the spec, 161 + /// `iss == sub == client_id` (the client-metadata URL) and `aud` identifies 162 + /// the space host. `iat`/`exp`/`jti` provide freshness and replay protection. 163 + #[derive(Debug, Deserialize)] 164 + struct AttestationPayload { 165 + /// Issuer — the OAuth client_id (a client-metadata URL). 166 + iss: String, 167 + /// Subject — must equal `iss`. 168 + sub: String, 169 + /// Audience — the space host identifier 170 + /// (`<spaceDid>#atproto_space_host`). 171 + #[serde(default)] 172 + aud: Option<String>, 173 + /// Issued-at (epoch seconds). 174 + #[serde(default)] 175 + iat: Option<u64>, 176 + /// Expiry (epoch seconds). 177 + #[serde(default)] 178 + exp: Option<u64>, 179 + /// Random nonce for replay protection. 180 + #[serde(default)] 181 + jti: Option<String>, 182 + } 183 + 184 + /// Minimal view of an OAuth client-metadata document — we only need the 185 + /// inline `jwks` or the `jwks_uri` to locate verifying keys. 186 + #[derive(Deserialize)] 187 + struct ClientMetadata { 188 + #[serde(default)] 189 + jwks: Option<WrappedJsonWebKeySet>, 190 + #[serde(default)] 191 + jwks_uri: Option<String>, 192 + } 193 + 194 + /// The audience a client attestation (and delegation token) must target: the 195 + /// space host service fragment derived from the space DID 196 + /// (`<spaceDid>#atproto_space_host`, spec line 166/191). 197 + #[must_use] 198 + pub fn space_host_audience(space_did: &str) -> String { 199 + atproto_space::credential::space_host_audience(space_did) 200 + } 201 + 202 + /// Maximum accepted lifetime of a client attestation. The spec calls the 203 + /// attestation "short-lived" (example `exp = iat + 60`); we reject any 204 + /// attestation whose stated lifetime exceeds five minutes to bound the replay 205 + /// window even before the `jti` guard records it. 206 + const MAX_ATTESTATION_LIFETIME_SECS: u64 = 300; 207 + 208 + fn now_secs() -> u64 { 209 + SystemTime::now() 210 + .duration_since(UNIX_EPOCH) 211 + .unwrap_or_default() 212 + .as_secs() 213 + } 214 + 215 + /// Verify a client-attestation compact JWT and return the verified 216 + /// `client_id` (its `iss`/`sub`). 217 + /// 218 + /// Checks the header `typ` is `atproto-client-attestation+jwt`, that 219 + /// `iss == sub == client_id` (an HTTPS client-metadata URL), that `aud` 220 + /// targets this space host, that `iat`/`exp` are present and the attestation 221 + /// is unexpired and not over-long-lived, and that `jti` is present and unseen 222 + /// (consumed via `jti_guard` for replay protection). It then resolves the 223 + /// issuer's client-metadata document → JWKS → key by `kid` and verifies the 224 + /// JWS signature. 225 + /// 226 + /// # Errors 227 + /// Returns [`MintDenial::InvalidClientAttestation`] for any malformed, 228 + /// unresolvable, mis-targeted, replayed, or signature-invalid attestation. 229 + pub async fn verify_client_attestation( 230 + http: &reqwest::Client, 231 + jti_guard: &crate::security::JtiReplayGuard, 232 + attestation: &str, 233 + space: &SpaceUri, 234 + ) -> Result<String, MintDenial> { 235 + let invalid = |reason: String| MintDenial::InvalidClientAttestation { reason }; 236 + 237 + let parts: Vec<&str> = attestation.split('.').collect(); 238 + if parts.len() != 3 { 239 + return Err(invalid("attestation is not a compact JWS".to_string())); 240 + } 241 + let header_bytes = B64URL 242 + .decode(parts[0].as_bytes()) 243 + .map_err(|e| invalid(format!("header not base64url: {e}")))?; 244 + let header: AttestationHeader = serde_json::from_slice(&header_bytes) 245 + .map_err(|e| invalid(format!("header not JSON: {e}")))?; 246 + let payload_bytes = B64URL 247 + .decode(parts[1].as_bytes()) 248 + .map_err(|e| invalid(format!("payload not base64url: {e}")))?; 249 + let payload: AttestationPayload = serde_json::from_slice(&payload_bytes) 250 + .map_err(|e| invalid(format!("payload not JSON: {e}")))?; 251 + 252 + // typ header MUST be the client-attestation token type. 253 + match header.typ.as_deref() { 254 + Some(TYP_CLIENT_ATTESTATION) => {} 255 + Some(other) => { 256 + return Err(invalid(format!( 257 + "header typ {other} is not {TYP_CLIENT_ATTESTATION}" 258 + ))); 259 + } 260 + None => return Err(invalid("attestation header missing typ".to_string())), 261 + } 262 + 263 + // iss == sub == client_id, both an HTTPS client-metadata URL. 264 + if payload.iss != payload.sub { 265 + return Err(invalid("iss != sub".to_string())); 266 + } 267 + let client_id = payload.iss.clone(); 268 + if !client_id.starts_with("https://") { 269 + return Err(invalid(format!( 270 + "client_id is not an https client-metadata URL: {client_id}" 271 + ))); 272 + } 273 + 274 + // aud must target this space host. 275 + let expected_aud = space_host_audience(&space.space_did); 276 + match payload.aud.as_deref() { 277 + Some(aud) if aud == expected_aud => {} 278 + Some(aud) => { 279 + return Err(invalid(format!( 280 + "aud {aud} does not target space host {expected_aud}" 281 + ))); 282 + } 283 + None => return Err(invalid("attestation missing aud".to_string())), 284 + } 285 + 286 + // iat and exp are required; the attestation must be unexpired and bounded 287 + // to a short lifetime (spec lines 192-193). 288 + let now = now_secs(); 289 + let iat = payload 290 + .iat 291 + .ok_or_else(|| invalid("attestation missing iat".to_string()))?; 292 + let exp = payload 293 + .exp 294 + .ok_or_else(|| invalid("attestation missing exp".to_string()))?; 295 + if exp <= now { 296 + return Err(invalid("attestation expired".to_string())); 297 + } 298 + if exp <= iat || exp - iat > MAX_ATTESTATION_LIFETIME_SECS { 299 + return Err(invalid(format!( 300 + "attestation lifetime exceeds {MAX_ATTESTATION_LIFETIME_SECS}s" 301 + ))); 302 + } 303 + 304 + // jti is required and single-use (spec line 194). Consume it before the 305 + // signature is checked so a replayed nonce is rejected regardless. 306 + let jti = payload 307 + .jti 308 + .as_deref() 309 + .ok_or_else(|| invalid("attestation missing jti".to_string()))?; 310 + let ttl = std::time::Duration::from_secs(exp.saturating_sub(now)); 311 + jti_guard 312 + .check_and_insert(jti, ttl) 313 + .await 314 + .map_err(|_| invalid("attestation jti already used (replay)".to_string()))?; 315 + 316 + // Resolve the client-metadata document. 317 + let metadata: ClientMetadata = http 318 + .get(&client_id) 319 + .header("Accept", "application/json") 320 + .send() 321 + .await 322 + .map_err(|e| invalid(format!("fetch client-metadata {client_id}: {e}")))? 323 + .error_for_status() 324 + .map_err(|e| invalid(format!("client-metadata {client_id} status: {e}")))? 325 + .json() 326 + .await 327 + .map_err(|e| invalid(format!("parse client-metadata {client_id}: {e}")))?; 328 + 329 + // Resolve the JWKS (inline `jwks` wins; else fetch `jwks_uri`). 330 + let jwks: WrappedJsonWebKeySet = match (metadata.jwks, metadata.jwks_uri) { 331 + (Some(jwks), _) => jwks, 332 + (None, Some(uri)) => http 333 + .get(&uri) 334 + .header("Accept", "application/json") 335 + .send() 336 + .await 337 + .map_err(|e| invalid(format!("fetch jwks_uri {uri}: {e}")))? 338 + .error_for_status() 339 + .map_err(|e| invalid(format!("jwks_uri {uri} status: {e}")))? 340 + .json() 341 + .await 342 + .map_err(|e| invalid(format!("parse jwks_uri {uri}: {e}")))?, 343 + (None, None) => { 344 + return Err(invalid( 345 + "client-metadata has neither jwks nor jwks_uri".to_string(), 346 + )); 347 + } 348 + }; 349 + 350 + // Select the verifying key by kid (or the sole key when no kid is given). 351 + let wrapped = match header.kid.as_deref() { 352 + Some(kid) => jwks 353 + .keys 354 + .iter() 355 + .find(|k| k.kid.as_deref() == Some(kid)) 356 + .ok_or_else(|| invalid(format!("no JWK with kid {kid}")))?, 357 + None if jwks.keys.len() == 1 => &jwks.keys[0], 358 + None => { 359 + return Err(invalid( 360 + "attestation header has no kid and JWKS has multiple keys".to_string(), 361 + )); 362 + } 363 + }; 364 + let verifying_key: KeyData = wrapped 365 + .try_into() 366 + .map_err(|e| invalid(format!("JWK is not a usable verifying key: {e}")))?; 367 + 368 + // Verify the JWS signature over `header.payload`. 369 + let signing_input = format!("{}.{}", parts[0], parts[1]); 370 + let sig = B64URL 371 + .decode(parts[2].as_bytes()) 372 + .map_err(|e| invalid(format!("signature not base64url: {e}")))?; 373 + identity_validate(&verifying_key, &sig, signing_input.as_bytes()) 374 + .map_err(|e| invalid(format!("attestation signature invalid: {e}")))?; 375 + 376 + Ok(client_id) 377 + } 378 + 379 + /// Output of `com.atproto.simplespace.checkUserAccess`. 380 + #[derive(Debug, Deserialize)] 381 + struct CheckUserAccessOutput { 382 + authorized: bool, 383 + } 384 + 385 + /// Ask the space's `managingApp` whether to authorize the requesting user, by 386 + /// calling `com.atproto.simplespace.checkUserAccess` on the managing-app 387 + /// service with a service-auth bearer issued by the space authority 388 + /// (`iss=owner_did`, `aud=managing_app`). 389 + /// 390 + /// The `service_endpoint` is the base URL of the managing-app service (already 391 + /// resolved by the caller from the `managingApp` service identifier); 392 + /// `managing_app` is the service identifier used as the JWT audience. 393 + /// 394 + /// # Errors 395 + /// Returns [`MintDenial::UserNotAuthorized`] when the managing app answers 396 + /// `authorized=false`, or [`MintDenial::NotAuthorized`] when the call could 397 + /// not be completed (network/sign failure) — the latter is not attributable 398 + /// to a single axis. 399 + #[allow(clippy::too_many_arguments)] 400 + pub async fn check_user_access( 401 + http: &reqwest::Client, 402 + service_endpoint: &str, 403 + managing_app: &str, 404 + owner_signing_key: &KeyData, 405 + owner_did: &str, 406 + space: &SpaceUri, 407 + user_did: &str, 408 + attested_client_id: Option<&str>, 409 + ) -> Result<(), MintDenial> { 410 + let token = mint_check_user_access_service_auth(owner_did, managing_app, owner_signing_key) 411 + .map_err(|reason| MintDenial::NotAuthorized { reason })?; 412 + 413 + let endpoint = format!( 414 + "{base}/xrpc/com.atproto.simplespace.checkUserAccess", 415 + base = service_endpoint.trim_end_matches('/') 416 + ); 417 + let mut query: Vec<(&str, String)> = 418 + vec![("space", space.to_string()), ("user", user_did.to_string())]; 419 + if let Some(client_id) = attested_client_id { 420 + query.push(("clientId", client_id.to_string())); 421 + } 422 + 423 + let response = http 424 + .get(&endpoint) 425 + .query(&query) 426 + .bearer_auth(&token) 427 + .header("Accept", "application/json") 428 + .send() 429 + .await 430 + .map_err(|e| MintDenial::NotAuthorized { 431 + reason: format!("checkUserAccess request to {endpoint} failed: {e}"), 432 + })? 433 + .error_for_status() 434 + .map_err(|e| MintDenial::NotAuthorized { 435 + reason: format!("checkUserAccess {endpoint} returned error status: {e}"), 436 + })?; 437 + 438 + let output: CheckUserAccessOutput = 439 + response 440 + .json() 441 + .await 442 + .map_err(|e| MintDenial::NotAuthorized { 443 + reason: format!("parse checkUserAccess response: {e}"), 444 + })?; 445 + 446 + if output.authorized { 447 + Ok(()) 448 + } else { 449 + Err(MintDenial::UserNotAuthorized { 450 + reason: "managing app refused (checkUserAccess authorized=false)".to_string(), 451 + }) 452 + } 453 + } 454 + 455 + /// Service-auth JWT header. 456 + #[derive(serde::Serialize)] 457 + struct ServiceAuthHeader { 458 + alg: String, 459 + typ: &'static str, 460 + } 461 + 462 + /// Service-auth JWT claims, scoped to the `checkUserAccess` lexicon method. 463 + #[derive(serde::Serialize)] 464 + struct ServiceAuthClaims<'a> { 465 + iss: &'a str, 466 + aud: &'a str, 467 + lxm: &'a str, 468 + iat: u64, 469 + exp: u64, 470 + jti: String, 471 + } 472 + 473 + /// Mint a 60-second `at+jwt` service-auth token signed by the owner's signing 474 + /// key, scoped to `com.atproto.simplespace.checkUserAccess`. 475 + fn mint_check_user_access_service_auth( 476 + owner_did: &str, 477 + managing_app: &str, 478 + signing_key: &KeyData, 479 + ) -> Result<String, String> { 480 + let iat = now_secs(); 481 + let exp = iat + 60; 482 + let jti = { 483 + let mut bytes = [0u8; 16]; 484 + use rand::RngExt; 485 + rand::rng().fill(&mut bytes); 486 + B64URL.encode(bytes) 487 + }; 488 + let header = ServiceAuthHeader { 489 + alg: atproto_identity::key::jws_alg(signing_key).to_string(), 490 + typ: "at+jwt", 491 + }; 492 + let payload = ServiceAuthClaims { 493 + iss: owner_did, 494 + aud: managing_app, 495 + lxm: "com.atproto.simplespace.checkUserAccess", 496 + iat, 497 + exp, 498 + jti, 499 + }; 500 + let header_bytes = 501 + serde_json::to_vec(&header).map_err(|e| format!("serialize service-auth header: {e}"))?; 502 + let payload_bytes = 503 + serde_json::to_vec(&payload).map_err(|e| format!("serialize service-auth payload: {e}"))?; 504 + let signing_input = format!( 505 + "{}.{}", 506 + B64URL.encode(&header_bytes), 507 + B64URL.encode(&payload_bytes) 508 + ); 509 + let sig = atproto_identity::key::sign(signing_key, signing_input.as_bytes()) 510 + .map_err(|e| format!("sign service-auth: {e}"))?; 511 + Ok(format!("{}.{}", signing_input, B64URL.encode(&sig))) 512 + } 513 + 514 + #[cfg(test)] 515 + mod tests { 516 + use super::*; 517 + 518 + fn allow_list(ids: &[&str]) -> AppAccess { 519 + AppAccess::AllowList { 520 + allowed: ids.iter().map(|s| s.to_string()).collect(), 521 + } 522 + } 523 + 524 + // --- USER axis --------------------------------------------------------- 525 + 526 + #[test] 527 + fn user_axis_public_authorizes_anyone() { 528 + assert_eq!(user_axis_local(MintPolicy::Public, false), Ok(Some(()))); 529 + assert_eq!(user_axis_local(MintPolicy::Public, true), Ok(Some(()))); 530 + } 531 + 532 + #[test] 533 + fn user_axis_member_list_allows_member() { 534 + assert_eq!(user_axis_local(MintPolicy::MemberList, true), Ok(Some(()))); 535 + } 536 + 537 + #[test] 538 + fn user_axis_member_list_denies_non_member() { 539 + let r = user_axis_local(MintPolicy::MemberList, false); 540 + assert!(matches!(r, Err(MintDenial::UserNotAuthorized { .. }))); 541 + assert_eq!(r.unwrap_err().error_name(), "UserNotAuthorized"); 542 + } 543 + 544 + #[test] 545 + fn user_axis_managing_app_defers() { 546 + assert_eq!(user_axis_local(MintPolicy::ManagingApp, false), Ok(None)); 547 + assert_eq!(user_axis_local(MintPolicy::ManagingApp, true), Ok(None)); 548 + } 549 + 550 + // --- APP axis ---------------------------------------------------------- 551 + 552 + #[test] 553 + fn app_axis_open_allows_with_or_without_attestation() { 554 + assert_eq!(app_axis(&AppAccess::Open, None), Ok(())); 555 + assert_eq!( 556 + app_axis(&AppAccess::Open, Some("https://app.example")), 557 + Ok(()) 558 + ); 559 + } 560 + 561 + #[test] 562 + fn app_axis_allow_list_allows_listed_client() { 563 + let access = allow_list(&["https://a.example", "https://b.example"]); 564 + assert_eq!(app_axis(&access, Some("https://b.example")), Ok(())); 565 + } 566 + 567 + #[test] 568 + fn app_axis_allow_list_denies_unlisted_client() { 569 + let access = allow_list(&["https://a.example"]); 570 + let r = app_axis(&access, Some("https://evil.example")); 571 + assert!(matches!(r, Err(MintDenial::AppNotAuthorized { .. }))); 572 + assert_eq!(r.unwrap_err().error_name(), "AppNotAuthorized"); 573 + } 574 + 575 + #[test] 576 + fn app_axis_allow_list_denies_when_no_attestation() { 577 + let access = allow_list(&["https://a.example"]); 578 + let r = app_axis(&access, None); 579 + assert!(matches!(r, Err(MintDenial::AppNotAuthorized { .. }))); 580 + } 581 + 582 + // --- combined matrix --------------------------------------------------- 583 + 584 + #[test] 585 + fn matrix_public_open_allows() { 586 + assert!(user_axis_local(MintPolicy::Public, false).is_ok()); 587 + assert!(app_axis(&AppAccess::Open, None).is_ok()); 588 + } 589 + 590 + #[test] 591 + fn matrix_member_list_allow_list_requires_both() { 592 + // member + listed app -> ok on both axes 593 + assert!(user_axis_local(MintPolicy::MemberList, true).is_ok()); 594 + let access = allow_list(&["https://app.example"]); 595 + assert!(app_axis(&access, Some("https://app.example")).is_ok()); 596 + 597 + // non-member fails user axis even with a listed app 598 + assert!(user_axis_local(MintPolicy::MemberList, false).is_err()); 599 + 600 + // member but unlisted app fails app axis 601 + assert!(app_axis(&access, Some("https://other.example")).is_err()); 602 + } 603 + 604 + #[test] 605 + fn denial_reasons_are_populated() { 606 + let d = user_axis_local(MintPolicy::MemberList, false).unwrap_err(); 607 + assert!(!d.reason().is_empty()); 608 + } 609 + 610 + #[test] 611 + fn space_host_audience_format() { 612 + assert_eq!( 613 + space_host_audience("did:plc:owner"), 614 + "did:plc:owner#atproto_space_host" 615 + ); 616 + } 617 + 618 + #[test] 619 + fn attestation_rejects_non_compact_jws() { 620 + // Build a client that will never be used (decode fails first). 621 + let http = reqwest::Client::new(); 622 + let space = SpaceUri::new( 623 + "did:plc:owner".to_string(), 624 + atproto_space::types::SpaceType::new("app.bsky.group").unwrap(), 625 + atproto_space::types::SpaceKey::new("k").unwrap(), 626 + ); 627 + let guard = crate::security::JtiReplayGuard::new(16); 628 + let rt = tokio::runtime::Builder::new_current_thread() 629 + .enable_all() 630 + .build() 631 + .unwrap(); 632 + let r = rt.block_on(verify_client_attestation( 633 + &http, 634 + &guard, 635 + "not-a-jwt", 636 + &space, 637 + )); 638 + assert!(matches!( 639 + r, 640 + Err(MintDenial::InvalidClientAttestation { .. }) 641 + )); 642 + } 643 + }
+14 -6
crates/atproto-pds/src/space/mod.rs
··· 1 1 //! Permissioned-data spaces subsystem — wires `atproto-space` orchestrators 2 2 //! into the per-actor SQLite store and exposes them via XRPC. 3 3 //! 4 + //! Components: 4 5 //! 5 6 //! - `SpaceService` — `createSpace`, `getSpace`, `listSpaces`, `addMember`, 6 - //! `removeMember`, `getMembers`. 7 + //! `removeMember`, `listMembers`. 7 8 //! - `SpaceWriter` — `createRecord` / `putRecord` / `deleteRecord` / 8 9 //! `applyWrites` against a permissioned repo (per-(DID, space-URI) lock). 9 10 //! - `SpaceReader` — dual-auth `getRecord` / `listRecords` (own-PDS OAuth 10 11 //! or remote SpaceCredential). 11 - //! - `SpaceSync` — `getRepoState`, `getRepoOplog`, `getMemberState`, 12 - //! `getMemberOplog`. 12 + //! - `SpaceSync` — `getRepoState`, `listRepoOps`. 13 13 //! - Credential mint/verify wired to `atproto-space::credential`. 14 14 //! 15 - //! The auth-extractor extensions for MemberGrant/SpaceCredential JWTs 15 + //! The auth-extractor extensions for delegation-token/SpaceCredential JWTs 16 16 //! live in the HTTP layer alongside the app-password sessions. 17 17 18 - pub mod export; 18 + pub mod config; 19 + pub mod declaration; 19 20 pub mod inbound; 21 + pub mod mint_authz; 20 22 pub mod notify; 21 23 pub mod reader; 22 24 pub mod recipient; 23 25 pub mod service; 26 + pub mod service_auth; 24 27 pub mod sync; 25 28 pub mod writer; 26 29 30 + pub use config::{AppAccess, MintPolicy, SpaceConfig, SpaceConfigPatch}; 31 + pub use declaration::{ 32 + CachingSpaceDeclarationResolver, NetworkSpaceDeclarationResolver, SpaceDeclaration, 33 + SpaceDeclarationResolver, 34 + }; 27 35 pub use reader::SpaceReader; 28 - pub use service::{SpaceInfo, SpaceService}; 36 + pub use service::{GetSpaceOutput, SpaceInfo, SpaceService}; 29 37 pub use sync::SpaceSync; 30 38 pub use writer::{SpaceWriteAction, SpaceWriteOp, SpaceWriter};
+201 -165
crates/atproto-pds/src/space/notify.rs
··· 1 1 //! Spaces notification fan-out — payload definitions + enqueue helpers. 2 2 //! 3 - //! and 4 - //! after every successful Spaces commit the owner's PDS 5 - //! must broadcast to every registered consumer service. This module 3 + //! Spaces writes don't fan out over the public firehose. Instead the owner's 4 + //! PDS broadcasts to every registered consumer service. This module 6 5 //! encapsulates: 7 6 //! 8 - //! - The on-the-wire DAG-CBOR payload shapes for `notifyWrite` (records) and 9 - //! `notifyMembership` (member list). 10 - //! - Helpers that look up `space_credential_recipient` rows on the owner's 11 - //! per-actor store and append one `notify_attempt` row per recipient on the 12 - //! shared accounts pool. 7 + //! - The on-the-wire payload shape for `notifyWrite` (contentless; 8 + //! `{ space, repo, rev }`). 9 + //! - Helpers that read/write `space_credential_recipient` subscription rows on 10 + //! the owner's per-actor store and append one `notify_attempt` row per 11 + //! recipient on the shared accounts pool. 13 12 //! 14 13 //! The actual delivery happens in [`crate::notifier::Notifier::tick`]. 15 14 //! 16 - //! Recipient registration is owned by [`crate::http::space_handlers`] — 17 - //! [`upsert_recipient`] is the helper called from `getSpaceCredential`. 15 + //! # Notify payloads 16 + //! 17 + //! `notifyWrite` carries **no record data** per the 0016 Permissioned Data 18 + //! draft lexicon `com.atproto.space.notifyWrite`: the body is exactly 19 + //! `{ space, repo, rev }` and is encoded as `application/json`. Consumers PULL 20 + //! the actual ops via `listRepoOps`/`getRepoState`. The 0016 draft has no 21 + //! membership-notification flow. 18 22 //! 19 23 //! # Recipient discovery 20 24 //! 21 - //! (closed in commit `9cbdd7f`) wired DID-document 22 - //! resolution to find each consumer's declared `atproto_pds` service 23 - //! endpoint. The `recipient.rs` resolver picks the path automatically; 24 - //! see for the soft-fail contract on consumers 25 - //! whose DID document doesn't expose a discoverable PDS endpoint. 25 + //! Subscriptions are recorded two ways: 26 + //! - `getSpaceCredential` self-registers the credential consumer for the whole 27 + //! space (`repo` NULL, no expiry). 28 + //! - `registerNotify` records an endpoint subscription, either whole-space 29 + //! (`repo` NULL) or for a single repo (`repo` = that DID), with an 30 + //! `expires_at` registration lifetime. 26 31 27 32 use crate::actor_store::sql::SqlActorStore; 28 33 use crate::errors::{PdsError, PdsResult}; 29 34 use crate::notifier::enqueue_notification; 30 - use atproto_space::commit::Commit; 31 - use atproto_space::space_repo::{Op, OpAction}; 35 + use crate::space::service_auth::{NOTIFY_SERVICE_AUTH_TTL_SECS, mint_service_auth}; 36 + use atproto_identity::key::KeyData; 32 37 use atproto_space::types::SpaceUri; 33 38 use chrono::Utc; 34 39 use serde::{Deserialize, Serialize}; ··· 37 42 /// NSID for outbound `notifyWrite` POSTs. 38 43 pub const NOTIFY_WRITE_NSID: &str = "com.atproto.space.notifyWrite"; 39 44 40 - /// NSID for outbound `notifyMembership` POSTs. 41 - pub const NOTIFY_MEMBERSHIP_NSID: &str = "com.atproto.space.notifyMembership"; 42 - 43 - /// One op inside a [`NotifyWritePayload`]. 44 - #[derive(Debug, Clone, Serialize, Deserialize)] 45 - pub struct NotifyOp { 46 - /// `"create"` | `"update"` | `"delete"`. 47 - pub action: String, 48 - /// NSID collection. 49 - pub collection: String, 50 - /// Record key. 51 - pub rkey: String, 52 - /// CID of the value (`None` for delete). 53 - #[serde(skip_serializing_if = "Option::is_none")] 54 - pub cid: Option<String>, 55 - /// DAG-CBOR-encoded record value (`None` for delete). 56 - #[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes_opt")] 57 - pub value: Option<Vec<u8>>, 58 - } 59 - 60 - /// Wire-shape of `com.atproto.space.notifyWrite` request body (DAG-CBOR). 45 + /// Wire-shape of `com.atproto.space.notifyWrite` request body 46 + /// (`application/json`). Contentless: it announces that `repo` advanced to 47 + /// `rev` within `space`; consumers PULL the ops via `listRepoOps`. 61 48 #[derive(Debug, Clone, Serialize, Deserialize)] 62 49 pub struct NotifyWritePayload { 63 50 /// Space URI. 64 51 pub space: String, 65 - /// DID of the member who wrote. 66 - pub member: String, 67 - /// Signed commit (set_hash, rev, ikm, tag, sig). 68 - pub commit: Commit, 69 - /// Per-op detail. 70 - pub ops: Vec<NotifyOp>, 52 + /// DID of the account whose repo advanced. 53 + pub repo: String, 54 + /// The revision of the write. 55 + pub rev: String, 71 56 } 72 57 73 - /// Wire-shape of `com.atproto.space.notifyMembership` request body (DAG-CBOR). 74 - #[derive(Debug, Clone, Serialize, Deserialize)] 75 - pub struct NotifyMembershipPayload { 76 - /// Space URI. 77 - pub space: String, 78 - /// `"add"` | `"remove"`. 79 - pub action: String, 80 - /// DID of the member affected. 81 - pub member: String, 82 - /// Signed commit covering this membership change. 83 - pub commit: Commit, 84 - } 85 - 86 - /// Translate a [`crate::space::writer::SpaceWriter`]-style [`Op`] into the 87 - /// notify-payload shape (drops the value bytes for deletes). 88 - #[must_use] 89 - pub fn op_to_notify(op: &Op) -> NotifyOp { 90 - NotifyOp { 91 - action: match op.action { 92 - OpAction::Create => "create".to_string(), 93 - OpAction::Update => "update".to_string(), 94 - OpAction::Delete => "delete".to_string(), 95 - }, 96 - collection: op.collection.clone(), 97 - rkey: op.rkey.clone(), 98 - cid: op.cid.clone(), 99 - value: op.value.clone(), 100 - } 101 - } 102 - 103 - /// One row from `space_credential_recipient` for a given space. 58 + /// One subscription row from `space_credential_recipient` for a given space. 104 59 #[derive(Debug, Clone)] 105 60 pub struct Recipient { 106 61 /// Recipient service DID. ··· 109 64 pub service_endpoint: String, 110 65 } 111 66 112 - /// Read all recipients for `space` from the owner's per-actor store. 67 + /// Read recipients for `space` from the owner's per-actor store, expanding the 68 + /// per-repo subscription filter: whole-space rows (`repo = ''`) always match; 69 + /// per-repo rows match only when `repo == writer_repo`. Expired rows 70 + /// (`expires_at` in the past) are skipped. 71 + /// 72 + /// `writer_repo` is the DID of the account that wrote (for `notifyWrite` 73 + /// fan-out); pass `None` for membership fan-out, which targets whole-space 74 + /// subscribers only. 113 75 pub async fn list_recipients( 114 76 owner_actor_pool: &SqlitePool, 115 77 space: &SpaceUri, 78 + writer_repo: Option<&str>, 116 79 ) -> PdsResult<Vec<Recipient>> { 117 - let rows: Vec<(String, String)> = sqlx::query_as( 118 - "SELECT service_did, service_endpoint 80 + let now = Utc::now().to_rfc3339(); 81 + let rows: Vec<(String, String, String, Option<String>)> = sqlx::query_as( 82 + "SELECT service_did, service_endpoint, repo, expires_at 119 83 FROM space_credential_recipient 120 84 WHERE space = ?", 121 85 ) ··· 127 91 })?; 128 92 Ok(rows 129 93 .into_iter() 130 - .map(|(service_did, service_endpoint)| Recipient { 94 + .filter(|(_, _, repo, expires_at)| { 95 + // Skip expired registrations. 96 + if let Some(exp) = expires_at 97 + && exp.as_str() <= now.as_str() 98 + { 99 + return false; 100 + } 101 + // Whole-space rows always match; per-repo rows match the writer. 102 + repo.is_empty() || writer_repo == Some(repo.as_str()) 103 + }) 104 + .map(|(service_did, service_endpoint, _, _)| Recipient { 131 105 service_did, 132 106 service_endpoint, 133 107 }) 134 108 .collect()) 135 109 } 136 110 137 - /// INSERT-OR-REPLACE into the owner's per-actor `space_credential_recipient` 138 - /// table. Idempotent on `(space, service_did)` — re-issuing a credential to 139 - /// the same client just bumps `last_issued_at`. 111 + /// INSERT-OR-REPLACE a whole-space subscription into the owner's per-actor 112 + /// `space_credential_recipient` table (the `getSpaceCredential` self-register 113 + /// path). Idempotent on `(space, '', service_did)` — re-issuing a credential to 114 + /// the same client just bumps `last_issued_at`. Leaves `expires_at` unset. 140 115 pub async fn upsert_recipient( 141 116 owner_actor_pool: &SqlitePool, 142 117 space: &SpaceUri, 143 118 service_did: &str, 144 119 service_endpoint: &str, 145 120 ) -> PdsResult<()> { 121 + upsert_subscription( 122 + owner_actor_pool, 123 + space, 124 + None, 125 + service_did, 126 + service_endpoint, 127 + None, 128 + ) 129 + .await 130 + } 131 + 132 + /// INSERT-OR-REPLACE a subscription, keyed `(space, repo-or-empty, 133 + /// service_did)`. `repo = None` is the whole-space sentinel; `expires_at` is 134 + /// the optional RFC 3339 registration lifetime. Used by both the 135 + /// `getSpaceCredential` self-register path ([`upsert_recipient`]) and 136 + /// `registerNotify`. 137 + pub async fn upsert_subscription( 138 + owner_actor_pool: &SqlitePool, 139 + space: &SpaceUri, 140 + repo: Option<&str>, 141 + service_did: &str, 142 + service_endpoint: &str, 143 + expires_at: Option<&str>, 144 + ) -> PdsResult<()> { 146 145 let now = Utc::now().to_rfc3339(); 147 146 sqlx::query( 148 147 "INSERT INTO space_credential_recipient 149 - (space, service_did, service_endpoint, last_issued_at) 150 - VALUES (?, ?, ?, ?) 151 - ON CONFLICT(space, service_did) DO UPDATE SET 148 + (space, repo, service_did, service_endpoint, last_issued_at, expires_at) 149 + VALUES (?, ?, ?, ?, ?, ?) 150 + ON CONFLICT(space, repo, service_did) DO UPDATE SET 152 151 service_endpoint = excluded.service_endpoint, 153 - last_issued_at = excluded.last_issued_at", 152 + last_issued_at = excluded.last_issued_at, 153 + expires_at = excluded.expires_at", 154 154 ) 155 155 .bind(space.to_string()) 156 + .bind(repo.unwrap_or("")) 156 157 .bind(service_did) 157 158 .bind(service_endpoint) 158 159 .bind(&now) 160 + .bind(expires_at) 159 161 .execute(owner_actor_pool) 160 162 .await 161 163 .map_err(|e| PdsError::Storage { 162 - reason: format!("upsert_recipient: {e}"), 164 + reason: format!("upsert_subscription: {e}"), 163 165 })?; 164 166 Ok(()) 165 167 } 166 168 167 - /// Encode `payload` as DAG-CBOR and enqueue one `notify_attempt` row 168 - /// recipient. Returns the count of rows appended. 169 + /// Encode the contentless `notifyWrite` payload as JSON and enqueue one 170 + /// `notify_attempt` row per matching recipient (HOP 2 fan-out). `writer_repo` 171 + /// filters per-repo subscriptions. Each row carries a fresh service-auth token 172 + /// signed by the owner (`iss = space.space_did`, `aud = recipient service`). 173 + /// Returns the count of rows appended. 169 174 /// 170 - /// Failures here are non-fatal to the caller — e.g. if a recipient lookup 171 - /// returns zero rows, the writer's commit is still durable. The caller 172 - /// passes both pools because the recipient table lives on the owner's 173 - /// per-actor store while the queue lives on the shared accounts pool. 175 + /// Failures here are non-fatal to the caller — if a recipient lookup returns 176 + /// zero rows, the writer's commit is still durable. The caller passes both 177 + /// pools because the subscription table lives on the owner's per-actor store 178 + /// while the queue lives on the shared accounts pool. 174 179 pub async fn enqueue_writes( 175 180 accounts_pool: &SqlitePool, 176 181 data_dir: &std::path::Path, 177 182 space: &SpaceUri, 178 183 payload: &NotifyWritePayload, 179 - ) -> PdsResult<u32> { 180 - enqueue_for_space(accounts_pool, data_dir, space, NOTIFY_WRITE_NSID, payload).await 181 - } 182 - 183 - /// Encode `payload` as DAG-CBOR and enqueue one `notify_attempt` row 184 - /// recipient — membership flavor. 185 - pub async fn enqueue_membership( 186 - accounts_pool: &SqlitePool, 187 - data_dir: &std::path::Path, 188 - space: &SpaceUri, 189 - payload: &NotifyMembershipPayload, 184 + owner_signing_key: &KeyData, 190 185 ) -> PdsResult<u32> { 191 186 enqueue_for_space( 192 187 accounts_pool, 193 188 data_dir, 194 189 space, 195 - NOTIFY_MEMBERSHIP_NSID, 196 - payload, 190 + Some(payload.repo.as_str()), 191 + NOTIFY_WRITE_NSID, 192 + "application/json", 193 + &serde_json::to_vec(payload).map_err(|e| PdsError::Storage { 194 + reason: format!("encode notifyWrite payload: {e}"), 195 + })?, 196 + Some(owner_signing_key), 197 197 ) 198 198 .await 199 199 } 200 200 201 - async fn enqueue_for_space<P: serde::Serialize>( 201 + #[allow(clippy::too_many_arguments)] 202 + async fn enqueue_for_space( 202 203 accounts_pool: &SqlitePool, 203 204 data_dir: &std::path::Path, 204 205 space: &SpaceUri, 206 + writer_repo: Option<&str>, 205 207 nsid: &str, 206 - payload: &P, 208 + content_type: &str, 209 + body: &[u8], 210 + owner_signing_key: Option<&KeyData>, 207 211 ) -> PdsResult<u32> { 208 - let owner_store = SqlActorStore::open(data_dir, &space.owner_did).await?; 209 - let recipients = list_recipients(owner_store.pool(), space).await?; 212 + let owner_store = SqlActorStore::open(data_dir, &space.space_did).await?; 213 + let recipients = list_recipients(owner_store.pool(), space, writer_repo).await?; 210 214 if recipients.is_empty() { 211 215 return Ok(0); 212 216 } 213 217 214 - let body = atproto_dasl::to_vec(payload).map_err(|e| PdsError::Storage { 215 - reason: format!("encode notify payload: {e}"), 216 - })?; 217 - 218 218 let mut count = 0u32; 219 219 for r in recipients { 220 + // Mint a per-recipient service-auth token when a signing key is 221 + // supplied (notifyWrite). aud is the recipient service DID. 222 + let auth_token = match owner_signing_key { 223 + Some(key) => Some(mint_service_auth( 224 + key, 225 + &space.space_did, 226 + &r.service_did, 227 + nsid, 228 + NOTIFY_SERVICE_AUTH_TTL_SECS, 229 + )?), 230 + None => None, 231 + }; 220 232 enqueue_notification( 221 233 accounts_pool, 222 234 &r.service_did, 223 235 &r.service_endpoint, 224 - body.clone(), 236 + body.to_vec(), 225 237 nsid, 238 + content_type, 239 + auth_token.as_deref(), 226 240 ) 227 241 .await?; 228 242 count += 1; 229 243 } 230 244 Ok(count) 231 - } 232 - 233 - /// `serde_bytes`-style adapter for `Option<Vec<u8>>` so the payload encodes 234 - /// the raw bytes as a CBOR byte string instead of an array of integers. 235 - mod serde_bytes_opt { 236 - use serde::{Deserialize, Deserializer, Serialize, Serializer}; 237 - 238 - pub fn serialize<S: Serializer>( 239 - value: &Option<Vec<u8>>, 240 - serializer: S, 241 - ) -> Result<S::Ok, S::Error> { 242 - match value { 243 - Some(bytes) => serde_bytes::ByteBuf::from(bytes.clone()).serialize(serializer), 244 - None => serializer.serialize_none(), 245 - } 246 - } 247 - 248 - pub fn deserialize<'de, D: Deserializer<'de>>( 249 - deserializer: D, 250 - ) -> Result<Option<Vec<u8>>, D::Error> { 251 - let opt: Option<serde_bytes::ByteBuf> = Option::deserialize(deserializer)?; 252 - Ok(opt.map(|b| b.into_vec())) 253 - } 254 245 } 255 246 256 247 #[cfg(test)] ··· 310 301 .await 311 302 .unwrap(); 312 303 313 - let recipients = list_recipients(owner_store.pool(), &uri).await.unwrap(); 304 + let recipients = list_recipients(owner_store.pool(), &uri, None) 305 + .await 306 + .unwrap(); 314 307 assert_eq!(recipients.len(), 1); 315 308 assert_eq!(recipients[0].service_did, "did:web:appview.example"); 316 309 } ··· 328 321 .unwrap(); 329 322 let payload = NotifyWritePayload { 330 323 space: test_space().to_string(), 331 - member: "did:plc:alice".to_string(), 332 - commit: Commit { 333 - set_hash: vec![0u8; 32], 334 - rev: "rev".to_string(), 335 - ikm: vec![0u8; 32], 336 - tag: vec![0u8; 32], 337 - sig: vec![0u8; 64], 338 - }, 339 - ops: vec![], 324 + repo: "did:plc:alice".to_string(), 325 + rev: "rev".to_string(), 340 326 }; 341 - let count = enqueue_writes(accounts.pool(), &dir, &test_space(), &payload) 327 + let owner_key = 328 + atproto_identity::key::generate_key(atproto_identity::key::KeyType::P256Private) 329 + .unwrap(); 330 + let count = enqueue_writes(accounts.pool(), &dir, &test_space(), &payload, &owner_key) 342 331 .await 343 332 .unwrap(); 344 333 assert_eq!(count, 0); ··· 373 362 .unwrap(); 374 363 let payload = NotifyWritePayload { 375 364 space: uri.to_string(), 376 - member: "did:plc:alice".to_string(), 377 - commit: Commit { 378 - set_hash: vec![0u8; 32], 379 - rev: "3kmev".to_string(), 380 - ikm: vec![0u8; 32], 381 - tag: vec![0u8; 32], 382 - sig: vec![0u8; 64], 383 - }, 384 - ops: vec![NotifyOp { 385 - action: "create".to_string(), 386 - collection: "c".to_string(), 387 - rkey: "k".to_string(), 388 - cid: Some("bafy...".to_string()), 389 - value: Some(b"v".to_vec()), 390 - }], 365 + repo: "did:plc:alice".to_string(), 366 + rev: "3kmev".to_string(), 391 367 }; 392 - let count = enqueue_writes(accounts.pool(), &dir, &uri, &payload) 368 + let owner_key = 369 + atproto_identity::key::generate_key(atproto_identity::key::KeyType::P256Private) 370 + .unwrap(); 371 + let count = enqueue_writes(accounts.pool(), &dir, &uri, &payload, &owner_key) 393 372 .await 394 373 .unwrap(); 395 374 assert_eq!(count, 2); ··· 399 378 .unwrap(); 400 379 assert_eq!(due.len(), 2); 401 380 assert!(due.iter().all(|d| d.nsid == NOTIFY_WRITE_NSID)); 381 + } 382 + 383 + /// A per-repo subscription only matches notifyWrite fan-out for that repo. 384 + #[tokio::test(flavor = "multi_thread")] 385 + async fn per_repo_subscription_filters_by_writer() { 386 + let tmp = TempDir::new().unwrap(); 387 + let dir = tmp.path(); 388 + let owner_store = SqlActorStore::open(dir, "did:plc:owner").await.unwrap(); 389 + let uri = test_space(); 390 + ensure_space_row(owner_store.pool(), &uri).await; 391 + // Subscribe `svc` to alice's repo only. 392 + upsert_subscription( 393 + owner_store.pool(), 394 + &uri, 395 + Some("did:plc:alice"), 396 + "did:web:svc.example", 397 + "https://svc.example", 398 + None, 399 + ) 400 + .await 401 + .unwrap(); 402 + 403 + // Writer = alice → matches. 404 + let for_alice = list_recipients(owner_store.pool(), &uri, Some("did:plc:alice")) 405 + .await 406 + .unwrap(); 407 + assert_eq!(for_alice.len(), 1); 408 + 409 + // Writer = bob → no match. 410 + let for_bob = list_recipients(owner_store.pool(), &uri, Some("did:plc:bob")) 411 + .await 412 + .unwrap(); 413 + assert_eq!(for_bob.len(), 0); 414 + } 415 + 416 + /// Expired registrations are skipped by `list_recipients`. 417 + #[tokio::test(flavor = "multi_thread")] 418 + async fn expired_subscription_is_skipped() { 419 + let tmp = TempDir::new().unwrap(); 420 + let dir = tmp.path(); 421 + let owner_store = SqlActorStore::open(dir, "did:plc:owner").await.unwrap(); 422 + let uri = test_space(); 423 + ensure_space_row(owner_store.pool(), &uri).await; 424 + upsert_subscription( 425 + owner_store.pool(), 426 + &uri, 427 + None, 428 + "did:web:svc.example", 429 + "https://svc.example", 430 + Some("2000-01-01T00:00:00Z"), 431 + ) 432 + .await 433 + .unwrap(); 434 + let recipients = list_recipients(owner_store.pool(), &uri, Some("did:plc:alice")) 435 + .await 436 + .unwrap(); 437 + assert_eq!(recipients.len(), 0); 402 438 } 403 439 }
+264 -90
crates/atproto-pds/src/space/reader.rs
··· 1 1 //! `SpaceReader` — dual-auth permissioned-record reads. 2 2 //! 3 - //! Spaces reads accept 4 - //! either: 3 + //! Spaces reads accept either: 5 4 //! - **Own-PDS OAuth** — the caller holds an OAuth bearer for the local 6 5 //! member's account and can read whatever rows their per-actor store has. 7 6 //! The PDS itself is the auth boundary. 8 7 //! - **Remote `SpaceCredential`** — a JWT minted by the space owner via 9 8 //! `getSpaceCredential` and bound to a `clientId`. The owner's PDS verifies 10 9 //! the credential against its own signing key, then serves rows from the 11 - //! *owner's* per-actor store. 10 + //! per-actor store of the `repo` DID supplied by the caller. 12 11 //! 13 12 //! `SpaceReader` is intentionally storage-agnostic above the 14 13 //! `SpaceRepoStorage` trait: **the PDS does not enforce 15 - //! membership at read time** for own-PDS-OAuth callers (G35) — the consumer 14 + //! membership at read time** for own-PDS-OAuth callers — the consumer 16 15 //! is responsible for membership checks at sync. For SpaceCredential callers, 17 16 //! the credential itself acts as proof that the owner has authorized this 18 17 //! `clientId` to read the space. 18 + //! 19 + //! Both auth modes accept an explicit `target_repo` (the DID whose per-actor 20 + //! store to read from). For OAuth, the caller may omit `repo` and the read 21 + //! defaults to their own subject; for SpaceCredential the caller MUST supply 22 + //! `repo` — handler-level validation rejects missing values before they reach 23 + //! the reader. 19 24 20 25 use crate::actor_store::sql::{SqlActorStore, SqlSpaceRepoStorage}; 21 26 use crate::errors::{PdsError, PdsResult}; 22 27 use crate::realm::PdsSetHash; 28 + use crate::space::config::ensure_space_live; 23 29 use atproto_identity::key::{KeyData, to_public}; 24 30 use atproto_space::credential::{SpaceCredential, verify_space_credential}; 25 31 use atproto_space::space_repo::SpaceRepo; ··· 33 39 pub enum SpaceReadAuth<'a> { 34 40 /// Caller holds an OAuth bearer for the named account on this PDS. 35 41 /// Reads happen against that account's per-actor store. No membership 36 - /// check is performed here (G35) — consumers verify at sync time. 42 + /// check is performed here — consumers verify at sync time. 37 43 OwnPds { 38 44 /// DID of the local account whose per-actor store to read from. 39 45 account_did: &'a str, 40 46 }, 41 - /// Caller presented a `SpaceCredential` JWT minted by the space owner. 42 - /// Reads happen against the *owner's* per-actor store. 47 + /// Caller presented a `SpaceCredential` JWT minted by the space authority. 48 + /// Reads happen against the *authority's* per-actor store. The credential's 49 + /// `client_id` claim is advisory (the trust comes from the authority's 50 + /// signature), so no expected-client check is performed here. 43 51 SpaceCredential { 44 52 /// The compact-form JWT. 45 53 token: &'a str, 46 - /// `client_id` the credential is expected to be bound to (from the 47 - /// HTTP-layer DPoP/auth check). 48 - expected_client_id: &'a str, 49 54 }, 50 55 } 51 56 ··· 62 67 Self { data_dir, accounts } 63 68 } 64 69 65 - /// `getRecord` — fetch a single record by `(collection, rkey)`. 70 + /// Fail with `SpaceNotFound` when the space is tombstoned. The tombstone 71 + /// lives in the space-authority's (`space.space_did`) per-actor store; the 72 + /// check is a no-op when that store is not local (cross-PDS spaces, where 73 + /// the authority enforces deletion on its own side). 74 + async fn ensure_space_live(&self, space: &SpaceUri) -> PdsResult<()> { 75 + ensure_space_live(&self.data_dir, space).await?; 76 + Ok(()) 77 + } 78 + 79 + /// `getRecord` — fetch a single record by `(collection, rkey)` from the 80 + /// per-actor store of `target_repo`. 81 + /// 82 + /// `target_repo` is the DID of the member whose records to read from. 83 + /// For OAuth callers this is typically the caller's own DID (but may 84 + /// differ if the caller passed `repo` to read another member's repo on 85 + /// this PDS). For SpaceCredential callers this is the `repo` query 86 + /// parameter; handler-level validation ensures it is always supplied. 66 87 /// 67 88 /// Returns `Ok(None)` when the record does not exist OR is taken-down 68 - /// per §4.4 (`space_record_takedown`); returns [`PdsError::AuthDenied`] 89 + /// (`space_record_takedown`); returns [`PdsError::AuthDenied`] 69 90 /// for invalid SpaceCredentials. 70 91 pub async fn get_record( 71 92 &self, 72 93 space: &SpaceUri, 73 94 auth: SpaceReadAuth<'_>, 95 + target_repo: &str, 74 96 collection: &str, 75 97 rkey: &str, 76 98 ) -> PdsResult<Option<RecordRow>> { 77 - let owner_did_for_read = self.resolve_read_target(space, &auth).await?; 78 - let store = SqlActorStore::open(&self.data_dir, &owner_did_for_read).await?; 99 + self.verify_auth(space, &auth).await?; 100 + self.ensure_space_live(space).await?; 101 + let store = SqlActorStore::open(&self.data_dir, target_repo).await?; 79 102 80 - // §4.4 takedown gate — admin moderation hides the record at read time. 103 + // Takedown gate — admin moderation hides the record at read time. 81 104 if is_record_taken_down(store.pool(), space, collection, rkey).await? { 82 105 return Ok(None); 83 106 } ··· 90 113 .map_err(PdsError::Space) 91 114 } 92 115 93 - /// `listRecords` — paginated listing within a collection. 116 + /// `listRecords` — paginated listing within a collection, or across all 117 + /// collections when `collection` is `None`. 118 + /// 119 + /// When `collection` is `Some`, the call is paginated via the supplied 120 + /// `cursor`. When `collection` is `None`, the reader iterates every 121 + /// collection in the space and concatenates the first `limit` records 122 + /// from each; `cursor` is ignored in this mode (matching the TypeScript 123 + /// PDS behavior). 124 + /// 125 + /// `target_repo` has the same meaning as in [`Self::get_record`]. 94 126 pub async fn list_records( 95 127 &self, 96 128 space: &SpaceUri, 97 129 auth: SpaceReadAuth<'_>, 98 - collection: &str, 130 + target_repo: &str, 131 + collection: Option<&str>, 99 132 cursor: Option<&str>, 100 133 limit: u32, 101 134 ) -> PdsResult<RecordPage> { 102 - let owner_did_for_read = self.resolve_read_target(space, &auth).await?; 103 - let store = SqlActorStore::open(&self.data_dir, &owner_did_for_read).await?; 135 + self.verify_auth(space, &auth).await?; 136 + self.ensure_space_live(space).await?; 137 + let store = SqlActorStore::open(&self.data_dir, target_repo).await?; 104 138 let storage = SqlSpaceRepoStorage::new(store.pool().clone()); 105 139 let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> = 106 140 SpaceRepo::new(space.clone(), storage); 107 - let mut page = repo 108 - .list_records(collection, cursor, limit) 109 - .await 110 - .map_err(PdsError::Space)?; 111 141 112 - // §4.4 takedown filter — drop taken-down rkeys from the page. 113 - // We hit the takedown table once per page rather than per record. 114 - let taken: std::collections::HashSet<String> = 115 - taken_down_rkeys(store.pool(), space, collection).await?; 116 - if !taken.is_empty() { 117 - page.records.retain(|r| !taken.contains(&r.rkey)); 142 + match collection { 143 + Some(coll) => { 144 + let mut page = repo 145 + .list_records(coll, cursor, limit) 146 + .await 147 + .map_err(PdsError::Space)?; 148 + let taken: std::collections::HashSet<String> = 149 + taken_down_rkeys(store.pool(), space, coll).await?; 150 + if !taken.is_empty() { 151 + page.records.retain(|r| !taken.contains(&r.rkey)); 152 + } 153 + Ok(page) 154 + } 155 + None => { 156 + let collections = repo.list_collections().await.map_err(PdsError::Space)?; 157 + let mut all_records = Vec::new(); 158 + for coll in collections { 159 + let mut page = repo 160 + .list_records(&coll, None, limit) 161 + .await 162 + .map_err(PdsError::Space)?; 163 + let taken: std::collections::HashSet<String> = 164 + taken_down_rkeys(store.pool(), space, &coll).await?; 165 + if !taken.is_empty() { 166 + page.records.retain(|r| !taken.contains(&r.rkey)); 167 + } 168 + all_records.append(&mut page.records); 169 + } 170 + Ok(RecordPage { 171 + records: all_records, 172 + cursor: None, 173 + }) 174 + } 118 175 } 119 - Ok(page) 120 176 } 121 177 122 - /// Verify the read auth and return the DID of the per-actor store to read. 123 - /// 124 - /// - `OwnPds { account_did }` → that account. 125 - /// - `SpaceCredential { .. }` → the space owner (after JWT verification). 126 - async fn resolve_read_target( 178 + /// Public entry point to verify a record-read auth against `space` without 179 + /// reading a record. Used by `com.atproto.space.getBlob`, which serves 180 + /// blob bytes under the same auth gate as `getRecord` / `listRecords` 181 + /// (space-credential-space-match OR OAuth/session) but does not go through 182 + /// the record path. 183 + pub async fn verify_read_auth( 127 184 &self, 128 185 space: &SpaceUri, 129 186 auth: &SpaceReadAuth<'_>, 130 - ) -> PdsResult<String> { 187 + ) -> PdsResult<()> { 188 + self.verify_auth(space, auth).await 189 + } 190 + 191 + /// Verify a presented `SpaceCredential` JWT against `space` without reading 192 + /// a record. Resolves the space authority's `#atproto_space` signing key and 193 + /// runs the full credential check (signature + `iss`/`sub`/`exp` bound to 194 + /// `space`), returning [`PdsError::AuthDenied`] on any failure. 195 + /// 196 + /// Used by the host/sync read methods (`getSpace`, `getRepoState`, 197 + /// `listRepoOps`, `listRepos`) to verify a credential-`typ` bearer at the 198 + /// HTTP layer, so a forged, unsigned, expired, or wrong-space credential is 199 + /// rejected rather than admitted on its `typ` string alone. 200 + pub async fn verify_space_credential_for( 201 + &self, 202 + space: &SpaceUri, 203 + token: &str, 204 + ) -> PdsResult<()> { 205 + self.verify_auth(space, &SpaceReadAuth::SpaceCredential { token }) 206 + .await 207 + } 208 + 209 + /// Verify the read auth. For SpaceCredential, performs JWT signature 210 + /// verification against the space authority's `#atproto_space` signing key 211 + /// plus `iss`/`sub`/`exp` checks. The credential's `client_id` is advisory 212 + /// and not enforced. For OwnPds, this is a no-op — the HTTP-layer 213 + /// OAuth/session check already validated the bearer. 214 + async fn verify_auth(&self, space: &SpaceUri, auth: &SpaceReadAuth<'_>) -> PdsResult<()> { 131 215 match auth { 132 - SpaceReadAuth::OwnPds { account_did } => Ok((*account_did).to_string()), 133 - SpaceReadAuth::SpaceCredential { 134 - token, 135 - expected_client_id, 136 - } => { 137 - // Verify with the owner's *public* signing key. The owner is 138 - // an account managed by this PDS (otherwise we could not have 139 - // minted the credential), so we look up the signing-key ref 140 - // from `account` and convert to public form for verification. 141 - let owner_did = &space.owner_did; 142 - let owner_pub = self.owner_public_key(owner_did).await?; 216 + SpaceReadAuth::OwnPds { .. } => Ok(()), 217 + SpaceReadAuth::SpaceCredential { token } => { 218 + let authority_did = &space.space_did; 219 + let authority_pub = self.authority_public_key(authority_did).await?; 143 220 let _payload: SpaceCredential = 144 - verify_space_credential(token, owner_did, space, &owner_pub).map_err(|e| { 145 - PdsError::AuthDenied { 146 - reason: format!("invalid SpaceCredential: {e}"), 147 - } 148 - })?; 149 - // Re-verify the bound client_id matches the HTTP-layer 150 - // expected value (the JWT carries this in payload.client_id; 151 - // verify_space_credential already returns the payload). 152 - let payload: SpaceCredential = 153 - verify_space_credential(token, owner_did, space, &owner_pub).map_err(|e| { 154 - PdsError::AuthDenied { 221 + verify_space_credential(token, authority_did, space, &authority_pub).map_err( 222 + |e| PdsError::AuthDenied { 155 223 reason: format!("invalid SpaceCredential: {e}"), 156 - } 157 - })?; 158 - if &payload.client_id != expected_client_id { 159 - return Err(PdsError::AuthDenied { 160 - reason: format!( 161 - "SpaceCredential clientId mismatch: token={}, expected={}", 162 - payload.client_id, expected_client_id 163 - ), 164 - }); 165 - } 166 - Ok(owner_did.clone()) 224 + }, 225 + )?; 226 + Ok(()) 167 227 } 168 228 } 169 229 } 170 230 171 - /// Resolve an account's atproto signing key in *public* form. Used to 172 - /// verify SpaceCredentials minted by this PDS for one of its owners. 173 - async fn owner_public_key(&self, owner_did: &str) -> PdsResult<KeyData> { 231 + /// Resolve a local authority's space-credential verification key in 232 + /// *public* form. Used to verify SpaceCredentials minted by this PDS for one 233 + /// of its authorities. Per 0016 line 92 the authority's `#atproto_space` 234 + /// verification method MAY coincide with `#atproto`; for accounts this PDS 235 + /// manages it does, so this returns the account's atproto signing key. 236 + async fn authority_public_key(&self, authority_did: &str) -> PdsResult<KeyData> { 174 237 let key_ref: Option<(String,)> = 175 238 sqlx::query_as("SELECT signing_key_ref FROM account WHERE did = ?") 176 - .bind(owner_did) 239 + .bind(authority_did) 177 240 .fetch_optional(self.accounts.pool()) 178 241 .await 179 242 .map_err(|e| PdsError::Storage { ··· 181 244 })?; 182 245 let key_ref = key_ref 183 246 .ok_or_else(|| PdsError::NotFound { 184 - what: format!("account {owner_did} (signing_key_ref)"), 247 + what: format!("account {authority_did} (signing_key_ref)"), 185 248 })? 186 249 .0; 187 250 let private = self.accounts.key_store().get(&key_ref).await?; ··· 192 255 } 193 256 194 257 /// Returns true when a row in `space_record_takedown` matches the 195 - /// `(space, collection, rkey)` triple. Per §4.4. 258 + /// `(space, collection, rkey)` triple. 196 259 pub(crate) async fn is_record_taken_down( 197 260 pool: &sqlx::SqlitePool, 198 261 space: &SpaceUri, ··· 311 374 SpaceReadAuth::OwnPds { 312 375 account_did: "did:plc:owner", 313 376 }, 377 + "did:plc:owner", 314 378 "app.bsky.group.message", 315 379 "abc", 316 380 ) ··· 336 400 SpaceReadAuth::OwnPds { 337 401 account_did: "did:plc:owner", 338 402 }, 339 - "app.bsky.group.message", 403 + "did:plc:owner", 404 + Some("app.bsky.group.message"), 340 405 None, 341 406 10, 342 407 ) ··· 346 411 } 347 412 348 413 #[tokio::test(flavor = "multi_thread")] 414 + async fn list_records_across_collections_when_collection_none() { 415 + let tmp = TempDir::new().unwrap(); 416 + let dir = tmp.path().to_path_buf(); 417 + let manager = fresh_manager(&dir).await; 418 + let uri = test_space(); 419 + let writer = crate::space::writer::SpaceWriter::new(manager.clone(), dir.clone()); 420 + for (collection, rkey) in [ 421 + ("app.bsky.group.message", "m1"), 422 + ("app.bsky.group.like", "l1"), 423 + ] { 424 + writer 425 + .apply_writes( 426 + "did:plc:owner", 427 + &uri, 428 + vec![SpaceWriteOp { 429 + action: SpaceWriteAction::Create, 430 + collection: collection.to_string(), 431 + rkey: rkey.to_string(), 432 + value: Some(serde_json::json!({})), 433 + }], 434 + ) 435 + .await 436 + .unwrap(); 437 + } 438 + 439 + let reader = SpaceReader::new(manager, dir); 440 + let page = reader 441 + .list_records( 442 + &uri, 443 + SpaceReadAuth::OwnPds { 444 + account_did: "did:plc:owner", 445 + }, 446 + "did:plc:owner", 447 + None, 448 + None, 449 + 10, 450 + ) 451 + .await 452 + .unwrap(); 453 + assert_eq!(page.records.len(), 2, "should aggregate across collections"); 454 + assert!( 455 + page.cursor.is_none(), 456 + "cross-collection listing has no cursor" 457 + ); 458 + } 459 + 460 + #[tokio::test(flavor = "multi_thread")] 349 461 async fn space_credential_round_trip() { 350 462 let tmp = TempDir::new().unwrap(); 351 463 let dir = tmp.path().to_path_buf(); ··· 365 477 let token = create_space_credential( 366 478 "did:plc:owner", 367 479 &uri, 368 - "https://app.example/client-metadata.json", 480 + Some("https://app.example/client-metadata.json"), 369 481 &signing_key, 370 482 SPACE_CREDENTIAL_TTL_SECS, 371 483 ) ··· 375 487 let row = reader 376 488 .get_record( 377 489 &uri, 378 - SpaceReadAuth::SpaceCredential { 379 - token: &token, 380 - expected_client_id: "https://app.example/client-metadata.json", 381 - }, 490 + SpaceReadAuth::SpaceCredential { token: &token }, 491 + "did:plc:owner", 382 492 "app.bsky.group.message", 383 493 "abc", 384 494 ) ··· 389 499 } 390 500 391 501 #[tokio::test(flavor = "multi_thread")] 392 - async fn space_credential_wrong_client_rejected() { 502 + async fn space_credential_wrong_space_rejected() { 393 503 let tmp = TempDir::new().unwrap(); 394 504 let dir = tmp.path().to_path_buf(); 395 505 let manager = fresh_manager(&dir).await; ··· 403 513 .await 404 514 .unwrap(); 405 515 let signing_key = manager.key_store().get(&key_ref.0).await.unwrap(); 516 + // Mint a credential bound to a *different* space; the reader must 517 + // reject it against `uri` on the `sub` claim mismatch. 518 + let other = SpaceUri::new( 519 + "did:plc:owner".to_string(), 520 + atproto_space::types::SpaceType::new("app.bsky.group").unwrap(), 521 + atproto_space::types::SpaceKey::new("other").unwrap(), 522 + ); 406 523 let token = create_space_credential( 407 524 "did:plc:owner", 408 - &uri, 409 - "client-A", 525 + &other, 526 + None, 410 527 &signing_key, 411 528 SPACE_CREDENTIAL_TTL_SECS, 412 529 ) ··· 416 533 let result = reader 417 534 .get_record( 418 535 &uri, 419 - SpaceReadAuth::SpaceCredential { 420 - token: &token, 421 - expected_client_id: "client-B", 422 - }, 536 + SpaceReadAuth::SpaceCredential { token: &token }, 537 + "did:plc:owner", 423 538 "app.bsky.group.message", 424 539 "abc", 425 540 ) ··· 428 543 } 429 544 430 545 #[tokio::test(flavor = "multi_thread")] 546 + async fn verify_space_credential_for_accepts_valid_and_rejects_forged() { 547 + let tmp = TempDir::new().unwrap(); 548 + let dir = tmp.path().to_path_buf(); 549 + let manager = fresh_manager(&dir).await; 550 + let uri = test_space(); 551 + seed_record(manager.clone(), dir.clone(), uri.clone()).await; 552 + 553 + let key_ref: (String,) = 554 + sqlx::query_as("SELECT signing_key_ref FROM account WHERE did = ?") 555 + .bind("did:plc:owner") 556 + .fetch_one(manager.pool()) 557 + .await 558 + .unwrap(); 559 + let signing_key = manager.key_store().get(&key_ref.0).await.unwrap(); 560 + let token = create_space_credential( 561 + "did:plc:owner", 562 + &uri, 563 + None, 564 + &signing_key, 565 + SPACE_CREDENTIAL_TTL_SECS, 566 + ) 567 + .unwrap(); 568 + 569 + let reader = SpaceReader::new(manager, dir); 570 + // A genuinely authority-signed credential verifies. 571 + reader 572 + .verify_space_credential_for(&uri, &token) 573 + .await 574 + .unwrap(); 575 + 576 + // A token that merely *classifies* as a credential (correct typ/kid 577 + // header) but carries a zero signature must be rejected. 578 + let header = serde_json::json!({ 579 + "alg": "ES256", 580 + "typ": "atproto-space-credential+jwt", 581 + "kid": "#atproto_space" 582 + }); 583 + let payload = serde_json::json!({ 584 + "iss": "did:plc:owner", 585 + "sub": uri.to_string(), 586 + "iat": 0, 587 + "exp": 9_999_999_999u64, 588 + }); 589 + use base64::Engine as _; 590 + let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; 591 + let forged = format!( 592 + "{}.{}.{}", 593 + b64.encode(serde_json::to_vec(&header).unwrap()), 594 + b64.encode(serde_json::to_vec(&payload).unwrap()), 595 + b64.encode([0u8; 64]), 596 + ); 597 + let result = reader.verify_space_credential_for(&uri, &forged).await; 598 + assert!(matches!(result, Err(PdsError::AuthDenied { .. }))); 599 + } 600 + 601 + #[tokio::test(flavor = "multi_thread")] 431 602 async fn nonexistent_record_returns_none() { 432 603 let tmp = TempDir::new().unwrap(); 433 604 let dir = tmp.path().to_path_buf(); ··· 441 612 SpaceReadAuth::OwnPds { 442 613 account_did: "did:plc:owner", 443 614 }, 615 + "did:plc:owner", 444 616 "app.bsky.group.message", 445 617 "missing", 446 618 ) ··· 449 621 assert!(row.is_none()); 450 622 } 451 623 452 - /// §4.4 takedown gate: a row in `space_record_takedown` hides the 624 + /// Takedown gate: a row in `space_record_takedown` hides the 453 625 /// record from `get_record` even when the underlying `space_record` 454 626 /// row is intact. Re-inserting via `list_records` confirms the 455 627 /// page-level filter is also wired. ··· 486 658 SpaceReadAuth::OwnPds { 487 659 account_did: "did:plc:owner", 488 660 }, 661 + "did:plc:owner", 489 662 "app.bsky.group.message", 490 663 "abc", 491 664 ) ··· 500 673 SpaceReadAuth::OwnPds { 501 674 account_did: "did:plc:owner", 502 675 }, 503 - "app.bsky.group.message", 676 + "did:plc:owner", 677 + Some("app.bsky.group.message"), 504 678 None, 505 679 10, 506 680 )
+53 -10
crates/atproto-pds/src/space/recipient.rs
··· 1 1 //! Consumer recipient discovery for `getSpaceCredential`. 2 2 //! 3 - //! when a consumer service swaps a `MemberGrant` 4 - //! for a `SpaceCredential`, the owner's PDS must record the consumer's 3 + //! When a consumer service swaps a delegation token 4 + //! for a `SpaceCredential`, the authority's PDS must record the consumer's 5 5 //! `(service_did, service_endpoint)` so the notifier knows where to fan 6 6 //! out future commits. 7 7 //! ··· 26 26 use crate::errors::{PdsError, PdsResult}; 27 27 use atproto_identity::resolve::resolve_handle_http; 28 28 29 - /// Resolved consumer identity, derivable from a MemberGrant + its 30 - /// bearing `client_id`. 29 + /// Resolved consumer identity, derivable from a delegation token's issuer + 30 + /// the attested `client_id`. 31 31 #[derive(Debug, Clone, PartialEq, Eq)] 32 32 pub struct ResolvedRecipient { 33 33 /// Consumer service DID (e.g. `did:web:appview.example`). ··· 104 104 did: &str, 105 105 plc_directory_hostname: Option<&str>, 106 106 ) -> PdsResult<Option<String>> { 107 + let Some(document) = fetch_did_document(http, did, plc_directory_hostname).await? else { 108 + return Ok(None); 109 + }; 110 + Ok(document.pds_endpoints().first().map(|s| s.to_string())) 111 + } 112 + 113 + /// Fetch a DID document for `did:plc:`/`did:web:`, returning `None` for 114 + /// unsupported methods (e.g. `did:webvh`). 115 + async fn fetch_did_document( 116 + http: &reqwest::Client, 117 + did: &str, 118 + plc_directory_hostname: Option<&str>, 119 + ) -> PdsResult<Option<atproto_identity::model::Document>> { 107 120 use atproto_identity::plc::query as plc_query; 108 121 use atproto_identity::web::query as web_query; 109 - let document = if let Some(stripped) = did.strip_prefix("did:plc:") { 122 + let document = if did.starts_with("did:plc:") { 110 123 let host = plc_directory_hostname.unwrap_or("plc.directory"); 111 - let _ = stripped; 112 124 plc_query(http, host, did) 113 125 .await 114 126 .map_err(|e| PdsError::Storage { ··· 119 131 reason: format!("web query: {e}"), 120 132 })? 121 133 } else { 122 - // did:webvh and other methods aren't yet covered by the 123 - // recipient resolver — see. Surface a soft 124 - // failure so the stub kicks in. 134 + // did:webvh and other methods aren't yet covered. 135 + return Ok(None); 136 + }; 137 + Ok(Some(document)) 138 + } 139 + 140 + /// Resolve a service identifier of the form `<did>#<fragment>` (e.g. 141 + /// `did:web:example.com#forum`) to the service's base endpoint URL by fetching 142 + /// the DID document and matching the service entry by fragment. 143 + /// 144 + /// Returns `Ok(None)` when the DID method is unsupported, the document has no 145 + /// matching service entry, or the identifier is malformed. 146 + /// 147 + /// # Errors 148 + /// Returns [`PdsError::Storage`] on a DID-document fetch failure. 149 + pub async fn resolve_service_endpoint( 150 + http: &reqwest::Client, 151 + service_id: &str, 152 + plc_directory_hostname: Option<&str>, 153 + ) -> PdsResult<Option<String>> { 154 + let (did, fragment) = match service_id.split_once('#') { 155 + Some((did, frag)) => (did, Some(frag)), 156 + None => (service_id, None), 157 + }; 158 + let Some(document) = fetch_did_document(http, did, plc_directory_hostname).await? else { 125 159 return Ok(None); 126 160 }; 127 - Ok(document.pds_endpoints().first().map(|s| s.to_string())) 161 + // Match the service whose `id` ends with the requested fragment 162 + // (DID documents render service ids as `<did>#frag` or `#frag`). 163 + let endpoint = document.service.iter().find_map(|svc| match fragment { 164 + Some(frag) => { 165 + let svc_frag = svc.id.rsplit('#').next().unwrap_or(svc.id.as_str()); 166 + (svc_frag == frag).then(|| svc.service_endpoint.clone()) 167 + } 168 + None => Some(svc.service_endpoint.clone()), 169 + }); 170 + Ok(endpoint) 128 171 } 129 172 130 173 /// Build the fallback recipient — used both as the result on failure
+636 -160
crates/atproto-pds/src/space/service.rs
··· 1 1 //! `SpaceService` — owner-side management endpoints. 2 2 //! 3 3 //! `createSpace`, `getSpace`, `listSpaces`, `addMember`, `removeMember`, 4 - //! `getMembers`. Operates against the per-actor SQLite store. 4 + //! `listMembers`. Operates against the per-actor SQLite store. 5 5 6 6 use crate::account::AccountManager; 7 7 use crate::actor_store::sql::{SqlActorStore, SqlSpaceMembersStorage}; 8 8 use crate::errors::{PdsError, PdsResult}; 9 9 use crate::realm::PdsSetHash; 10 - use crate::space::notify::{NotifyMembershipPayload, enqueue_membership}; 11 - use atproto_space::commit::{CommitScope, SpaceContext, create_commit}; 12 - use atproto_space::set_hash::SetHash; 10 + use crate::space::config::{SpaceConfig, SpaceConfigPatch, ensure_not_deleted}; 13 11 use atproto_space::space_members::{MemberOp, MemberOpAction, SpaceMembers}; 14 12 use atproto_space::storage::{MemberPage, MemberState}; 15 13 use atproto_space::types::{SpaceKey, SpaceType, SpaceUri}; ··· 21 19 /// Space-management orchestrator. 22 20 pub struct SpaceService { 23 21 data_dir: PathBuf, 24 - /// Optional accounts manager — required for `notifyMembership` fan-out 25 - /// (signing the membership commit + enqueuing into the shared queue). 26 - /// Test fixtures pass `None`; production wiring in `bin/pds.rs` always 27 - /// supplies an instance. 28 - accounts: Option<Arc<AccountManager>>, 29 22 } 30 23 31 24 impl SpaceService { 32 - /// Construct without account-manager wiring. Suitable for tests that 33 - /// don't exercise membership-notification fan-out; `add_member` / 34 - /// `remove_member` will silently skip the `notifyMembership` enqueue. 25 + /// Construct a space-management orchestrator rooted at `data_dir`. 35 26 #[must_use] 36 27 pub fn new(data_dir: PathBuf) -> Self { 37 - Self { 38 - data_dir, 39 - accounts: None, 40 - } 28 + Self { data_dir } 41 29 } 42 30 43 - /// Construct with account-manager wiring for full `notifyMembership` 44 - /// fan-out. Production binaries call this; the manager backs both the 45 - /// signing-key lookup (to sign the membership commit) and the shared 46 - /// notify-attempt queue. 31 + /// Construct with account-manager wiring. The 0016 Permissioned Data draft 32 + /// has no membership-notification flow, so the manager is no longer needed 33 + /// for member management; this constructor is retained for call-site 34 + /// compatibility and is equivalent to [`SpaceService::new`]. 47 35 #[must_use] 48 - pub fn with_accounts(data_dir: PathBuf, accounts: Arc<AccountManager>) -> Self { 49 - Self { 50 - data_dir, 51 - accounts: Some(accounts), 52 - } 36 + pub fn with_accounts(data_dir: PathBuf, _accounts: Arc<AccountManager>) -> Self { 37 + Self { data_dir } 53 38 } 54 39 55 40 /// `createSpace` — owner-side. Inserts a `space` row marked `is_owner=1` 56 - /// in the owner's per-actor store and seeds an empty member-list state. 57 - /// Idempotent: re-creating the same URI yields the existing row. 41 + /// in the owner's per-actor store, seeds the member-list state, and adds 42 + /// the owner as the first member of the space. Idempotent: re-creating the 43 + /// same URI is a no-op on the second call (the duplicate member-add is 44 + /// skipped via the `INSERT OR IGNORE` semantics in `SpaceMembersStorage`). 58 45 pub async fn create_space( 59 46 &self, 60 47 owner_did: &str, 61 48 space_type: &str, 62 49 space_key: &str, 50 + config: SpaceConfig, 63 51 ) -> PdsResult<SpaceInfo> { 64 52 let space_type = SpaceType::new(space_type).map_err(space_err)?; 65 53 let space_key = SpaceKey::new(space_key).map_err(space_err)?; ··· 67 55 let store = SqlActorStore::open(&self.data_dir, owner_did).await?; 68 56 69 57 let now = Utc::now().to_rfc3339(); 70 - sqlx::query( 71 - "INSERT INTO space (uri, is_owner, is_member, created_at) VALUES (?, 1, 1, ?) 58 + let mint_policy = config.mint_policy.as_str().to_string(); 59 + let app_access = config.app_access.to_storage_json()?; 60 + // Use INSERT … ON CONFLICT here so the existing `created_at` is 61 + // preserved on re-creation; the conflict branch reasserts the 62 + // owner/member flags in case they were unset by a prior takedown. 63 + let inserted = sqlx::query( 64 + "INSERT INTO space (uri, is_owner, is_member, created_at, mint_policy, app_access, managing_app) 65 + VALUES (?, 1, 1, ?, ?, ?, ?) 72 66 ON CONFLICT(uri) DO UPDATE SET is_owner = 1, is_member = 1", 73 67 ) 74 68 .bind(uri.to_string()) 75 69 .bind(&now) 70 + .bind(&mint_policy) 71 + .bind(&app_access) 72 + .bind(config.managing_app.as_deref()) 76 73 .execute(store.pool()) 77 74 .await 78 75 .map_err(|e| PdsError::Storage { ··· 90 87 reason: format!("createSpace seed member_state: {e}"), 91 88 })?; 92 89 90 + // On first creation, add the owner as the initial member so the 91 + // member set contains them from t=0. Skipped on idempotent re-create 92 + // (rows_affected == 0) since a second `Add` for the same DID would 93 + // fail the SpaceMembers duplicate-add check. 94 + if inserted.rows_affected() > 0 { 95 + let storage = SqlSpaceMembersStorage::new(store.pool().clone()); 96 + let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> = 97 + SpaceMembers::new(uri.clone(), storage); 98 + let prepared = members 99 + .format_commit(&[MemberOp { 100 + action: MemberOpAction::Add, 101 + did: owner_did.to_string(), 102 + }]) 103 + .await 104 + .map_err(space_err)?; 105 + members.apply_commit(prepared).await.map_err(space_err)?; 106 + } 107 + 108 + // Re-read created_at so the response reflects the persisted row even 109 + // on idempotent re-create (where the INSERT was a no-op). 110 + let created_at: String = sqlx::query_scalar("SELECT created_at FROM space WHERE uri = ?") 111 + .bind(uri.to_string()) 112 + .fetch_one(store.pool()) 113 + .await 114 + .map_err(|e| PdsError::Storage { 115 + reason: format!("createSpace re-read created_at: {e}"), 116 + })?; 117 + 93 118 Ok(SpaceInfo { 94 119 uri: uri.to_string(), 95 120 is_owner: true, 96 121 is_member: true, 97 - created_at: now, 122 + created_at, 123 + }) 124 + } 125 + 126 + /// `getSpace` — return `{uri, config}` for a non-deleted space. 127 + /// 128 + /// The `config` is the `com.atproto.simplespace.defs#spaceConfig` open 129 + /// union (mintPolicy + appAccess + optional managingApp). A tombstoned 130 + /// space (`deleted_at IS NOT NULL`) or a missing row both yield 131 + /// [`PdsError::SpaceNotFound`]. 132 + pub async fn get_space(&self, viewer_did: &str, uri: &SpaceUri) -> PdsResult<GetSpaceOutput> { 133 + let store = SqlActorStore::open(&self.data_dir, viewer_did).await?; 134 + let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as( 135 + "SELECT mint_policy, app_access, managing_app, deleted_at FROM space WHERE uri = ?", 136 + ) 137 + .bind(uri.to_string()) 138 + .fetch_optional(store.pool()) 139 + .await 140 + .map_err(|e| PdsError::Storage { 141 + reason: format!("getSpace: {e}"), 142 + })?; 143 + let (mint_policy, app_access, managing_app, deleted_at) = 144 + row.ok_or_else(|| PdsError::SpaceNotFound { 145 + uri: uri.to_string(), 146 + })?; 147 + if deleted_at.is_some() { 148 + return Err(PdsError::SpaceNotFound { 149 + uri: uri.to_string(), 150 + }); 151 + } 152 + let config = SpaceConfig::from_columns(&mint_policy, &app_access, managing_app)?; 153 + Ok(GetSpaceOutput { 154 + uri: uri.to_string(), 155 + config: config.to_wire(), 98 156 }) 99 157 } 100 158 101 - /// `getSpace` — return the row by URI. 102 - pub async fn get_space( 159 + /// Load the mint-time authorization inputs for `getSpaceCredential`: 160 + /// the space's persisted [`SpaceConfig`], whether the row exists, whether 161 + /// it has been tombstoned, and whether the requesting `member_did` is in 162 + /// the space's member list. 163 + /// 164 + /// Unlike [`Self::get_space`], this does **not** collapse the deleted and 165 + /// missing states — the credential-mint handler must distinguish 166 + /// `SpaceNotFound` from `SpaceDeleted` per the `getSpaceCredential` 167 + /// lexicon. The query runs against the space authority's own per-actor 168 + /// store (`uri.space_did`). 169 + /// 170 + /// # Errors 171 + /// Returns [`PdsError::Storage`] on a query failure. 172 + pub async fn load_mint_authz_inputs( 103 173 &self, 104 - viewer_did: &str, 105 174 uri: &SpaceUri, 106 - ) -> PdsResult<Option<SpaceInfo>> { 107 - let store = SqlActorStore::open(&self.data_dir, viewer_did).await?; 108 - let row: Option<(i64, i64, String)> = 109 - sqlx::query_as("SELECT is_owner, is_member, created_at FROM space WHERE uri = ?") 175 + member_did: &str, 176 + ) -> PdsResult<MintAuthzInputs> { 177 + let store = SqlActorStore::open(&self.data_dir, &uri.space_did).await?; 178 + let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as( 179 + "SELECT mint_policy, app_access, managing_app, deleted_at FROM space WHERE uri = ?", 180 + ) 181 + .bind(uri.to_string()) 182 + .fetch_optional(store.pool()) 183 + .await 184 + .map_err(|e| PdsError::Storage { 185 + reason: format!("load_mint_authz_inputs: {e}"), 186 + })?; 187 + 188 + let Some((mint_policy, app_access, managing_app, deleted_at)) = row else { 189 + return Ok(MintAuthzInputs { 190 + found: false, 191 + deleted: false, 192 + config: SpaceConfig::default(), 193 + is_member: false, 194 + }); 195 + }; 196 + if deleted_at.is_some() { 197 + return Ok(MintAuthzInputs { 198 + found: true, 199 + deleted: true, 200 + config: SpaceConfig::default(), 201 + is_member: false, 202 + }); 203 + } 204 + 205 + let config = SpaceConfig::from_columns(&mint_policy, &app_access, managing_app)?; 206 + let is_member: Option<i64> = 207 + sqlx::query_scalar("SELECT 1 FROM space_member WHERE space = ? AND did = ? LIMIT 1") 110 208 .bind(uri.to_string()) 209 + .bind(member_did) 111 210 .fetch_optional(store.pool()) 112 211 .await 113 212 .map_err(|e| PdsError::Storage { 114 - reason: format!("getSpace: {e}"), 213 + reason: format!("load_mint_authz_inputs member check: {e}"), 115 214 })?; 116 - Ok(row.map(|(is_owner, is_member, created_at)| SpaceInfo { 117 - uri: uri.to_string(), 118 - is_owner: is_owner != 0, 119 - is_member: is_member != 0, 120 - created_at, 121 - })) 215 + 216 + Ok(MintAuthzInputs { 217 + found: true, 218 + deleted: false, 219 + config, 220 + is_member: is_member.is_some(), 221 + }) 222 + } 223 + 224 + /// `updateSpace` — owner-only. Applies a [`SpaceConfigPatch`]; omitted 225 + /// fields are left unchanged and `managingApp == ""` clears the column to 226 + /// `NULL`. Fails with [`PdsError::SpaceNotFound`] for a missing or 227 + /// tombstoned space and [`PdsError::NotSpaceOwner`] when the caller is not 228 + /// the space authority. 229 + pub async fn update_space( 230 + &self, 231 + owner_did: &str, 232 + uri: &SpaceUri, 233 + patch: SpaceConfigPatch, 234 + ) -> PdsResult<()> { 235 + if uri.space_did != owner_did { 236 + return Err(PdsError::NotSpaceOwner { 237 + uri: uri.to_string(), 238 + }); 239 + } 240 + let store = SqlActorStore::open(&self.data_dir, owner_did).await?; 241 + // Confirm the row exists and is not tombstoned before mutating. 242 + let exists: Option<Option<String>> = 243 + sqlx::query_scalar("SELECT deleted_at FROM space WHERE uri = ?") 244 + .bind(uri.to_string()) 245 + .fetch_optional(store.pool()) 246 + .await 247 + .map_err(|e| PdsError::Storage { 248 + reason: format!("updateSpace lookup: {e}"), 249 + })?; 250 + match exists { 251 + Some(None) => {} 252 + _ => { 253 + return Err(PdsError::SpaceNotFound { 254 + uri: uri.to_string(), 255 + }); 256 + } 257 + } 258 + 259 + if let Some(policy) = patch.mint_policy { 260 + sqlx::query("UPDATE space SET mint_policy = ? WHERE uri = ?") 261 + .bind(policy.as_str()) 262 + .bind(uri.to_string()) 263 + .execute(store.pool()) 264 + .await 265 + .map_err(|e| PdsError::Storage { 266 + reason: format!("updateSpace mint_policy: {e}"), 267 + })?; 268 + } 269 + if let Some(ref access) = patch.app_access { 270 + sqlx::query("UPDATE space SET app_access = ? WHERE uri = ?") 271 + .bind(access.to_storage_json()?) 272 + .bind(uri.to_string()) 273 + .execute(store.pool()) 274 + .await 275 + .map_err(|e| PdsError::Storage { 276 + reason: format!("updateSpace app_access: {e}"), 277 + })?; 278 + } 279 + if let Some(ref app) = patch.managing_app { 280 + // Empty string clears to NULL; any other value sets it. 281 + let value = if app.is_empty() { 282 + None 283 + } else { 284 + Some(app.as_str()) 285 + }; 286 + sqlx::query("UPDATE space SET managing_app = ? WHERE uri = ?") 287 + .bind(value) 288 + .bind(uri.to_string()) 289 + .execute(store.pool()) 290 + .await 291 + .map_err(|e| PdsError::Storage { 292 + reason: format!("updateSpace managing_app: {e}"), 293 + })?; 294 + } 295 + Ok(()) 296 + } 297 + 298 + /// `deleteSpace` — owner-only tombstone. Sets `deleted_at`; after this all 299 + /// reads and writes against the space fail with 300 + /// [`PdsError::SpaceNotFound`]. Idempotent on an already-deleted space. 301 + pub async fn delete_space(&self, owner_did: &str, uri: &SpaceUri) -> PdsResult<()> { 302 + if uri.space_did != owner_did { 303 + return Err(PdsError::NotSpaceOwner { 304 + uri: uri.to_string(), 305 + }); 306 + } 307 + let store = SqlActorStore::open(&self.data_dir, owner_did).await?; 308 + let now = Utc::now().to_rfc3339(); 309 + let affected = 310 + sqlx::query("UPDATE space SET deleted_at = ? WHERE uri = ? AND deleted_at IS NULL") 311 + .bind(&now) 312 + .bind(uri.to_string()) 313 + .execute(store.pool()) 314 + .await 315 + .map_err(|e| PdsError::Storage { 316 + reason: format!("deleteSpace: {e}"), 317 + })?; 318 + if affected.rows_affected() == 0 { 319 + // Either no such row, or already deleted. Confirm existence so we 320 + // return SpaceNotFound only for a genuinely absent space. 321 + let exists: Option<String> = sqlx::query_scalar("SELECT uri FROM space WHERE uri = ?") 322 + .bind(uri.to_string()) 323 + .fetch_optional(store.pool()) 324 + .await 325 + .map_err(|e| PdsError::Storage { 326 + reason: format!("deleteSpace recheck: {e}"), 327 + })?; 328 + if exists.is_none() { 329 + return Err(PdsError::SpaceNotFound { 330 + uri: uri.to_string(), 331 + }); 332 + } 333 + } 334 + 335 + // Per the 0016 Permissioned Data draft (line 363): "The authority also 336 + // deletes its own repo in the space." The `space` row itself is kept as 337 + // a tombstone (repo-host flag behavior, spec line 365 — the host flags 338 + // rather than erases), but the authority's *own* repo data within the 339 + // space (records, repo set-hash state, and the record oplog) is erased. 340 + // 341 + // Note: spec line 367's "a syncer should delete every copy" is a 342 + // *syncer-role* behavior, not applicable to this PDS acting as the 343 + // repo-host/authority; see `notify_space_deleted` for the recipient side. 344 + for table in ["space_record", "space_record_oplog", "space_repo"] { 345 + sqlx::query(&format!("DELETE FROM {table} WHERE space = ?")) 346 + .bind(uri.to_string()) 347 + .execute(store.pool()) 348 + .await 349 + .map_err(|e| PdsError::Storage { 350 + reason: format!("deleteSpace purge {table}: {e}"), 351 + })?; 352 + } 353 + Ok(()) 354 + } 355 + 356 + /// Whether `did` is a member of `space` (reference `store.space.isMember`). 357 + /// 358 + /// Membership is the `space_member` table; the space owner is implicitly a 359 + /// member (owner-as-member). Used by the owner-side `notifyWrite` fan-out to 360 + /// reject notifications from non-members. A missing or tombstoned space 361 + /// yields `false`. 362 + pub async fn is_member(&self, uri: &SpaceUri, did: &str) -> PdsResult<bool> { 363 + if uri.space_did == did { 364 + return Ok(true); 365 + } 366 + let store = SqlActorStore::open(&self.data_dir, &uri.space_did).await?; 367 + let row: Option<i64> = 368 + sqlx::query_scalar("SELECT 1 FROM space_member WHERE space = ? AND did = ? LIMIT 1") 369 + .bind(uri.to_string()) 370 + .bind(did) 371 + .fetch_optional(store.pool()) 372 + .await 373 + .map_err(|e| PdsError::Storage { 374 + reason: format!("is_member: {e}"), 375 + })?; 376 + Ok(row.is_some()) 122 377 } 123 378 124 379 /// `listSpaces` — paginated listing for a viewer DID. `filter` is one of ··· 132 387 ) -> PdsResult<Vec<SpaceInfo>> { 133 388 let store = SqlActorStore::open(&self.data_dir, viewer_did).await?; 134 389 let limit = limit.clamp(1, 100); 135 - let mut clauses: Vec<String> = Vec::new(); 390 + // Tombstoned spaces are never listed. 391 + let mut clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()]; 136 392 match filter { 137 393 "owned" => clauses.push("is_owner = 1".to_string()), 138 394 "member" => clauses.push("is_member = 1".to_string()), ··· 181 437 182 438 /// `addMember` — owner-side. Atomic via `SpaceMembers::format_commit` + 183 439 /// `apply_commit`. Caller is responsible for verifying that 184 - /// `caller_did == uri.owner_did` (auth check) before invoking. 440 + /// `caller_did == uri.space_did` (auth check) before invoking. 185 441 pub async fn add_member( 186 442 &self, 187 443 owner_did: &str, ··· 204 460 } 205 461 206 462 /// Shared body of `add_member` / `remove_member`. Formats + applies the 207 - /// commit, then (when an `AccountManager` is wired) signs the commit and 208 - /// enqueues a `notifyMembership` row per registered recipient. 463 + /// member-list change against the owner's per-actor store. 464 + /// 465 + /// The 0016 Permissioned Data draft has no member commits or 466 + /// membership-notification flow: the member list is plain owner-managed 467 + /// state backing `mintPolicy=member-list`, not a signed-commit log. 209 468 async fn apply_member_op( 210 469 &self, 211 470 owner_did: &str, ··· 213 472 action: MemberOpAction, 214 473 target_did: &str, 215 474 ) -> PdsResult<()> { 216 - if uri.owner_did != owner_did { 217 - return Err(PdsError::AuthDenied { 218 - reason: format!( 219 - "{owner_did} is not the owner of {} (owner is {})", 220 - uri, uri.owner_did 221 - ), 475 + if uri.space_did != owner_did { 476 + return Err(PdsError::NotSpaceOwner { 477 + uri: uri.to_string(), 222 478 }); 223 479 } 224 480 let store = SqlActorStore::open(&self.data_dir, owner_did).await?; 481 + ensure_not_deleted(store.pool(), uri).await?; 225 482 let storage = SqlSpaceMembersStorage::new(store.pool().clone()); 226 483 let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> = 227 484 SpaceMembers::new(uri.clone(), storage); ··· 232 489 }]) 233 490 .await 234 491 .map_err(space_err)?; 235 - 236 - let action_str = match action { 237 - MemberOpAction::Add => "add", 238 - MemberOpAction::Remove => "remove", 239 - }; 240 - let rev = prepared.rev.clone(); 241 - let set_hash_digest = prepared.storage_commit.new_set_hash.clone(); 242 - 243 - // Drop the storage borrow before any await on the notifier path. 244 492 members.apply_commit(prepared).await.map_err(space_err)?; 245 - 246 - // `notifyMembership` fan-out — only when wiring is complete. 247 - if let Some(ref accounts) = self.accounts 248 - && let Err(e) = self 249 - .enqueue_membership_notification( 250 - accounts, 251 - uri, 252 - action_str, 253 - target_did, 254 - &rev, 255 - &set_hash_digest, 256 - ) 257 - .await 258 - { 259 - tracing::warn!( 260 - error = ?e, 261 - space = %uri, 262 - action = action_str, 263 - member = target_did, 264 - "notifyMembership enqueue failed; recipients catch up via getMemberOplog" 265 - ); 266 - } 267 - 268 493 Ok(()) 269 494 } 270 495 271 - /// Sign a membership commit with the owner's atproto signing key and 272 - /// enqueue one `notify_attempt` per registered recipient. 273 - async fn enqueue_membership_notification( 274 - &self, 275 - accounts: &Arc<AccountManager>, 276 - uri: &SpaceUri, 277 - action_str: &str, 278 - target_did: &str, 279 - rev: &str, 280 - set_hash_digest: &[u8], 281 - ) -> PdsResult<()> { 282 - // Resolve the owner's signing key. 283 - let key_ref: Option<(String,)> = 284 - sqlx::query_as("SELECT signing_key_ref FROM account WHERE did = ?") 285 - .bind(&uri.owner_did) 286 - .fetch_optional(accounts.pool()) 287 - .await 288 - .map_err(|e| PdsError::Storage { 289 - reason: format!("lookup signing_key_ref: {e}"), 290 - })?; 291 - let key_ref = key_ref 292 - .ok_or_else(|| PdsError::NotFound { 293 - what: format!("account {} has no signing_key_ref", uri.owner_did), 294 - })? 295 - .0; 296 - let signing_key = accounts.key_store().get(&key_ref).await?; 297 - 298 - // Build the SpaceContext — `userDid` is the owner since they're the 299 - // one mutating the member list (owner is the only 300 - // writer of member-list commits). 301 - let context = SpaceContext { 302 - space_did: uri.owner_did.clone(), 303 - space_type: uri.space_type.to_string(), 304 - space_key: uri.space_key.to_string(), 305 - user_did: uri.owner_did.clone(), 306 - scope: CommitScope::Members, 307 - rev: rev.to_string(), 308 - }; 309 - let set_hash = PdsSetHash::from_digest(set_hash_digest).map_err(PdsError::Space)?; 310 - let signed = create_commit(&set_hash, &context, &signing_key).map_err(PdsError::Space)?; 311 - 312 - let payload = NotifyMembershipPayload { 313 - space: uri.to_string(), 314 - action: action_str.to_string(), 315 - member: target_did.to_string(), 316 - commit: signed, 317 - }; 318 - enqueue_membership(accounts.pool(), &self.data_dir, uri, &payload).await?; 319 - Ok(()) 320 - } 321 - 322 - /// `getMembers` — paginated. 496 + /// `listMembers` — paginated. 323 497 pub async fn list_members( 324 498 &self, 325 499 owner_did: &str, ··· 327 501 cursor: Option<&str>, 328 502 limit: u32, 329 503 ) -> PdsResult<MemberPage> { 330 - if uri.owner_did != owner_did { 331 - return Err(PdsError::AuthDenied { 332 - reason: format!("{owner_did} is not the owner of {uri}"), 504 + if uri.space_did != owner_did { 505 + return Err(PdsError::NotSpaceOwner { 506 + uri: uri.to_string(), 333 507 }); 334 508 } 335 509 let store = SqlActorStore::open(&self.data_dir, owner_did).await?; 510 + ensure_not_deleted(store.pool(), uri).await?; 336 511 let storage = SqlSpaceMembersStorage::new(store.pool().clone()); 337 512 let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> = 338 513 SpaceMembers::new(uri.clone(), storage); ··· 341 516 342 517 /// Read the member-list commitment. 343 518 pub async fn member_state(&self, owner_did: &str, uri: &SpaceUri) -> PdsResult<MemberState> { 344 - if uri.owner_did != owner_did { 345 - return Err(PdsError::AuthDenied { 346 - reason: format!("{owner_did} is not the owner of {uri}"), 519 + if uri.space_did != owner_did { 520 + return Err(PdsError::NotSpaceOwner { 521 + uri: uri.to_string(), 347 522 }); 348 523 } 349 524 let store = SqlActorStore::open(&self.data_dir, owner_did).await?; ··· 354 529 } 355 530 } 356 531 357 - /// Lexicon-shape of a space row. 532 + /// Internal view of a space row for `createSpace` / `listSpaces` (viewer 533 + /// relationship + creation time). Not the `getSpace` wire shape — see 534 + /// [`GetSpaceOutput`] for that. 358 535 #[derive(Debug, Clone, Serialize, Deserialize)] 359 536 pub struct SpaceInfo { 360 - /// Full `ats://` URI. 537 + /// Full space URI. 361 538 pub uri: String, 362 539 /// Whether the viewer is the owner. 363 540 #[serde(rename = "isOwner")] ··· 370 547 pub created_at: String, 371 548 } 372 549 550 + /// `com.atproto.space.getSpace` output: the space URI plus the open-union 551 + /// `config` (a `com.atproto.simplespace.defs#spaceConfig` wire value). 552 + #[derive(Debug, Clone, Serialize, Deserialize)] 553 + pub struct GetSpaceOutput { 554 + /// URI of the space. 555 + pub uri: String, 556 + /// Implementation-specific configuration union (carries `$type`). 557 + pub config: serde_json::Value, 558 + } 559 + 560 + /// Mint-time authorization inputs loaded by 561 + /// [`SpaceService::load_mint_authz_inputs`]. 562 + #[derive(Debug, Clone)] 563 + pub struct MintAuthzInputs { 564 + /// Whether a `space` row exists for the URI in the authority's store. 565 + pub found: bool, 566 + /// Whether the existing row is tombstoned (`deleted_at IS NOT NULL`). 567 + pub deleted: bool, 568 + /// The persisted space configuration (default when missing/deleted). 569 + pub config: SpaceConfig, 570 + /// Whether the requesting member DID is in the space member list. 571 + pub is_member: bool, 572 + } 573 + 373 574 fn space_err(err: atproto_space::SpaceError) -> PdsError { 374 575 PdsError::Space(err) 375 576 } ··· 389 590 async fn create_then_get_space() { 390 591 let (svc, _tmp) = fresh_service().await; 391 592 let info = svc 392 - .create_space("did:plc:owner", "app.bsky.group", "default") 593 + .create_space( 594 + "did:plc:owner", 595 + "app.bsky.group", 596 + "default", 597 + SpaceConfig::default(), 598 + ) 393 599 .await 394 600 .unwrap(); 395 601 assert!(info.is_owner); 396 602 assert!(info.is_member); 397 603 398 604 let uri = info.uri.parse::<SpaceUri>().unwrap(); 399 - let got = svc.get_space("did:plc:owner", &uri).await.unwrap().unwrap(); 605 + let got = svc.get_space("did:plc:owner", &uri).await.unwrap(); 400 606 assert_eq!(got.uri, info.uri); 607 + // Defaults surface as member-list + #open. 608 + assert_eq!(got.config["mintPolicy"], "member-list"); 609 + assert_eq!( 610 + got.config["appAccess"]["$type"], 611 + crate::space::config::APP_ACCESS_OPEN_TYPE 612 + ); 613 + } 614 + 615 + #[tokio::test(flavor = "multi_thread")] 616 + async fn update_then_get_space_reflects_config() { 617 + let (svc, _tmp) = fresh_service().await; 618 + let info = svc 619 + .create_space( 620 + "did:plc:owner", 621 + "app.bsky.group", 622 + "default", 623 + SpaceConfig::default(), 624 + ) 625 + .await 626 + .unwrap(); 627 + let uri = info.uri.parse::<SpaceUri>().unwrap(); 628 + 629 + let patch = SpaceConfigPatch { 630 + mint_policy: Some(crate::space::config::MintPolicy::Public), 631 + app_access: Some(crate::space::config::AppAccess::AllowList { 632 + allowed: vec!["c1".to_string()], 633 + }), 634 + managing_app: Some("did:web:m.example#svc".to_string()), 635 + }; 636 + svc.update_space("did:plc:owner", &uri, patch) 637 + .await 638 + .unwrap(); 639 + 640 + let got = svc.get_space("did:plc:owner", &uri).await.unwrap(); 641 + assert_eq!(got.config["mintPolicy"], "public"); 642 + assert_eq!(got.config["managingApp"], "did:web:m.example#svc"); 643 + assert_eq!( 644 + got.config["appAccess"]["$type"], 645 + crate::space::config::APP_ACCESS_ALLOW_LIST_TYPE 646 + ); 647 + 648 + // Clearing managingApp with empty string drops the field. 649 + let clear = SpaceConfigPatch { 650 + managing_app: Some(String::new()), 651 + ..Default::default() 652 + }; 653 + svc.update_space("did:plc:owner", &uri, clear) 654 + .await 655 + .unwrap(); 656 + let got = svc.get_space("did:plc:owner", &uri).await.unwrap(); 657 + assert!(got.config.get("managingApp").is_none()); 658 + } 659 + 660 + #[tokio::test(flavor = "multi_thread")] 661 + async fn load_mint_authz_inputs_reports_membership_and_config() { 662 + let (svc, _tmp) = fresh_service().await; 663 + let info = svc 664 + .create_space( 665 + "did:plc:owner", 666 + "app.bsky.group", 667 + "default", 668 + SpaceConfig { 669 + mint_policy: crate::space::config::MintPolicy::MemberList, 670 + app_access: crate::space::config::AppAccess::AllowList { 671 + allowed: vec!["https://app.example".to_string()], 672 + }, 673 + managing_app: None, 674 + }, 675 + ) 676 + .await 677 + .unwrap(); 678 + let uri = info.uri.parse::<SpaceUri>().unwrap(); 679 + 680 + // Owner is auto-added as the first member. 681 + let owner_in = svc 682 + .load_mint_authz_inputs(&uri, "did:plc:owner") 683 + .await 684 + .unwrap(); 685 + assert!(owner_in.found); 686 + assert!(!owner_in.deleted); 687 + assert!(owner_in.is_member); 688 + assert_eq!( 689 + owner_in.config.mint_policy, 690 + crate::space::config::MintPolicy::MemberList 691 + ); 692 + 693 + // A stranger is not a member. 694 + let stranger = svc 695 + .load_mint_authz_inputs(&uri, "did:plc:stranger") 696 + .await 697 + .unwrap(); 698 + assert!(stranger.found); 699 + assert!(!stranger.is_member); 700 + } 701 + 702 + #[tokio::test(flavor = "multi_thread")] 703 + async fn load_mint_authz_inputs_missing_space_not_found() { 704 + let (svc, _tmp) = fresh_service().await; 705 + // No createSpace — the owner store has no row for this URI. 706 + let uri: SpaceUri = "ats://did:plc:owner/app.bsky.group/missing" 707 + .parse() 708 + .unwrap(); 709 + let inputs = svc 710 + .load_mint_authz_inputs(&uri, "did:plc:owner") 711 + .await 712 + .unwrap(); 713 + assert!(!inputs.found); 714 + assert!(!inputs.deleted); 715 + } 716 + 717 + #[tokio::test(flavor = "multi_thread")] 718 + async fn load_mint_authz_inputs_deleted_space_flagged() { 719 + let (svc, _tmp) = fresh_service().await; 720 + let info = svc 721 + .create_space( 722 + "did:plc:owner", 723 + "app.bsky.group", 724 + "default", 725 + SpaceConfig::default(), 726 + ) 727 + .await 728 + .unwrap(); 729 + let uri = info.uri.parse::<SpaceUri>().unwrap(); 730 + 731 + // Tombstone the row directly. 732 + let store = SqlActorStore::open(svc.data_dir.as_path(), "did:plc:owner") 733 + .await 734 + .unwrap(); 735 + sqlx::query("UPDATE space SET deleted_at = ? WHERE uri = ?") 736 + .bind(Utc::now().to_rfc3339()) 737 + .bind(uri.to_string()) 738 + .execute(store.pool()) 739 + .await 740 + .unwrap(); 741 + 742 + let inputs = svc 743 + .load_mint_authz_inputs(&uri, "did:plc:owner") 744 + .await 745 + .unwrap(); 746 + assert!(inputs.found); 747 + assert!(inputs.deleted); 748 + } 749 + 750 + #[tokio::test(flavor = "multi_thread")] 751 + async fn update_space_by_non_owner_rejected() { 752 + let (svc, _tmp) = fresh_service().await; 753 + let info = svc 754 + .create_space( 755 + "did:plc:owner", 756 + "app.bsky.group", 757 + "default", 758 + SpaceConfig::default(), 759 + ) 760 + .await 761 + .unwrap(); 762 + let uri = info.uri.parse::<SpaceUri>().unwrap(); 763 + let result = svc 764 + .update_space("did:plc:eve", &uri, SpaceConfigPatch::default()) 765 + .await; 766 + assert!(matches!(result, Err(PdsError::NotSpaceOwner { .. }))); 767 + } 768 + 769 + #[tokio::test(flavor = "multi_thread")] 770 + async fn delete_space_tombstones_reads_and_writes() { 771 + let (svc, _tmp) = fresh_service().await; 772 + let info = svc 773 + .create_space( 774 + "did:plc:owner", 775 + "app.bsky.group", 776 + "default", 777 + SpaceConfig::default(), 778 + ) 779 + .await 780 + .unwrap(); 781 + let uri = info.uri.parse::<SpaceUri>().unwrap(); 782 + 783 + // Seed an authority-owned record in the space so we can assert the 784 + // authority's own repo is erased on delete (spec line 363). 785 + { 786 + let store = SqlActorStore::open(&svc.data_dir, "did:plc:owner") 787 + .await 788 + .unwrap(); 789 + sqlx::query( 790 + "INSERT INTO space_record (space, collection, rkey, cid, value, repo_rev, indexed_at) 791 + VALUES (?, 'app.bsky.feed.post', 'rk1', 'bafycid', X'00', '3jui', '2026-06-25T00:00:00Z')", 792 + ) 793 + .bind(uri.to_string()) 794 + .execute(store.pool()) 795 + .await 796 + .unwrap(); 797 + } 798 + 799 + svc.delete_space("did:plc:owner", &uri).await.unwrap(); 800 + 801 + // getSpace now reports SpaceNotFound. 802 + assert!(matches!( 803 + svc.get_space("did:plc:owner", &uri).await, 804 + Err(PdsError::SpaceNotFound { .. }) 805 + )); 806 + // The authority's own repo data within the space is erased. 807 + { 808 + let store = SqlActorStore::open(&svc.data_dir, "did:plc:owner") 809 + .await 810 + .unwrap(); 811 + let remaining: i64 = 812 + sqlx::query_scalar("SELECT COUNT(*) FROM space_record WHERE space = ?") 813 + .bind(uri.to_string()) 814 + .fetch_one(store.pool()) 815 + .await 816 + .unwrap(); 817 + assert_eq!( 818 + remaining, 0, 819 + "authority's own repo records should be purged" 820 + ); 821 + } 822 + // Member mutations are gated. 823 + assert!(matches!( 824 + svc.add_member("did:plc:owner", &uri, "did:plc:alice").await, 825 + Err(PdsError::SpaceNotFound { .. }) 826 + )); 827 + // listSpaces excludes the tombstoned space. 828 + let owned = svc 829 + .list_spaces("did:plc:owner", "owned", None, 10) 830 + .await 831 + .unwrap(); 832 + assert!(owned.is_empty()); 833 + // Re-deleting is idempotent. 834 + svc.delete_space("did:plc:owner", &uri).await.unwrap(); 835 + } 836 + 837 + #[tokio::test(flavor = "multi_thread")] 838 + async fn delete_unknown_space_is_not_found() { 839 + let (svc, _tmp) = fresh_service().await; 840 + let uri: SpaceUri = "ats://did:plc:owner/app.bsky.group/missing" 841 + .parse() 842 + .unwrap(); 843 + assert!(matches!( 844 + svc.delete_space("did:plc:owner", &uri).await, 845 + Err(PdsError::SpaceNotFound { .. }) 846 + )); 401 847 } 402 848 403 849 #[tokio::test(flavor = "multi_thread")] 404 850 async fn add_then_list_members() { 405 851 let (svc, _tmp) = fresh_service().await; 406 852 let info = svc 407 - .create_space("did:plc:owner", "app.bsky.group", "default") 853 + .create_space( 854 + "did:plc:owner", 855 + "app.bsky.group", 856 + "default", 857 + SpaceConfig::default(), 858 + ) 408 859 .await 409 860 .unwrap(); 410 861 let uri = info.uri.parse::<SpaceUri>().unwrap(); ··· 420 871 .list_members("did:plc:owner", &uri, None, 10) 421 872 .await 422 873 .unwrap(); 423 - assert_eq!(page.members.len(), 2); 874 + // createSpace seeds the owner as the first member, then add_member 875 + // appends alice + bob. 876 + assert_eq!(page.members.len(), 3); 424 877 let dids: Vec<_> = page.members.iter().map(|m| m.did.clone()).collect(); 878 + assert!(dids.contains(&"did:plc:owner".to_string())); 425 879 assert!(dids.contains(&"did:plc:alice".to_string())); 426 880 assert!(dids.contains(&"did:plc:bob".to_string())); 427 881 } ··· 430 884 async fn add_member_by_non_owner_rejected() { 431 885 let (svc, _tmp) = fresh_service().await; 432 886 let info = svc 433 - .create_space("did:plc:owner", "app.bsky.group", "default") 887 + .create_space( 888 + "did:plc:owner", 889 + "app.bsky.group", 890 + "default", 891 + SpaceConfig::default(), 892 + ) 434 893 .await 435 894 .unwrap(); 436 895 let uri = info.uri.parse::<SpaceUri>().unwrap(); 437 896 let result = svc.add_member("did:plc:eve", &uri, "did:plc:alice").await; 438 - assert!(matches!(result, Err(PdsError::AuthDenied { .. }))); 897 + assert!(matches!(result, Err(PdsError::NotSpaceOwner { .. }))); 439 898 } 440 899 441 900 #[tokio::test(flavor = "multi_thread")] 442 901 async fn remove_member_round_trip() { 443 902 let (svc, _tmp) = fresh_service().await; 444 903 let info = svc 445 - .create_space("did:plc:owner", "app.bsky.group", "default") 904 + .create_space( 905 + "did:plc:owner", 906 + "app.bsky.group", 907 + "default", 908 + SpaceConfig::default(), 909 + ) 446 910 .await 447 911 .unwrap(); 448 912 let uri = info.uri.parse::<SpaceUri>().unwrap(); ··· 456 920 .list_members("did:plc:owner", &uri, None, 10) 457 921 .await 458 922 .unwrap(); 459 - assert_eq!(page.members.len(), 0); 923 + // Owner remains after alice is removed. 924 + assert_eq!(page.members.len(), 1); 925 + assert_eq!(page.members[0].did, "did:plc:owner"); 460 926 } 461 927 462 928 #[tokio::test(flavor = "multi_thread")] 463 929 async fn list_spaces_filters() { 464 930 let (svc, _tmp) = fresh_service().await; 465 - svc.create_space("did:plc:owner", "app.bsky.group", "a") 466 - .await 467 - .unwrap(); 468 - svc.create_space("did:plc:owner", "app.bsky.group", "b") 469 - .await 470 - .unwrap(); 931 + svc.create_space( 932 + "did:plc:owner", 933 + "app.bsky.group", 934 + "a", 935 + SpaceConfig::default(), 936 + ) 937 + .await 938 + .unwrap(); 939 + svc.create_space( 940 + "did:plc:owner", 941 + "app.bsky.group", 942 + "b", 943 + SpaceConfig::default(), 944 + ) 945 + .await 946 + .unwrap(); 471 947 let owned = svc 472 948 .list_spaces("did:plc:owner", "owned", None, 10) 473 949 .await
+244
crates/atproto-pds/src/space/service_auth.rs
··· 1 + //! Inter-PDS service-auth JWTs for the Spaces notify path. 2 + //! 3 + //! `notifyWrite` and `notifySpaceDeleted` are authenticated with AT Protocol 4 + //! **service auth** (the same short-lived bearer the reference's 5 + //! `authVerifier.serviceAuth` accepts): a compact JWS signed by the issuer's 6 + //! `#atproto` signing key with `iss` / `aud` / `lxm` / `iat` / `exp` / `jti` 7 + //! claims. 8 + //! 9 + //! This module provides: 10 + //! - [`mint_service_auth`] — sign a service-auth JWT with a local account's 11 + //! private signing key (writer side, before POSTing notifyWrite to the owner 12 + //! PDS). 13 + //! - [`verify_service_auth`] — verify an inbound service-auth bearer by 14 + //! resolving the `iss` DID document's `#atproto` key, checking the signature, 15 + //! `aud`, `lxm`, and `exp`. 16 + 17 + use crate::errors::{PdsError, PdsResult}; 18 + use atproto_identity::key::{ 19 + KeyData, identify_key, jws_alg, sign as identity_sign, validate as identity_validate, 20 + }; 21 + use atproto_identity::model::VerificationMethod; 22 + use base64::{Engine as _, engine::general_purpose}; 23 + use rand::RngExt; 24 + use serde::{Deserialize, Serialize}; 25 + use std::time::{SystemTime, UNIX_EPOCH}; 26 + 27 + /// `typ` header value for service-auth JWTs. 28 + pub const TYP_SERVICE_AUTH: &str = "at+jwt"; 29 + 30 + /// Default TTL for a minted notify service-auth token (60s). 31 + pub const NOTIFY_SERVICE_AUTH_TTL_SECS: u64 = 60; 32 + 33 + /// Service-auth JWT header. 34 + #[derive(Debug, Serialize)] 35 + struct JwtHeader { 36 + alg: String, 37 + typ: String, 38 + } 39 + 40 + /// Service-auth JWT claims. `iss`/`aud` are DIDs; `lxm` scopes the token to a 41 + /// single XRPC method. 42 + #[derive(Debug, Clone, Serialize, Deserialize)] 43 + pub struct ServiceAuthClaims { 44 + /// Issuer DID (the signer). 45 + pub iss: String, 46 + /// Audience DID (the receiving service). 47 + pub aud: String, 48 + /// NSID of the lexicon method this token is scoped to. 49 + #[serde(skip_serializing_if = "Option::is_none")] 50 + pub lxm: Option<String>, 51 + /// Issued-at (epoch seconds). 52 + pub iat: u64, 53 + /// Expiry (epoch seconds). 54 + pub exp: u64, 55 + /// Random nonce. 56 + pub jti: String, 57 + } 58 + 59 + fn now_secs() -> u64 { 60 + SystemTime::now() 61 + .duration_since(UNIX_EPOCH) 62 + .unwrap_or_default() 63 + .as_secs() 64 + } 65 + 66 + fn b64url(bytes: &[u8]) -> String { 67 + general_purpose::URL_SAFE_NO_PAD.encode(bytes) 68 + } 69 + 70 + fn random_jti() -> String { 71 + let mut bytes = [0u8; 16]; 72 + rand::rng().fill(&mut bytes); 73 + b64url(&bytes) 74 + } 75 + 76 + /// Mint a service-auth JWT signed by `signing_key` (a private atproto signing 77 + /// key), bound to `iss` / `aud` / `lxm`, valid for `ttl_secs`. 78 + /// 79 + /// # Errors 80 + /// Returns [`PdsError::Storage`] on a JSON-encode or signing failure. 81 + pub fn mint_service_auth( 82 + signing_key: &KeyData, 83 + iss: &str, 84 + aud: &str, 85 + lxm: &str, 86 + ttl_secs: u64, 87 + ) -> PdsResult<String> { 88 + let iat = now_secs(); 89 + let header = JwtHeader { 90 + alg: jws_alg(signing_key).to_string(), 91 + typ: TYP_SERVICE_AUTH.to_string(), 92 + }; 93 + let claims = ServiceAuthClaims { 94 + iss: iss.to_string(), 95 + aud: aud.to_string(), 96 + lxm: Some(lxm.to_string()), 97 + iat, 98 + exp: iat + ttl_secs, 99 + jti: random_jti(), 100 + }; 101 + let header_bytes = serde_json::to_vec(&header).map_err(|e| PdsError::Storage { 102 + reason: format!("encode service-auth header: {e}"), 103 + })?; 104 + let claims_bytes = serde_json::to_vec(&claims).map_err(|e| PdsError::Storage { 105 + reason: format!("encode service-auth claims: {e}"), 106 + })?; 107 + let signing_input = format!("{}.{}", b64url(&header_bytes), b64url(&claims_bytes)); 108 + let sig = 109 + identity_sign(signing_key, signing_input.as_bytes()).map_err(|e| PdsError::Storage { 110 + reason: format!("sign service-auth token: {e}"), 111 + })?; 112 + Ok(format!("{}.{}", signing_input, b64url(&sig))) 113 + } 114 + 115 + /// Verify an inbound service-auth `token`. Resolves the `iss` DID document's 116 + /// `#atproto` signing key, checks the signature over `header.payload`, then 117 + /// validates `aud == expected_aud`, `lxm == expected_lxm` (when the token 118 + /// carries one), and `exp` in the future. 119 + /// 120 + /// Returns the verified claims on success. 121 + /// 122 + /// # Errors 123 + /// Returns [`PdsError::AuthDenied`] for any verification failure (bad shape, 124 + /// unknown issuer, bad signature, wrong audience/method, or expiry). 125 + pub async fn verify_service_auth( 126 + http: &reqwest::Client, 127 + token: &str, 128 + plc_directory_hostname: Option<&str>, 129 + expected_aud: &str, 130 + expected_lxm: &str, 131 + ) -> PdsResult<ServiceAuthClaims> { 132 + let mut parts = token.split('.'); 133 + let (Some(header_b64), Some(payload_b64), Some(sig_b64), None) = 134 + (parts.next(), parts.next(), parts.next(), parts.next()) 135 + else { 136 + return Err(deny("malformed service-auth token")); 137 + }; 138 + let payload_bytes = general_purpose::URL_SAFE_NO_PAD 139 + .decode(payload_b64.as_bytes()) 140 + .map_err(|_| deny("service-auth payload not base64url"))?; 141 + let claims: ServiceAuthClaims = serde_json::from_slice(&payload_bytes) 142 + .map_err(|_| deny("service-auth payload not JSON"))?; 143 + 144 + // Claim checks before the (more expensive) DID-document resolution. 145 + if claims.aud != expected_aud { 146 + return Err(deny(&format!( 147 + "service-auth aud mismatch: token={}, expected={}", 148 + claims.aud, expected_aud 149 + ))); 150 + } 151 + if let Some(lxm) = claims.lxm.as_deref() 152 + && lxm != expected_lxm 153 + { 154 + return Err(deny(&format!( 155 + "service-auth lxm mismatch: token={lxm}, expected={expected_lxm}" 156 + ))); 157 + } 158 + if claims.exp <= now_secs() { 159 + return Err(deny("service-auth token expired")); 160 + } 161 + 162 + let key = atproto_signing_key(http, &claims.iss, plc_directory_hostname) 163 + .await 164 + .map_err(|e| deny(&format!("resolve issuer signing key: {e}")))?; 165 + let sig = general_purpose::URL_SAFE_NO_PAD 166 + .decode(sig_b64.as_bytes()) 167 + .map_err(|_| deny("service-auth signature not base64url"))?; 168 + let signing_input = format!("{header_b64}.{payload_b64}"); 169 + identity_validate(&key, &sig, signing_input.as_bytes()) 170 + .map_err(|_| deny("service-auth signature invalid"))?; 171 + Ok(claims) 172 + } 173 + 174 + fn deny(reason: &str) -> PdsError { 175 + PdsError::AuthDenied { 176 + reason: reason.to_string(), 177 + } 178 + } 179 + 180 + /// Resolve a DID's `#atproto` Multikey signing key via its DID document. 181 + async fn atproto_signing_key( 182 + http: &reqwest::Client, 183 + did: &str, 184 + plc_directory_hostname: Option<&str>, 185 + ) -> anyhow::Result<KeyData> { 186 + use atproto_identity::plc::query as plc_query; 187 + use atproto_identity::web::query as web_query; 188 + let document = if did.starts_with("did:plc:") { 189 + let host = plc_directory_hostname.unwrap_or("plc.directory"); 190 + plc_query(http, host, did).await? 191 + } else if did.starts_with("did:web:") { 192 + web_query(http, did).await? 193 + } else { 194 + anyhow::bail!("unsupported DID method for service-auth verification: {did}"); 195 + }; 196 + for method in &document.verification_method { 197 + if let VerificationMethod::Multikey { 198 + id, 199 + public_key_multibase, 200 + .. 201 + } = method 202 + && id.ends_with("#atproto") 203 + { 204 + let did_key = if public_key_multibase.starts_with("did:key:") { 205 + public_key_multibase.clone() 206 + } else { 207 + format!("did:key:{public_key_multibase}") 208 + }; 209 + return Ok(identify_key(&did_key)?); 210 + } 211 + } 212 + anyhow::bail!("DID document for {did} has no #atproto Multikey verification method") 213 + } 214 + 215 + #[cfg(test)] 216 + mod tests { 217 + use super::*; 218 + use atproto_identity::key::{KeyType, generate_key}; 219 + 220 + #[test] 221 + fn mint_then_decode_round_trip() { 222 + let key = generate_key(KeyType::P256Private).unwrap(); 223 + let token = mint_service_auth( 224 + &key, 225 + "did:plc:writer", 226 + "did:plc:owner", 227 + "com.atproto.space.notifyWrite", 228 + 60, 229 + ) 230 + .unwrap(); 231 + // 3 segments. 232 + assert_eq!(token.split('.').count(), 3); 233 + // Payload decodes with the expected claims. 234 + let payload_b64 = token.split('.').nth(1).unwrap(); 235 + let bytes = general_purpose::URL_SAFE_NO_PAD 236 + .decode(payload_b64.as_bytes()) 237 + .unwrap(); 238 + let claims: ServiceAuthClaims = serde_json::from_slice(&bytes).unwrap(); 239 + assert_eq!(claims.iss, "did:plc:writer"); 240 + assert_eq!(claims.aud, "did:plc:owner"); 241 + assert_eq!(claims.lxm.as_deref(), Some("com.atproto.space.notifyWrite")); 242 + assert!(claims.exp > claims.iat); 243 + } 244 + }
+31 -85
crates/atproto-pds/src/space/sync.rs
··· 1 1 //! `SpaceSync` — sync-side reads of state and oplog. 2 2 //! 3 - //! syncing apps poll: 4 - //! - `getRepoState {space, member}` → `{set_hash, rev}` for the per-member 5 - //! record commitment. 6 - //! - `getRepoOplog {space, member, since?, limit?}` → ordered ops since `rev`. 7 - //! - `getMemberState {space}` → owner-only member-list commitment. 8 - //! - `getMemberOplog {space, since?, limit?}` → owner-only member-list ops. 3 + //! Syncing apps poll: 4 + //! - `getRepoState {space, repo}` → the per-account record commitment as a 5 + //! [`RepoState`] (full 2048-byte SetHash state + rev); the HTTP layer signs 6 + //! it into a `com.atproto.space.defs#signedCommit`. 7 + //! - `listRepoOps {space, repo, since?, limit?}` → ordered ops since `rev`. 8 + //! 9 + //! This module returns the raw [`RepoState`] / [`OplogPage`]; commit signing 10 + //! (rehydrating [`PdsSetHash`] and building a signed commit) happens in 11 + //! `crate::http::space_handlers`, which holds the account signing keys. 9 12 //! 10 13 //! Auth is performed at the HTTP layer; this struct just queries the 11 - //! per-actor SQLite store. The owner's per-actor store backs both member-list 12 - //! state and (for their own member-DID) record state. Per-member record 13 - //! state lives in *each member's* per-actor store (since they wrote it). 14 + //! per-actor SQLite store. Per-member record state lives in *each member's* 15 + //! per-actor store (since they wrote it). 14 16 //! 15 - //! / G35, **the PDS does not enforce membership at sync 16 - //! time** — consumers verify membership inductively from the owner's 17 + //! The 0016 Permissioned Data draft has no member commits or member-list sync: 18 + //! consumers learn the writer set from `listRepos`, not from a signed 17 19 //! member-list oplog. 18 20 19 - use crate::actor_store::sql::{SqlActorStore, SqlSpaceMembersStorage, SqlSpaceRepoStorage}; 21 + use crate::actor_store::sql::{SqlActorStore, SqlSpaceRepoStorage}; 20 22 use crate::errors::{PdsError, PdsResult}; 21 23 use crate::realm::PdsSetHash; 22 - use atproto_space::space_members::SpaceMembers; 24 + use crate::space::config::ensure_space_live; 23 25 use atproto_space::space_repo::SpaceRepo; 24 - use atproto_space::storage::{MemberState, OplogPage, RepoState}; 26 + use atproto_space::storage::{OplogCursor, OplogPage, RepoState}; 25 27 use atproto_space::types::SpaceUri; 26 28 use std::path::PathBuf; 27 29 ··· 37 39 Self { data_dir } 38 40 } 39 41 40 - /// `getRepoState` — current `{set_hash, rev}` for `(space, member)`'s 41 - /// record commitment. Reads from the *member's* per-actor store, since 42 - /// each member's writes live in their own store. 43 - pub async fn get_repo_state(&self, space: &SpaceUri, member_did: &str) -> PdsResult<RepoState> { 44 - let store = SqlActorStore::open(&self.data_dir, member_did).await?; 42 + /// `getRepoState` — current `{set_hash, rev}` for `(space, repo)`'s 43 + /// record commitment. Reads from the *repo account's* per-actor store, 44 + /// since each account's writes live in their own store. 45 + pub async fn get_repo_state(&self, space: &SpaceUri, repo_did: &str) -> PdsResult<RepoState> { 46 + ensure_space_live(&self.data_dir, space).await?; 47 + let store = SqlActorStore::open(&self.data_dir, repo_did).await?; 45 48 let storage = SqlSpaceRepoStorage::new(store.pool().clone()); 46 49 let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> = 47 50 SpaceRepo::new(space.clone(), storage); 48 51 repo.current_state().await.map_err(PdsError::Space) 49 52 } 50 53 51 - /// `getRepoOplog` — per-member record oplog from `since` (exclusive), 52 - /// up to `limit` ops. 53 - pub async fn get_repo_oplog( 54 + /// `listRepoOps` — per-repo record oplog strictly after the `(rev, idx)` 55 + /// `since` cursor, up to `limit` ops. 56 + pub async fn list_repo_ops( 54 57 &self, 55 58 space: &SpaceUri, 56 - member_did: &str, 57 - since: Option<&str>, 59 + repo_did: &str, 60 + since: Option<&OplogCursor>, 58 61 limit: u32, 59 62 ) -> PdsResult<OplogPage> { 60 - let store = SqlActorStore::open(&self.data_dir, member_did).await?; 63 + let store = SqlActorStore::open(&self.data_dir, repo_did).await?; 61 64 let storage = SqlSpaceRepoStorage::new(store.pool().clone()); 62 65 let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> = 63 66 SpaceRepo::new(space.clone(), storage); 64 67 repo.read_oplog(since, limit).await.map_err(PdsError::Space) 65 68 } 66 - 67 - /// `getMemberState` — owner-only member-list commitment. 68 - /// 69 - /// Reads from the owner's per-actor store. Caller is responsible for 70 - /// verifying that the request is authorized (e.g. owner-self-auth or 71 - /// member-with-credential). 72 - pub async fn get_member_state(&self, space: &SpaceUri) -> PdsResult<MemberState> { 73 - let store = SqlActorStore::open(&self.data_dir, &space.owner_did).await?; 74 - let storage = SqlSpaceMembersStorage::new(store.pool().clone()); 75 - let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> = 76 - SpaceMembers::new(space.clone(), storage); 77 - members.current_state().await.map_err(PdsError::Space) 78 - } 79 - 80 - /// `getMemberOplog` — owner-only member-list oplog. 81 - pub async fn get_member_oplog( 82 - &self, 83 - space: &SpaceUri, 84 - since: Option<&str>, 85 - limit: u32, 86 - ) -> PdsResult<OplogPage> { 87 - let store = SqlActorStore::open(&self.data_dir, &space.owner_did).await?; 88 - let storage = SqlSpaceMembersStorage::new(store.pool().clone()); 89 - let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> = 90 - SpaceMembers::new(space.clone(), storage); 91 - members 92 - .read_oplog(since, limit) 93 - .await 94 - .map_err(PdsError::Space) 95 - } 96 69 } 97 70 98 71 #[cfg(test)] ··· 100 73 use super::*; 101 74 use crate::account::{AccountDirectory, AccountManager, CreateAccountParams}; 102 75 use crate::keys::{KeyStore, MemoryKeyStore}; 103 - use crate::space::service::SpaceService; 104 76 use crate::space::writer::{SpaceWriteAction, SpaceWriteOp, SpaceWriter}; 105 77 use atproto_identity::key::KeyType; 106 78 use atproto_space::types::{SpaceKey, SpaceType}; ··· 184 156 assert!(state.rev.is_some()); 185 157 186 158 let oplog = sync 187 - .get_repo_oplog(&uri, "did:plc:alice", None, 100) 159 + .list_repo_ops(&uri, "did:plc:alice", None, 100) 188 160 .await 189 161 .unwrap(); 190 162 assert_eq!(oplog.ops.len(), 1); ··· 192 164 } 193 165 194 166 #[tokio::test(flavor = "multi_thread")] 195 - async fn member_oplog_observes_add_remove() { 196 - let tmp = TempDir::new().unwrap(); 197 - let dir = tmp.path().to_path_buf(); 198 - let _manager = fresh_manager(&dir).await; 199 - let svc = SpaceService::new(dir.clone()); 200 - svc.create_space("did:plc:owner", "app.bsky.group", "default") 201 - .await 202 - .unwrap(); 203 - let uri = test_space(); 204 - svc.add_member("did:plc:owner", &uri, "did:plc:alice") 205 - .await 206 - .unwrap(); 207 - svc.remove_member("did:plc:owner", &uri, "did:plc:alice") 208 - .await 209 - .unwrap(); 210 - 211 - let sync = SpaceSync::new(dir); 212 - let state = sync.get_member_state(&uri).await.unwrap(); 213 - assert!(state.set_hash.is_some()); 214 - 215 - let oplog = sync.get_member_oplog(&uri, None, 100).await.unwrap(); 216 - assert_eq!(oplog.ops.len(), 2); 217 - assert_eq!(oplog.ops[0].action, "add"); 218 - assert_eq!(oplog.ops[1].action, "remove"); 219 - } 220 - 221 - #[tokio::test(flavor = "multi_thread")] 222 167 async fn since_filter_excludes_prior_revs() { 223 168 let tmp = TempDir::new().unwrap(); 224 169 let dir = tmp.path().to_path_buf(); ··· 253 198 .unwrap(); 254 199 255 200 let sync = SpaceSync::new(dir); 201 + let cursor = OplogCursor::new(first.rev.clone(), 0); 256 202 let oplog = sync 257 - .get_repo_oplog(&uri, "did:plc:alice", Some(&first.rev), 100) 203 + .list_repo_ops(&uri, "did:plc:alice", Some(&cursor), 100) 258 204 .await 259 205 .unwrap(); 260 206 // Only the second commit's ops should be returned.
+360 -38
crates/atproto-pds/src/space/writer.rs
··· 1 1 //! `SpaceWriter` — permissioned-record CRUD via per-(DID, space) lock. 2 2 //! 3 - //! each write batch 4 - //! holds a per-(member-DID, space-URI) async mutex, computes a new SetHash, 5 - //! signs the commit (HKDF + HMAC + ECDSA via `atproto-space::create_commit`), 6 - //! and atomically persists changes + oplog. **/ G35**, the 3 + //! Each write batch holds a per-(member-DID, space-URI) async mutex, computes a 4 + //! new SetHash, signs the commit (HKDF + HMAC + ECDSA via 5 + //! `atproto-space::create_commit`), and atomically persists changes + oplog. The 7 6 //! PDS does not enforce membership at write time; consumers check at sync. 8 7 9 8 use crate::actor_store::sql::{SqlActorStore, SqlSpaceRepoStorage}; 10 9 use crate::errors::{PdsError, PdsResult}; 11 10 use crate::realm::PdsSetHash; 12 - use crate::space::notify::{NotifyWritePayload, enqueue_writes, op_to_notify}; 11 + use crate::space::config::ensure_space_live; 12 + use crate::space::notify::{NOTIFY_WRITE_NSID, NotifyWritePayload}; 13 + use crate::space::service_auth::{NOTIFY_SERVICE_AUTH_TTL_SECS, mint_service_auth}; 14 + use atproto_identity::key::KeyData; 13 15 use atproto_record::tid::Tid; 14 - use atproto_space::commit::{CommitScope, SpaceContext, create_commit}; 16 + use atproto_space::commit::{SpaceContext, create_commit}; 15 17 use atproto_space::space_repo::{Op, OpAction, SpaceRepo}; 16 18 use atproto_space::types::SpaceUri; 17 19 use dashmap::DashMap; ··· 55 57 pub set_hash: String, 56 58 /// Per-op AT-URIs (`ats://` form). 57 59 pub uris: Vec<String>, 60 + /// Per-op record CIDs, parallel to `uris`. `None` for delete ops. 61 + pub cids: Vec<Option<String>>, 58 62 } 59 63 60 64 type WriteLocks = Arc<DashMap<(String, String), Arc<Mutex<()>>>>; ··· 64 68 data_dir: PathBuf, 65 69 accounts: Arc<crate::account::AccountManager>, 66 70 locks: WriteLocks, 71 + /// PLC directory hostname for resolving the owner PDS endpoint on the 72 + /// outbound `notifyWrite` hop. `None` uses the upstream default. 73 + plc_directory: Option<String>, 67 74 } 68 75 69 76 impl SpaceWriter { ··· 73 80 data_dir, 74 81 accounts, 75 82 locks: Arc::new(DashMap::new()), 83 + plc_directory: None, 76 84 } 77 85 } 78 86 87 + /// Set the PLC directory hostname used to resolve the owner PDS endpoint 88 + /// for the outbound `notifyWrite` hop (HOP 1, writer PDS → owner PDS). 89 + #[must_use] 90 + pub fn with_plc_directory(mut self, plc_directory: Option<String>) -> Self { 91 + self.plc_directory = plc_directory; 92 + self 93 + } 94 + 79 95 fn lock_for(&self, did: &str, uri: &SpaceUri) -> Arc<Mutex<()>> { 80 96 self.locks 81 97 .entry((did.to_string(), uri.to_string())) ··· 100 116 let lock = self.lock_for(member_did, space); 101 117 let _guard = lock.lock().await; 102 118 119 + ensure_space_live(&self.data_dir, space).await?; 103 120 let store = SqlActorStore::open(&self.data_dir, member_did).await?; 104 121 let storage = SqlSpaceRepoStorage::new(store.pool().clone()); 105 122 let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> = 106 123 SpaceRepo::new(space.clone(), storage); 107 124 125 + self.apply_writes_locked(member_did, space, &repo, ops) 126 + .await 127 + } 128 + 129 + /// `createRecord` — single-op `Create`. Errors if the record already 130 + /// exists. An empty `rkey` auto-generates a TID. 131 + pub async fn create_record( 132 + &self, 133 + member_did: &str, 134 + space: &SpaceUri, 135 + collection: String, 136 + rkey: String, 137 + value: serde_json::Value, 138 + ) -> PdsResult<SpaceCommitResult> { 139 + self.apply_writes( 140 + member_did, 141 + space, 142 + vec![SpaceWriteOp { 143 + action: SpaceWriteAction::Create, 144 + collection, 145 + rkey, 146 + value: Some(value), 147 + }], 148 + ) 149 + .await 150 + } 151 + 152 + /// `putRecord` — single-op create-or-update. Resolves to `Update` when 153 + /// a record already exists at `(collection, rkey)`, else `Create`. The 154 + /// existence check runs under the per-(member, space) lock so the chosen 155 + /// action cannot race a concurrent write. 156 + pub async fn put_record( 157 + &self, 158 + member_did: &str, 159 + space: &SpaceUri, 160 + collection: String, 161 + rkey: String, 162 + value: serde_json::Value, 163 + ) -> PdsResult<SpaceCommitResult> { 164 + let lock = self.lock_for(member_did, space); 165 + let _guard = lock.lock().await; 166 + 167 + ensure_space_live(&self.data_dir, space).await?; 168 + let store = SqlActorStore::open(&self.data_dir, member_did).await?; 169 + let storage = SqlSpaceRepoStorage::new(store.pool().clone()); 170 + let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> = 171 + SpaceRepo::new(space.clone(), storage); 172 + 173 + let exists = repo 174 + .get_record(&collection, &rkey) 175 + .await 176 + .map_err(space_err)? 177 + .is_some(); 178 + let action = if exists { 179 + SpaceWriteAction::Update 180 + } else { 181 + SpaceWriteAction::Create 182 + }; 183 + self.apply_writes_locked( 184 + member_did, 185 + space, 186 + &repo, 187 + vec![SpaceWriteOp { 188 + action, 189 + collection, 190 + rkey, 191 + value: Some(value), 192 + }], 193 + ) 194 + .await 195 + } 196 + 197 + /// `deleteRecord` — single-op delete that is idempotent: when no record 198 + /// exists at `(collection, rkey)` it is a no-op returning the current 199 + /// `{rev, set_hash}` rather than erroring. The existence check runs under 200 + /// the per-(member, space) lock. 201 + pub async fn delete_record( 202 + &self, 203 + member_did: &str, 204 + space: &SpaceUri, 205 + collection: String, 206 + rkey: String, 207 + ) -> PdsResult<SpaceCommitResult> { 208 + let lock = self.lock_for(member_did, space); 209 + let _guard = lock.lock().await; 210 + 211 + ensure_space_live(&self.data_dir, space).await?; 212 + let store = SqlActorStore::open(&self.data_dir, member_did).await?; 213 + let storage = SqlSpaceRepoStorage::new(store.pool().clone()); 214 + let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> = 215 + SpaceRepo::new(space.clone(), storage); 216 + 217 + let exists = repo 218 + .get_record(&collection, &rkey) 219 + .await 220 + .map_err(space_err)? 221 + .is_some(); 222 + if !exists { 223 + // Idempotent no-op: report current state without a new commit. 224 + let state = repo.current_state().await.map_err(space_err)?; 225 + let uri = format!( 226 + "ats://{}/{}/{}/{}/{}/{}", 227 + space.space_did, space.space_type, space.space_key, member_did, collection, rkey 228 + ); 229 + return Ok(SpaceCommitResult { 230 + rev: state.rev.unwrap_or_default(), 231 + set_hash: state.set_hash.map(hex::encode).unwrap_or_default(), 232 + uris: vec![uri], 233 + cids: vec![None], 234 + }); 235 + } 236 + self.apply_writes_locked( 237 + member_did, 238 + space, 239 + &repo, 240 + vec![SpaceWriteOp { 241 + action: SpaceWriteAction::Delete, 242 + collection, 243 + rkey, 244 + value: None, 245 + }], 246 + ) 247 + .await 248 + } 249 + 250 + /// Commit a batch of writes against an already-opened `repo`, with the 251 + /// per-(member, space) lock already held by the caller. Shared by 252 + /// `apply_writes` and the single-op `createRecord` / `putRecord` / 253 + /// `deleteRecord` wrappers. 254 + async fn apply_writes_locked( 255 + &self, 256 + member_did: &str, 257 + space: &SpaceUri, 258 + repo: &SpaceRepo<SqlSpaceRepoStorage, PdsSetHash>, 259 + ops: Vec<SpaceWriteOp>, 260 + ) -> PdsResult<SpaceCommitResult> { 108 261 // Translate ops + auto-generate TIDs for empty rkeys on Create. 109 262 let mut translated = Vec::with_capacity(ops.len()); 110 263 let mut output_uris = Vec::with_capacity(ops.len()); 264 + let mut output_cids = Vec::with_capacity(ops.len()); 111 265 for op in ops { 112 266 let rkey = if matches!(op.action, SpaceWriteAction::Create) && op.rkey.is_empty() { 113 267 Tid::new().to_string() 114 268 } else { 115 269 op.rkey.clone() 116 270 }; 271 + // Six-segment permissioned record URI, including the author DID: 272 + // ats://<spaceDid>/<spaceType>/<skey>/<authorDid>/<collection>/<rkey>. 273 + // The author segment is required — records are not colocated, so two 274 + // members writing the same (collection, rkey) must not collide. 117 275 output_uris.push(format!( 118 - "ats://{}/{}/{}/{}/{}", 119 - space.owner_did, space.space_type, space.space_key, op.collection, rkey 276 + "ats://{}/{}/{}/{}/{}/{}", 277 + space.space_did, space.space_type, space.space_key, member_did, op.collection, rkey 120 278 )); 121 279 // Compute the value's CID (from DAG-CBOR) for create/update. 122 280 let (cid, value_bytes) = match op.action { ··· 132 290 } 133 291 SpaceWriteAction::Delete => (None, None), 134 292 }; 293 + output_cids.push(cid.clone()); 135 294 translated.push(Op { 136 295 action: match op.action { 137 296 SpaceWriteAction::Create => OpAction::Create, ··· 149 308 let rev = prepared.rev.clone(); 150 309 let set_hash_hex = hex::encode(&prepared.storage_commit.new_set_hash); 151 310 let context = SpaceContext { 152 - space_did: space.owner_did.clone(), 153 - space_type: space.space_type.to_string(), 154 - space_key: space.space_key.to_string(), 155 - user_did: member_did.to_string(), 156 - scope: CommitScope::Records, 311 + space: space.to_string(), 157 312 rev: rev.clone(), 158 313 }; 159 314 ··· 173 328 .0; 174 329 let signing_key = self.accounts.key_store().get(&key_ref).await?; 175 330 176 - // Sign the commit. We keep the result here because the signed Commit 177 - // is broadcast via `notifyWrite` to subscribed consumers (peers don't 178 - // see our durable storage, so they need the signed commit + ops to 179 - // apply on their side). 180 - let signed_commit = 331 + // Sign the commit so the repo's signed state is persisted. The commit 332 + // is no longer broadcast — `notifyWrite` is contentless per the 333 + // published spec; consumers PULL ops via `listRepoOps`. 334 + let _signed_commit = 181 335 create_commit(&prepared.set_hash, &context, &signing_key).map_err(space_err)?; 182 336 183 337 repo.apply_commit(prepared).await.map_err(space_err)?; 184 338 185 - // Fan out a `notifyWrite` to every registered recipient. Failures 186 - // here are logged but non-fatal — the commit is durable; redelivery 187 - // of stale missed notifications is a sync-side problem, not a write 188 - // failure. (The owner's catch-up oplog at `getRepoOplog` is the 189 - // authoritative source.) 190 - let payload = NotifyWritePayload { 191 - space: space.to_string(), 192 - member: member_did.to_string(), 193 - commit: signed_commit, 194 - ops: translated.iter().map(op_to_notify).collect(), 195 - }; 196 - if let Err(e) = enqueue_writes(self.accounts.pool(), &self.data_dir, space, &payload).await 197 - { 198 - tracing::warn!( 199 - error = ?e, 200 - space = %space, 201 - member = %member_did, 202 - "notifyWrite enqueue failed; recipients may miss this commit until next catch-up sync" 203 - ); 204 - } 339 + // HOP 1 (writer PDS → owner PDS): announce that this repo advanced to 340 + // `rev`. Contentless payload `{ space, repo, rev }`, service-auth 341 + // signed by the writer's key. Best-effort: failures are logged but 342 + // never fail the (already-durable) write. The owner-side inbound 343 + // handler does the isMember check + fans out to registered recipients. 344 + self.fire_notify_write(space, member_did, &rev, &signing_key) 345 + .await; 205 346 206 347 Ok(SpaceCommitResult { 207 348 rev, 208 349 set_hash: set_hash_hex, 209 350 uris: output_uris, 351 + cids: output_cids, 210 352 }) 211 353 } 354 + 355 + /// HOP 1 of the reference two-hop notify path: the writer's PDS POSTs a 356 + /// contentless `notifyWrite` `{ space, repo, rev }` to the OWNER's PDS, 357 + /// authenticated with service auth (iss = writer DID, aud = owner DID). 358 + /// 359 + /// Resolution: owner DID (`space.space_did`) → DID document → 360 + /// `#atproto_pds` service endpoint. Entirely best-effort — every failure 361 + /// path is logged and swallowed so a write never fails on a missed 362 + /// notification (the owner's `listRepoOps` is the authoritative catch-up 363 + /// source). 364 + async fn fire_notify_write( 365 + &self, 366 + space: &SpaceUri, 367 + writer_did: &str, 368 + rev: &str, 369 + writer_signing_key: &KeyData, 370 + ) { 371 + let owner_did = space.space_did.clone(); 372 + let http = reqwest::Client::builder() 373 + .timeout(std::time::Duration::from_secs(10)) 374 + .user_agent(crate::user_agent()) 375 + .build() 376 + .unwrap_or_default(); 377 + 378 + // Resolve the owner's PDS endpoint from their DID document. 379 + let owner_pds = match crate::space::recipient::resolve_service_endpoint( 380 + &http, 381 + &format!("{owner_did}#atproto_pds"), 382 + self.plc_directory.as_deref(), 383 + ) 384 + .await 385 + { 386 + Ok(Some(ep)) => ep, 387 + Ok(None) => { 388 + tracing::debug!( 389 + space = %space, 390 + owner = %owner_did, 391 + "notifyWrite: owner DID document has no #atproto_pds service; skipping fan-out hop" 392 + ); 393 + return; 394 + } 395 + Err(e) => { 396 + tracing::warn!( 397 + error = ?e, 398 + space = %space, 399 + owner = %owner_did, 400 + "notifyWrite: failed to resolve owner PDS endpoint; skipping fan-out hop" 401 + ); 402 + return; 403 + } 404 + }; 405 + 406 + let token = match mint_service_auth( 407 + writer_signing_key, 408 + writer_did, 409 + &owner_did, 410 + NOTIFY_WRITE_NSID, 411 + NOTIFY_SERVICE_AUTH_TTL_SECS, 412 + ) { 413 + Ok(t) => t, 414 + Err(e) => { 415 + tracing::warn!( 416 + error = ?e, 417 + space = %space, 418 + "notifyWrite: failed to mint service-auth token; skipping fan-out hop" 419 + ); 420 + return; 421 + } 422 + }; 423 + 424 + let payload = NotifyWritePayload { 425 + space: space.to_string(), 426 + repo: writer_did.to_string(), 427 + rev: rev.to_string(), 428 + }; 429 + let url = format!( 430 + "{}/xrpc/{}", 431 + owner_pds.trim_end_matches('/'), 432 + NOTIFY_WRITE_NSID 433 + ); 434 + match http 435 + .post(&url) 436 + .bearer_auth(&token) 437 + .json(&payload) 438 + .send() 439 + .await 440 + { 441 + Ok(resp) if resp.status().is_success() => { 442 + tracing::debug!(space = %space, owner = %owner_did, rev = %rev, "notifyWrite delivered to owner PDS"); 443 + } 444 + Ok(resp) => { 445 + tracing::warn!( 446 + status = %resp.status(), 447 + space = %space, 448 + owner = %owner_did, 449 + "notifyWrite: owner PDS rejected the notification (best-effort, ignored)" 450 + ); 451 + } 452 + Err(e) => { 453 + tracing::warn!( 454 + error = ?e, 455 + space = %space, 456 + owner = %owner_did, 457 + "notifyWrite: transport error delivering to owner PDS (best-effort, ignored)" 458 + ); 459 + } 460 + } 461 + } 212 462 } 213 463 214 464 fn space_err(err: atproto_space::SpaceError) -> PdsError { ··· 343 593 .unwrap(); 344 594 let last_seg = result.uris[0].split('/').next_back().unwrap(); 345 595 assert_eq!(last_seg.len(), 13, "TID rkey is 13 chars"); 596 + } 597 + 598 + #[tokio::test(flavor = "multi_thread")] 599 + async fn create_record_returns_uri_and_cid() { 600 + let (w, _tmp, uri) = fresh_writer().await; 601 + let result = w 602 + .create_record( 603 + "did:plc:alice", 604 + &uri, 605 + "c".to_string(), 606 + "k".to_string(), 607 + serde_json::json!({"v": 1}), 608 + ) 609 + .await 610 + .unwrap(); 611 + assert_eq!(result.uris.len(), 1); 612 + assert!(result.uris[0].ends_with("/c/k")); 613 + assert!(result.cids[0].is_some()); 614 + } 615 + 616 + #[tokio::test(flavor = "multi_thread")] 617 + async fn put_record_creates_then_updates() { 618 + let (w, _tmp, uri) = fresh_writer().await; 619 + // First put creates. 620 + let first = w 621 + .put_record( 622 + "did:plc:alice", 623 + &uri, 624 + "c".to_string(), 625 + "k".to_string(), 626 + serde_json::json!({"v": 1}), 627 + ) 628 + .await 629 + .unwrap(); 630 + // Second put updates the same rkey (does not error on existing). 631 + let second = w 632 + .put_record( 633 + "did:plc:alice", 634 + &uri, 635 + "c".to_string(), 636 + "k".to_string(), 637 + serde_json::json!({"v": 2}), 638 + ) 639 + .await 640 + .unwrap(); 641 + assert_eq!(first.uris[0], second.uris[0]); 642 + assert_ne!(first.cids[0], second.cids[0], "value changed → new CID"); 643 + } 644 + 645 + #[tokio::test(flavor = "multi_thread")] 646 + async fn delete_record_is_idempotent() { 647 + let (w, _tmp, uri) = fresh_writer().await; 648 + // Delete on a non-existent record is a no-op (does not error). 649 + let absent = w 650 + .delete_record("did:plc:alice", &uri, "c".to_string(), "k".to_string()) 651 + .await 652 + .unwrap(); 653 + assert!(absent.cids[0].is_none()); 654 + 655 + // Create then delete succeeds. 656 + w.create_record( 657 + "did:plc:alice", 658 + &uri, 659 + "c".to_string(), 660 + "k".to_string(), 661 + serde_json::json!({"v": 1}), 662 + ) 663 + .await 664 + .unwrap(); 665 + w.delete_record("did:plc:alice", &uri, "c".to_string(), "k".to_string()) 666 + .await 667 + .unwrap(); 346 668 } 347 669 }
+1 -2
crates/atproto-pds/tests/http_phase2.rs
··· 82 82 .unwrap(); 83 83 let status = response.status(); 84 84 let body = response.into_body().collect().await.unwrap().to_bytes(); 85 - let value: serde_json::Value = 86 - serde_json::from_slice(&body).unwrap_or_else(|_| serde_json::Value::Null); 85 + let value: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null); 87 86 (status, value) 88 87 } 89 88
+987 -435
crates/atproto-pds/tests/http_phase7_spaces.rs
··· 1 - //! Phase 7 HTTP integration tests — `com.atproto.space.*` endpoints. 1 + //! Phase 7 HTTP integration tests — the `com.atproto.space.*` and 2 + //! `com.atproto.simplespace.*` surface, aligned to the published 0016 3 + //! Permissioned Data spec. 4 + //! 5 + //! This suite exercises the spec-aligned wire shapes: 6 + //! - `com.atproto.simplespace.createSpace` (`{did?, type, skey?, config?}` -> 7 + //! `{uri}`), `updateSpace`, `deleteSpace`, `addMember`, `removeMember`, 8 + //! `listMembers`. 9 + //! - `com.atproto.space.getSpace` (`{uri, config}`), `listSpaces`. 10 + //! - `applyWrites` + single-op `createRecord` / `putRecord` / `deleteRecord`. 11 + //! - `getRecord` (`{uri, cid, value}`) / `listRecords` (keys-only 12 + //! `{collection, rkey, cid}`). 13 + //! - `getRepoState` / `listRepoOps` — signed-commit (`{commit:#signedCommit}`) 14 + //! sync surface (sig over `ctx` + `mac`-bound `hash`, spec lines 285-316). 15 + //! - `getDelegationToken` (GET -> `{token}`; spec lines 147-176) exchanged at 16 + //! `getSpaceCredential` (delegation token in the `Authorization` header + 17 + //! body `{space, clientAttestation?}` -> `{credential}`; spec lines 200-251) 18 + //! with mint authorization (member-list allow, non-member deny, `#allowList` 19 + //! app deny). 20 + //! - `getBlob` permissioned gating, `listRepos` (`{did, rev}`), 21 + //! `registerNotify`, `notifyWrite` (contentless `{space, repo, rev}`), 22 + //! `notifySpaceDeleted`. 2 23 //! 3 - //! Coverage: 4 - //! - createSpace / getSpace / listSpaces (owner-OAuth gated) 5 - //! - addMember / removeMember / getMembers (owner-OAuth, 403 for non-owner) 6 - //! - applyWrites against a permissioned repo (member-OAuth) 7 - //! - getRecord / listRecords (own-PDS OAuth + remote SpaceCredential paths) 8 - //! - getRepoState / getRepoOplog / getMemberState / getMemberOplog 9 - //! - getMemberGrant + getSpaceCredential round-trip 24 + //! Management endpoints authenticate with an app-password session token (the 25 + //! `space:` scope gate is a no-op for non-OAuth subjects). `getDelegationToken` 26 + //! requires an OAuth token carrying a `client_id`, so those flows mint an 27 + //! HS256 OAuth access token directly (mirroring `tests/dpop_enforcement.rs`). 10 28 11 29 use atproto_identity::key::KeyType; 12 30 use atproto_pds::account::{AccountDirectory, AccountManager}; ··· 16 34 use atproto_pds::space::{SpaceReader, SpaceService, SpaceSync, SpaceWriter}; 17 35 use axum::body::Body; 18 36 use axum::http::{Request, StatusCode}; 37 + use base64::Engine as _; 38 + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL; 39 + use hmac::{Hmac, Mac}; 19 40 use http_body_util::BodyExt; 20 41 use serde_json::{Value, json}; 42 + use sha2::Sha256; 43 + use sha2::digest::KeyInit; 21 44 use std::sync::Arc; 22 45 use tempfile::TempDir; 23 46 use tower::ServiceExt; 24 47 48 + type HmacSha256 = Hmac<Sha256>; 49 + 50 + const JWT_SECRET: &[u8] = b"test-secret-do-not-use-in-prod-32!"; 51 + const CLIENT_ID: &str = "https://app.example/client-metadata.json"; 52 + 53 + /// Build a fresh PDS app with the full Spaces wiring (service + writer + 54 + /// reader + sync). Returns the router and the owning `TempDir`. 25 55 async fn build_app() -> (axum::Router, TempDir) { 26 56 let tmp = TempDir::new().unwrap(); 27 57 let dir = tmp.path().to_path_buf(); ··· 38 68 let writer = Arc::new(RepoWriter::new(manager.clone(), dir.clone())); 39 69 let reader = Arc::new(RepoReader::new(accounts, dir.clone())); 40 70 41 - // Phase 7 wiring — `with_accounts` enables the notifyMembership fan-out 42 - // path (signing the membership commit + enqueuing into `notify_attempt`). 43 71 let svc = Arc::new(SpaceService::with_accounts(dir.clone(), manager.clone())); 44 72 let space_writer = Arc::new(SpaceWriter::new(manager.clone(), dir.clone())); 45 73 let space_reader = Arc::new(SpaceReader::new(manager.clone(), dir.clone())); ··· 49 77 reader, 50 78 manager, 51 79 "did:web:test.example".to_string(), 52 - b"test-secret-do-not-use-in-prod-32!".to_vec(), 80 + JWT_SECRET.to_vec(), 53 81 false, 54 82 ) 55 83 .with_writer(writer) 56 84 .with_spaces(svc, space_writer, space_reader, space_sync); 57 85 58 - let app = build_router(state); 59 - (app, tmp) 86 + (build_router(state), tmp) 60 87 } 61 88 89 + /// Create an account and return its app-password session access token. 62 90 async fn create_account_and_token(app: &axum::Router, did: &str, handle: &str) -> String { 63 91 let req = Request::builder() 64 92 .uri("/xrpc/com.atproto.server.createAccount") ··· 79 107 body["accessJwt"].as_str().unwrap().to_string() 80 108 } 81 109 110 + /// Mint an OAuth access JWT (`typ=at-oauth-access`, HS256 over `JWT_SECRET`) 111 + /// with the supplied `scope`, no `cnf` (so no DPoP proof is required). Carries 112 + /// a `client_id`, which `getDelegationToken` needs. 113 + fn mint_oauth_access(sub: &str, scope: &str) -> String { 114 + let header = json!({"alg": "HS256", "typ": "at-oauth-access"}); 115 + let now = chrono::Utc::now().timestamp() as u64; 116 + let payload = json!({ 117 + "sub": sub, 118 + "iss": "did:web:test.example", 119 + "aud": "did:web:test.example", 120 + "client_id": CLIENT_ID, 121 + "scope": scope, 122 + "iat": now, 123 + "exp": now + 3600, 124 + "jti": format!("oauth-jti-{sub}-{now}"), 125 + }); 126 + let h = B64URL.encode(serde_json::to_vec(&header).unwrap()); 127 + let p = B64URL.encode(serde_json::to_vec(&payload).unwrap()); 128 + let signing_input = format!("{h}.{p}"); 129 + let mut mac = <HmacSha256 as KeyInit>::new_from_slice(JWT_SECRET).unwrap(); 130 + mac.update(signing_input.as_bytes()); 131 + format!( 132 + "{}.{}", 133 + signing_input, 134 + B64URL.encode(mac.finalize().into_bytes()) 135 + ) 136 + } 137 + 138 + /// Mint a *real*, authority-signed space credential for `member_did` over 139 + /// `space_uri` by running the full `getDelegationToken` -> `getSpaceCredential` 140 + /// flow. `member_did` must already be authorized to mint (a member under the 141 + /// default member-list policy). Returns the compact credential JWT. 142 + async fn mint_space_credential(app: &axum::Router, member_did: &str, space_uri: &str) -> String { 143 + let oauth = mint_oauth_access(member_did, &read_scope()); 144 + let (status, body) = get_json( 145 + app.clone(), 146 + &format!( 147 + "/xrpc/com.atproto.space.getDelegationToken?space={}", 148 + urlencode(space_uri) 149 + ), 150 + Some(&oauth), 151 + ) 152 + .await; 153 + assert_eq!(status, StatusCode::OK, "getDelegationToken: {body}"); 154 + let grant = body["token"].as_str().unwrap().to_string(); 155 + let (status, body) = exchange_credential(app, &grant, space_uri, None).await; 156 + assert_eq!(status, StatusCode::OK, "getSpaceCredential: {body}"); 157 + body["credential"].as_str().unwrap().to_string() 158 + } 159 + 160 + /// Forge a JWT that merely *classifies* as a space credential — correct 161 + /// `typ`/`kid` header so [`classify`] tags it `SpaceCredential`, plausible 162 + /// `iss`/`sub`/`exp` claims, but a bogus (non-cryptographic) signature. The 163 + /// host/sync read methods must reject this: it is never signed by the space 164 + /// authority's `#atproto_space` key. 165 + fn forge_space_credential(authority_did: &str, space_uri: &str) -> String { 166 + let header = json!({ 167 + "alg": "ES256", 168 + "typ": "atproto-space-credential+jwt", 169 + "kid": "#atproto_space" 170 + }); 171 + let now = chrono::Utc::now().timestamp() as u64; 172 + let payload = json!({ 173 + "iss": authority_did, 174 + "sub": space_uri, 175 + "iat": now, 176 + "exp": now + 3600, 177 + }); 178 + let h = B64URL.encode(serde_json::to_vec(&header).unwrap()); 179 + let p = B64URL.encode(serde_json::to_vec(&payload).unwrap()); 180 + // 64-byte all-zero "signature" — structurally a P-256 sig, cryptographically 181 + // invalid. 182 + let sig = B64URL.encode([0u8; 64]); 183 + format!("{h}.{p}.{sig}") 184 + } 185 + 82 186 async fn post_json( 83 187 app: axum::Router, 84 188 path: &str, ··· 88 192 let mut req = Request::builder() 89 193 .uri(path) 90 194 .method("POST") 91 - .header("content-type", "application/json"); 195 + .header("content-type", "application/json") 196 + .header("host", "test.example"); 92 197 if let Some(t) = bearer { 93 198 req = req.header("authorization", format!("Bearer {t}")); 94 199 } ··· 103 208 } 104 209 105 210 async fn get_json(app: axum::Router, path: &str, bearer: Option<&str>) -> (StatusCode, Value) { 106 - let mut req = Request::builder().uri(path); 211 + let mut req = Request::builder().uri(path).header("host", "test.example"); 107 212 if let Some(t) = bearer { 108 213 req = req.header("authorization", format!("Bearer {t}")); 109 214 } ··· 115 220 (status, value) 116 221 } 117 222 223 + /// Split a compact JWT into its decoded `(header, payload)` JSON objects, 224 + /// without verifying the signature (the handlers verify; tests assert shape). 225 + fn decode_jwt(jwt: &str) -> (Value, Value) { 226 + let mut parts = jwt.split('.'); 227 + let header_b64 = parts.next().expect("jwt header segment"); 228 + let payload_b64 = parts.next().expect("jwt payload segment"); 229 + assert!(parts.next().is_some(), "jwt must carry a signature segment"); 230 + let header: Value = 231 + serde_json::from_slice(&B64URL.decode(header_b64).expect("decode jwt header")) 232 + .expect("parse jwt header"); 233 + let payload: Value = 234 + serde_json::from_slice(&B64URL.decode(payload_b64).expect("decode jwt payload")) 235 + .expect("parse jwt payload"); 236 + (header, payload) 237 + } 238 + 239 + /// Percent-encode a string for use in a query parameter. 240 + fn urlencode(s: &str) -> String { 241 + s.chars() 242 + .map(|c| match c { 243 + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), 244 + _ => format!("%{:02X}", c as u8), 245 + }) 246 + .collect() 247 + } 248 + 249 + /// Create a space via `simplespace.createSpace` and return its URI. 250 + async fn create_space(app: &axum::Router, owner_token: &str, skey: &str) -> String { 251 + let (status, body) = post_json( 252 + app.clone(), 253 + "/xrpc/com.atproto.simplespace.createSpace", 254 + json!({"type": "app.bsky.group", "skey": skey}), 255 + Some(owner_token), 256 + ) 257 + .await; 258 + assert_eq!(status, StatusCode::OK, "createSpace failed: {body}"); 259 + body["uri"].as_str().unwrap().to_string() 260 + } 261 + 262 + /// Exchange a delegation token for a space credential at 263 + /// `com.atproto.space.getSpaceCredential`. 264 + /// 265 + /// Per the 0016 spec (lines 200-251) the delegation token is presented in the 266 + /// `Authorization: Bearer` header and the body carries the target `space` plus 267 + /// an optional `clientAttestation`. The response is `{credential}`. 268 + async fn exchange_credential( 269 + app: &axum::Router, 270 + delegation_token: &str, 271 + space: &str, 272 + client_attestation: Option<&str>, 273 + ) -> (StatusCode, Value) { 274 + let mut body = json!({ "space": space }); 275 + if let Some(att) = client_attestation { 276 + body["clientAttestation"] = json!(att); 277 + } 278 + post_json( 279 + app.clone(), 280 + "/xrpc/com.atproto.space.getSpaceCredential", 281 + body, 282 + Some(delegation_token), 283 + ) 284 + .await 285 + } 286 + 287 + // --------------------------------------------------------------------------- 288 + // simplespace management 289 + // --------------------------------------------------------------------------- 290 + 118 291 #[tokio::test(flavor = "multi_thread")] 119 292 async fn create_space_round_trip() { 120 293 let (app, _tmp) = build_app().await; ··· 122 295 123 296 let (status, body) = post_json( 124 297 app.clone(), 125 - "/xrpc/com.atproto.space.createSpace", 126 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 298 + "/xrpc/com.atproto.simplespace.createSpace", 299 + json!({"type": "app.bsky.group", "skey": "default"}), 127 300 Some(&token), 128 301 ) 129 302 .await; 130 303 assert_eq!(status, StatusCode::OK, "body: {body}"); 131 304 let uri = body["uri"].as_str().unwrap(); 132 305 assert_eq!(uri, "ats://did:plc:owner/app.bsky.group/default"); 133 - assert_eq!(body["isOwner"], true); 134 306 135 - // Look it up. 307 + // getSpace returns {uri, config}; config carries the default mint policy. 136 308 let (status, info) = get_json( 137 309 app, 138 310 &format!("/xrpc/com.atproto.space.getSpace?space={}", urlencode(uri)), ··· 141 313 .await; 142 314 assert_eq!(status, StatusCode::OK, "body: {info}"); 143 315 assert_eq!(info["uri"], uri); 316 + assert_eq!(info["config"]["mintPolicy"], "member-list"); 144 317 } 145 318 146 319 #[tokio::test(flavor = "multi_thread")] ··· 148 321 let (app, _tmp) = build_app().await; 149 322 let (status, _) = post_json( 150 323 app, 151 - "/xrpc/com.atproto.space.createSpace", 152 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 324 + "/xrpc/com.atproto.simplespace.createSpace", 325 + json!({"type": "app.bsky.group", "skey": "default"}), 153 326 None, 154 327 ) 155 328 .await; ··· 157 330 } 158 331 159 332 #[tokio::test(flavor = "multi_thread")] 160 - async fn add_then_list_members() { 333 + async fn create_space_auto_generates_skey() { 334 + let (app, _tmp) = build_app().await; 335 + let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 336 + let (status, body) = post_json( 337 + app, 338 + "/xrpc/com.atproto.simplespace.createSpace", 339 + json!({"type": "app.bsky.group"}), 340 + Some(&token), 341 + ) 342 + .await; 343 + assert_eq!(status, StatusCode::OK, "body: {body}"); 344 + let uri = body["uri"].as_str().unwrap(); 345 + assert!(uri.starts_with("ats://did:plc:owner/app.bsky.group/")); 346 + // The auto-generated skey is a non-empty TID. 347 + assert!(uri.rsplit('/').next().unwrap().len() >= 10); 348 + } 349 + 350 + #[tokio::test(flavor = "multi_thread")] 351 + async fn create_space_did_must_match_caller() { 352 + let (app, _tmp) = build_app().await; 353 + let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 354 + let (status, _) = post_json( 355 + app, 356 + "/xrpc/com.atproto.simplespace.createSpace", 357 + json!({"did": "did:plc:someoneelse", "type": "app.bsky.group", "skey": "x"}), 358 + Some(&token), 359 + ) 360 + .await; 361 + assert_eq!(status, StatusCode::FORBIDDEN); 362 + } 363 + 364 + #[tokio::test(flavor = "multi_thread")] 365 + async fn update_space_reflects_in_get_space() { 366 + let (app, _tmp) = build_app().await; 367 + let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 368 + let uri = create_space(&app, &token, "default").await; 369 + 370 + let (status, _) = post_json( 371 + app.clone(), 372 + "/xrpc/com.atproto.simplespace.updateSpace", 373 + json!({"space": uri, "mintPolicy": "public"}), 374 + Some(&token), 375 + ) 376 + .await; 377 + assert_eq!(status, StatusCode::OK); 378 + 379 + let (status, info) = get_json( 380 + app, 381 + &format!("/xrpc/com.atproto.space.getSpace?space={}", urlencode(&uri)), 382 + Some(&token), 383 + ) 384 + .await; 385 + assert_eq!(status, StatusCode::OK, "body: {info}"); 386 + assert_eq!(info["config"]["mintPolicy"], "public"); 387 + } 388 + 389 + #[tokio::test(flavor = "multi_thread")] 390 + async fn delete_space_then_get_space_fails() { 391 + let (app, _tmp) = build_app().await; 392 + let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 393 + let uri = create_space(&app, &token, "default").await; 394 + 395 + let (status, _) = post_json( 396 + app.clone(), 397 + "/xrpc/com.atproto.simplespace.deleteSpace", 398 + json!({"space": uri}), 399 + Some(&token), 400 + ) 401 + .await; 402 + assert_eq!(status, StatusCode::OK); 403 + 404 + // getSpace on a tombstoned space reads as SpaceNotFound. 405 + let (status, body) = get_json( 406 + app.clone(), 407 + &format!("/xrpc/com.atproto.space.getSpace?space={}", urlencode(&uri)), 408 + Some(&token), 409 + ) 410 + .await; 411 + assert!( 412 + status.is_client_error(), 413 + "expected 4xx after delete: {body}" 414 + ); 415 + assert_eq!( 416 + body["error"], "SpaceNotFound", 417 + "deleted space must read as SpaceNotFound: {body}" 418 + ); 419 + 420 + // The tombstone gate also blocks writes: applyWrites to a deleted space 421 + // must fail with SpaceNotFound (the owner is a member, so this is the 422 + // tombstone gate firing, not a membership rejection). 423 + let (status, body) = post_json( 424 + app, 425 + "/xrpc/com.atproto.space.applyWrites", 426 + json!({ 427 + "space": uri, 428 + "writes": [{ 429 + "action": "create", 430 + "collection": "app.bsky.group.message", 431 + "rkey": "x", 432 + "value": {"text": "hi"} 433 + }] 434 + }), 435 + Some(&token), 436 + ) 437 + .await; 438 + assert!( 439 + status.is_client_error(), 440 + "expected 4xx writing to deleted space: {body}" 441 + ); 442 + assert_eq!( 443 + body["error"], "SpaceNotFound", 444 + "writing to a deleted space must fail with SpaceNotFound: {body}" 445 + ); 446 + } 447 + 448 + #[tokio::test(flavor = "multi_thread")] 449 + async fn create_space_seeds_owner_in_member_list() { 450 + let (app, _tmp) = build_app().await; 451 + let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 452 + let uri = create_space(&app, &token, "default").await; 453 + 454 + let (status, body) = get_json( 455 + app, 456 + &format!( 457 + "/xrpc/com.atproto.simplespace.listMembers?space={}", 458 + urlencode(&uri) 459 + ), 460 + Some(&token), 461 + ) 462 + .await; 463 + assert_eq!(status, StatusCode::OK, "body: {body}"); 464 + let members = body["members"].as_array().unwrap(); 465 + assert_eq!(members.len(), 1); 466 + assert_eq!(members[0]["did"], "did:plc:owner"); 467 + } 468 + 469 + #[tokio::test(flavor = "multi_thread")] 470 + async fn add_remove_then_list_members() { 161 471 let (app, _tmp) = build_app().await; 162 472 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 163 473 let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 474 + let uri = create_space(&app, &owner_token, "default").await; 164 475 165 - post_json( 476 + let (status, _) = post_json( 166 477 app.clone(), 167 - "/xrpc/com.atproto.space.createSpace", 168 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 478 + "/xrpc/com.atproto.simplespace.addMember", 479 + json!({"space": uri, "did": "did:plc:alice"}), 480 + Some(&owner_token), 481 + ) 482 + .await; 483 + assert_eq!(status, StatusCode::OK); 484 + 485 + let (status, body) = get_json( 486 + app.clone(), 487 + &format!( 488 + "/xrpc/com.atproto.simplespace.listMembers?space={}", 489 + urlencode(&uri) 490 + ), 169 491 Some(&owner_token), 170 492 ) 171 493 .await; 172 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 494 + assert_eq!(status, StatusCode::OK, "body: {body}"); 495 + let dids: Vec<&str> = body["members"] 496 + .as_array() 497 + .unwrap() 498 + .iter() 499 + .map(|m| m["did"].as_str().unwrap()) 500 + .collect(); 501 + assert!(dids.contains(&"did:plc:owner")); 502 + assert!(dids.contains(&"did:plc:alice")); 173 503 504 + // Remove alice. 174 505 let (status, _) = post_json( 175 506 app.clone(), 176 - "/xrpc/com.atproto.space.addMember", 507 + "/xrpc/com.atproto.simplespace.removeMember", 177 508 json!({"space": uri, "did": "did:plc:alice"}), 178 509 Some(&owner_token), 179 510 ) ··· 183 514 let (status, body) = get_json( 184 515 app, 185 516 &format!( 186 - "/xrpc/com.atproto.space.getMembers?space={}", 187 - urlencode(uri) 517 + "/xrpc/com.atproto.simplespace.listMembers?space={}", 518 + urlencode(&uri) 188 519 ), 189 520 Some(&owner_token), 190 521 ) 191 522 .await; 192 523 assert_eq!(status, StatusCode::OK, "body: {body}"); 193 - assert_eq!(body["members"].as_array().unwrap().len(), 1); 194 - assert_eq!(body["members"][0]["did"], "did:plc:alice"); 524 + let dids: Vec<&str> = body["members"] 525 + .as_array() 526 + .unwrap() 527 + .iter() 528 + .map(|m| m["did"].as_str().unwrap()) 529 + .collect(); 530 + assert!(!dids.contains(&"did:plc:alice")); 195 531 } 196 532 197 533 #[tokio::test(flavor = "multi_thread")] ··· 199 535 let (app, _tmp) = build_app().await; 200 536 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 201 537 let eve_token = create_account_and_token(&app, "did:plc:eve", "eve.example").await; 202 - 203 - post_json( 204 - app.clone(), 205 - "/xrpc/com.atproto.space.createSpace", 206 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 207 - Some(&owner_token), 208 - ) 209 - .await; 210 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 538 + let uri = create_space(&app, &owner_token, "default").await; 211 539 212 540 let (status, _) = post_json( 213 541 app, 214 - "/xrpc/com.atproto.space.addMember", 542 + "/xrpc/com.atproto.simplespace.addMember", 215 543 json!({"space": uri, "did": "did:plc:alice"}), 216 544 Some(&eve_token), 217 545 ) ··· 219 547 assert_eq!(status, StatusCode::FORBIDDEN); 220 548 } 221 549 550 + // --------------------------------------------------------------------------- 551 + // applyWrites + single-op record writes + reads 552 + // --------------------------------------------------------------------------- 553 + 222 554 #[tokio::test(flavor = "multi_thread")] 223 555 async fn apply_writes_then_read_back() { 224 556 let (app, _tmp) = build_app().await; 225 557 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 226 558 let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 227 - 228 - post_json( 229 - app.clone(), 230 - "/xrpc/com.atproto.space.createSpace", 231 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 232 - Some(&owner_token), 233 - ) 234 - .await; 235 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 236 - 237 - // Add Alice as member (owner-side). 559 + let uri = create_space(&app, &owner_token, "default").await; 238 560 post_json( 239 561 app.clone(), 240 - "/xrpc/com.atproto.space.addMember", 562 + "/xrpc/com.atproto.simplespace.addMember", 241 563 json!({"space": uri, "did": "did:plc:alice"}), 242 564 Some(&owner_token), 243 565 ) 244 566 .await; 245 567 246 - // Alice writes a record into the permissioned repo. 247 568 let (status, body) = post_json( 248 569 app.clone(), 249 570 "/xrpc/com.atproto.space.applyWrites", ··· 263 584 assert!(!body["rev"].as_str().unwrap().is_empty()); 264 585 assert!(!body["setHash"].as_str().unwrap().is_empty()); 265 586 266 - // Read it back via Alice's OAuth. 587 + // getRecord returns {uri, cid, value}. 267 588 let (status, body) = get_json( 268 589 app, 269 590 &format!( 270 591 "/xrpc/com.atproto.space.getRecord?space={}&collection=app.bsky.group.message&rkey=first", 271 - urlencode(uri) 592 + urlencode(&uri) 272 593 ), 273 594 Some(&alice_token), 274 595 ) 275 596 .await; 276 597 assert_eq!(status, StatusCode::OK, "body: {body}"); 277 598 assert_eq!(body["value"]["text"], "hi"); 599 + assert!(body["cid"].as_str().is_some()); 600 + assert!( 601 + body["uri"] 602 + .as_str() 603 + .unwrap() 604 + .contains("app.bsky.group.message/first") 605 + ); 278 606 } 279 607 280 608 #[tokio::test(flavor = "multi_thread")] 281 - async fn get_repo_state_advances_with_writes() { 609 + async fn apply_writes_empty_batch_rejected() { 282 610 let (app, _tmp) = build_app().await; 283 611 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 284 - let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 285 - post_json( 612 + let uri = create_space(&app, &owner_token, "default").await; 613 + let (status, _) = post_json( 614 + app, 615 + "/xrpc/com.atproto.space.applyWrites", 616 + json!({"space": uri, "writes": []}), 617 + Some(&owner_token), 618 + ) 619 + .await; 620 + assert!(status.is_client_error()); 621 + } 622 + 623 + #[tokio::test(flavor = "multi_thread")] 624 + async fn create_put_delete_record_single_ops() { 625 + let (app, _tmp) = build_app().await; 626 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 627 + let uri = create_space(&app, &owner_token, "default").await; 628 + 629 + // createRecord (owner writes to its own repo). 630 + let (status, body) = post_json( 286 631 app.clone(), 287 - "/xrpc/com.atproto.space.createSpace", 288 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 632 + "/xrpc/com.atproto.space.createRecord", 633 + json!({ 634 + "space": uri, 635 + "repo": "did:plc:owner", 636 + "collection": "app.bsky.group.message", 637 + "rkey": "r1", 638 + "record": {"$type": "app.bsky.group.message", "text": "v1"} 639 + }), 640 + Some(&owner_token), 641 + ) 642 + .await; 643 + assert_eq!(status, StatusCode::OK, "createRecord: {body}"); 644 + assert!(body["cid"].as_str().is_some()); 645 + assert!( 646 + body["uri"] 647 + .as_str() 648 + .unwrap() 649 + .contains("app.bsky.group.message/r1") 650 + ); 651 + 652 + // putRecord updates the same record. 653 + let (status, body) = post_json( 654 + app.clone(), 655 + "/xrpc/com.atproto.space.putRecord", 656 + json!({ 657 + "space": uri, 658 + "repo": "did:plc:owner", 659 + "collection": "app.bsky.group.message", 660 + "rkey": "r1", 661 + "record": {"$type": "app.bsky.group.message", "text": "v2"} 662 + }), 289 663 Some(&owner_token), 290 664 ) 291 665 .await; 292 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 666 + assert_eq!(status, StatusCode::OK, "putRecord: {body}"); 293 667 294 - // Empty state first. 295 668 let (status, body) = get_json( 296 669 app.clone(), 297 670 &format!( 298 - "/xrpc/com.atproto.space.getRepoState?space={}&member=did:plc:alice", 299 - urlencode(uri) 671 + "/xrpc/com.atproto.space.getRecord?space={}&collection=app.bsky.group.message&rkey=r1", 672 + urlencode(&uri) 300 673 ), 301 - Some(&alice_token), 674 + Some(&owner_token), 302 675 ) 303 676 .await; 304 - assert_eq!(status, StatusCode::OK); 305 - assert!(body["setHash"].is_null()); 677 + assert_eq!(status, StatusCode::OK, "getRecord: {body}"); 678 + assert_eq!(body["value"]["text"], "v2"); 306 679 307 - // Write something, then state should be populated. 308 - post_json( 680 + // deleteRecord removes it. 681 + let (status, _) = post_json( 309 682 app.clone(), 310 - "/xrpc/com.atproto.space.applyWrites", 683 + "/xrpc/com.atproto.space.deleteRecord", 311 684 json!({ 312 685 "space": uri, 313 - "writes": [{ 314 - "action": "create", 315 - "collection": "c", 316 - "rkey": "k", 317 - "value": {"v": 1} 318 - }] 686 + "repo": "did:plc:owner", 687 + "collection": "app.bsky.group.message", 688 + "rkey": "r1" 319 689 }), 320 - Some(&alice_token), 690 + Some(&owner_token), 321 691 ) 322 692 .await; 323 - let (status, body) = get_json( 324 - app.clone(), 693 + assert_eq!(status, StatusCode::OK); 694 + 695 + let (status, _) = get_json( 696 + app, 325 697 &format!( 326 - "/xrpc/com.atproto.space.getRepoState?space={}&member=did:plc:alice", 327 - urlencode(uri) 698 + "/xrpc/com.atproto.space.getRecord?space={}&collection=app.bsky.group.message&rkey=r1", 699 + urlencode(&uri) 328 700 ), 329 - Some(&alice_token), 701 + Some(&owner_token), 702 + ) 703 + .await; 704 + assert_eq!(status, StatusCode::NOT_FOUND); 705 + } 706 + 707 + #[tokio::test(flavor = "multi_thread")] 708 + async fn create_record_repo_must_match_subject() { 709 + let (app, _tmp) = build_app().await; 710 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 711 + let uri = create_space(&app, &owner_token, "default").await; 712 + let (status, _) = post_json( 713 + app, 714 + "/xrpc/com.atproto.space.createRecord", 715 + json!({ 716 + "space": uri, 717 + "repo": "did:plc:someoneelse", 718 + "collection": "c", 719 + "record": {"$type": "c", "v": 1} 720 + }), 721 + Some(&owner_token), 330 722 ) 331 723 .await; 332 - assert_eq!(status, StatusCode::OK, "body: {body}"); 333 - assert!(body["setHash"].as_str().is_some()); 334 - assert!(body["rev"].as_str().is_some()); 724 + assert_eq!(status, StatusCode::FORBIDDEN); 725 + } 726 + 727 + #[tokio::test(flavor = "multi_thread")] 728 + async fn list_records_keys_only_paginated() { 729 + let (app, _tmp) = build_app().await; 730 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 731 + let uri = create_space(&app, &owner_token, "default").await; 732 + 733 + for r in ["a", "b", "c"] { 734 + post_json( 735 + app.clone(), 736 + "/xrpc/com.atproto.space.applyWrites", 737 + json!({ 738 + "space": uri, 739 + "writes": [{"action": "create", "collection": "c", "rkey": r, "value": {"k": r}}] 740 + }), 741 + Some(&owner_token), 742 + ) 743 + .await; 744 + } 335 745 336 - // Oplog should report 1 op. 337 746 let (status, body) = get_json( 338 747 app, 339 748 &format!( 340 - "/xrpc/com.atproto.space.getRepoOplog?space={}&member=did:plc:alice", 341 - urlencode(uri) 749 + "/xrpc/com.atproto.space.listRecords?space={}&collection=c&limit=2", 750 + urlencode(&uri) 342 751 ), 343 - Some(&alice_token), 752 + Some(&owner_token), 344 753 ) 345 754 .await; 346 755 assert_eq!(status, StatusCode::OK, "body: {body}"); 347 - assert_eq!(body["ops"].as_array().unwrap().len(), 1); 348 - assert_eq!(body["ops"][0]["action"], "create"); 756 + let records = body["records"].as_array().unwrap(); 757 + assert_eq!(records.len(), 2); 758 + // Keys-only shape: {collection, rkey, cid} and NO value. 759 + assert_eq!(records[0]["collection"], "c"); 760 + assert!(records[0]["rkey"].as_str().is_some()); 761 + assert!(records[0]["cid"].as_str().is_some()); 762 + assert!(records[0].get("value").is_none()); 763 + assert!(body["cursor"].as_str().is_some()); 349 764 } 350 765 351 766 #[tokio::test(flavor = "multi_thread")] 352 - async fn member_grant_then_credential_then_read() { 767 + async fn list_records_across_all_collections() { 353 768 let (app, _tmp) = build_app().await; 354 769 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 355 - let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 770 + let uri = create_space(&app, &owner_token, "default").await; 356 771 357 - post_json( 358 - app.clone(), 359 - "/xrpc/com.atproto.space.createSpace", 360 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 772 + for (collection, rkey) in [ 773 + ("app.bsky.group.message", "m1"), 774 + ("app.bsky.group.message", "m2"), 775 + ("app.bsky.group.like", "l1"), 776 + ] { 777 + post_json( 778 + app.clone(), 779 + "/xrpc/com.atproto.space.applyWrites", 780 + json!({ 781 + "space": uri, 782 + "writes": [{"action": "create", "collection": collection, "rkey": rkey, "value": {}}] 783 + }), 784 + Some(&owner_token), 785 + ) 786 + .await; 787 + } 788 + 789 + let (status, body) = get_json( 790 + app, 791 + &format!( 792 + "/xrpc/com.atproto.space.listRecords?space={}", 793 + urlencode(&uri) 794 + ), 361 795 Some(&owner_token), 362 796 ) 363 797 .await; 364 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 798 + assert_eq!(status, StatusCode::OK, "body: {body}"); 799 + assert_eq!(body["records"].as_array().unwrap().len(), 3); 800 + } 801 + 802 + #[tokio::test(flavor = "multi_thread")] 803 + async fn get_record_oauth_with_repo_override() { 804 + let (app, _tmp) = build_app().await; 805 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 806 + let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 807 + let uri = create_space(&app, &owner_token, "default").await; 365 808 post_json( 366 809 app.clone(), 367 - "/xrpc/com.atproto.space.addMember", 810 + "/xrpc/com.atproto.simplespace.addMember", 368 811 json!({"space": uri, "did": "did:plc:alice"}), 369 812 Some(&owner_token), 370 813 ) 371 814 .await; 372 815 373 - // Alice writes a record (so there's something to credential-read later). 816 + // Alice writes to her own per-actor store. 374 817 post_json( 375 818 app.clone(), 376 819 "/xrpc/com.atproto.space.applyWrites", 377 820 json!({ 378 821 "space": uri, 379 - "writes": [{ 380 - "action": "create", 381 - "collection": "c", 382 - "rkey": "k", 383 - "value": {"v": "secret"} 384 - }] 822 + "writes": [{"action": "create", "collection": "c", "rkey": "alice-1", "value": {"who": "alice"}}] 385 823 }), 386 824 Some(&alice_token), 387 825 ) 388 826 .await; 389 827 390 - // Step 1: Alice mints a MemberGrant for client-id `app1`. 391 - let (status, body) = post_json( 828 + // Owner reads with repo=alice and pulls from Alice's store. 829 + let (status, body) = get_json( 392 830 app.clone(), 393 - "/xrpc/com.atproto.space.getMemberGrant", 394 - json!({"space": uri, "clientId": "https://app.example/client-metadata.json"}), 395 - Some(&alice_token), 831 + &format!( 832 + "/xrpc/com.atproto.space.getRecord?space={}&collection=c&rkey=alice-1&repo=did:plc:alice", 833 + urlencode(&uri) 834 + ), 835 + Some(&owner_token), 396 836 ) 397 837 .await; 398 838 assert_eq!(status, StatusCode::OK, "body: {body}"); 399 - let grant = body["token"].as_str().unwrap().to_string(); 839 + assert_eq!(body["value"]["who"], "alice"); 400 840 401 - // Step 2: App swaps the grant for a SpaceCredential at the owner's PDS. 402 - let (status, body) = post_json( 403 - app.clone(), 404 - "/xrpc/com.atproto.space.getSpaceCredential", 405 - json!({"grant": grant}), 406 - None, // No auth — the grant *is* the auth. 841 + // Without repo, the owner's own store has no such record. 842 + let (status, _) = get_json( 843 + app, 844 + &format!( 845 + "/xrpc/com.atproto.space.getRecord?space={}&collection=c&rkey=alice-1", 846 + urlencode(&uri) 847 + ), 848 + Some(&owner_token), 407 849 ) 408 850 .await; 409 - assert_eq!(status, StatusCode::OK, "body: {body}"); 410 - let credential = body["token"].as_str().unwrap().to_string(); 851 + assert_eq!(status, StatusCode::NOT_FOUND); 852 + } 411 853 412 - // Step 3: App reads a record from the *owner's* per-actor store using the 413 - // credential. (Note: this reads from the owner's store, which won't have 414 - // Alice's record. So we test the negative path here — a successful 415 - // verify but no record present at the owner's store.) 854 + // --------------------------------------------------------------------------- 855 + // Sync: getRepoState / listRepoOps (signed commit) 856 + // --------------------------------------------------------------------------- 857 + 858 + #[tokio::test(flavor = "multi_thread")] 859 + async fn get_repo_state_signed_commit() { 860 + let (app, _tmp) = build_app().await; 861 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 862 + let uri = create_space(&app, &owner_token, "default").await; 863 + 864 + // Empty repo: no commit yet. 416 865 let (status, body) = get_json( 417 866 app.clone(), 418 867 &format!( 419 - "/xrpc/com.atproto.space.getRecord?space={}&collection=c&rkey=k", 420 - urlencode(uri) 868 + "/xrpc/com.atproto.space.getRepoState?space={}&repo=did:plc:owner", 869 + urlencode(&uri) 421 870 ), 422 - Some(&credential), 871 + Some(&owner_token), 423 872 ) 424 873 .await; 425 - // Expect 404 since the owner hasn't written this record (Alice did, in 426 - // her own per-actor store). The credential auth path itself succeeded — 427 - // the request reached the reader and returned RecordNotFound. 428 - assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}"); 429 - } 874 + assert_eq!(status, StatusCode::OK, "body: {body}"); 875 + assert!(body.get("commit").is_none() || body["commit"].is_null()); 430 876 431 - #[tokio::test(flavor = "multi_thread")] 432 - async fn applywrites_empty_batch_rejected() { 433 - let (app, _tmp) = build_app().await; 434 - let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 877 + // Write, then a signed commit is present. 435 878 post_json( 436 879 app.clone(), 437 - "/xrpc/com.atproto.space.createSpace", 438 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 880 + "/xrpc/com.atproto.space.applyWrites", 881 + json!({ 882 + "space": uri, 883 + "writes": [{"action": "create", "collection": "c", "rkey": "k", "value": {"v": 1}}] 884 + }), 885 + Some(&owner_token), 886 + ) 887 + .await; 888 + 889 + let (status, body) = get_json( 890 + app.clone(), 891 + &format!( 892 + "/xrpc/com.atproto.space.getRepoState?space={}&repo=did:plc:owner", 893 + urlencode(&uri) 894 + ), 439 895 Some(&owner_token), 440 896 ) 441 897 .await; 442 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 443 - let (status, _) = post_json( 898 + assert_eq!(status, StatusCode::OK, "body: {body}"); 899 + let commit = &body["commit"]; 900 + assert!(commit["rev"].as_str().is_some()); 901 + // Signed-commit byte fields are lex-data {"$bytes": <base64>}. 902 + assert!(commit["hash"]["$bytes"].as_str().is_some()); 903 + assert!(commit["mac"]["$bytes"].as_str().is_some()); 904 + assert!(commit["ikm"]["$bytes"].as_str().is_some()); 905 + assert!(commit["sig"]["$bytes"].as_str().is_some()); 906 + 907 + // listRepoOps: the single create op + a caught-up signed commit. 908 + let (status, body) = get_json( 444 909 app, 445 - "/xrpc/com.atproto.space.applyWrites", 446 - json!({"space": uri, "writes": []}), 910 + &format!( 911 + "/xrpc/com.atproto.space.listRepoOps?space={}&repo=did:plc:owner", 912 + urlencode(&uri) 913 + ), 447 914 Some(&owner_token), 448 915 ) 449 916 .await; 450 - // Either 400 InvalidRequest or 400 SpaceError — both communicate the 451 - // empty-batch problem. Just assert it's a 4xx. 452 - assert!(status.is_client_error()); 917 + assert_eq!(status, StatusCode::OK, "body: {body}"); 918 + let ops = body["ops"].as_array().unwrap(); 919 + assert_eq!(ops.len(), 1); 920 + assert_eq!(ops[0]["collection"], "c"); 921 + assert_eq!(ops[0]["rkey"], "k"); 922 + assert!(ops[0]["cid"].as_str().is_some()); 923 + assert!(body["commit"]["sig"]["$bytes"].as_str().is_some()); 924 + } 925 + 926 + // --------------------------------------------------------------------------- 927 + // Credential flow: getDelegationToken -> getSpaceCredential, mint authz 928 + // --------------------------------------------------------------------------- 929 + 930 + /// Read scope string covering the default space (`ats://did:plc:owner/app.bsky.group/default`). 931 + fn read_scope() -> String { 932 + "atproto space:app.bsky.group?did=did:plc:owner&skey=default&action=read".to_string() 453 933 } 454 934 455 935 #[tokio::test(flavor = "multi_thread")] 456 - async fn list_records_returns_paginated() { 936 + async fn delegation_token_then_space_credential_member_allowed() { 457 937 let (app, _tmp) = build_app().await; 458 938 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 459 - let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 939 + let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 940 + let uri = create_space(&app, &owner_token, "default").await; 460 941 post_json( 461 942 app.clone(), 462 - "/xrpc/com.atproto.space.createSpace", 463 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 943 + "/xrpc/com.atproto.simplespace.addMember", 944 + json!({"space": uri, "did": "did:plc:alice"}), 464 945 Some(&owner_token), 465 946 ) 466 947 .await; 467 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 468 - post_json( 948 + 949 + // Alice mints a delegation token via OAuth (needs a client_id). 950 + let alice_oauth = mint_oauth_access("did:plc:alice", &read_scope()); 951 + let (status, body) = get_json( 469 952 app.clone(), 470 - "/xrpc/com.atproto.space.addMember", 471 - json!({"space": uri, "did": "did:plc:alice"}), 472 - Some(&owner_token), 953 + &format!( 954 + "/xrpc/com.atproto.space.getDelegationToken?space={}", 955 + urlencode(&uri) 956 + ), 957 + Some(&alice_oauth), 473 958 ) 474 959 .await; 475 - for r in ["a", "b", "c"] { 476 - post_json( 477 - app.clone(), 478 - "/xrpc/com.atproto.space.applyWrites", 479 - json!({ 480 - "space": uri, 481 - "writes": [{ 482 - "action": "create", 483 - "collection": "c", 484 - "rkey": r, 485 - "value": {"k": r} 486 - }] 487 - }), 488 - Some(&alice_token), 489 - ) 490 - .await; 491 - } 960 + assert_eq!(status, StatusCode::OK, "getDelegationToken: {body}"); 961 + let grant = body["token"].as_str().unwrap().to_string(); 962 + // Output is exactly {token} (spec lines 147-176; no expiresAt field). 963 + assert!(body.get("expiresAt").is_none()); 964 + assert!(body.get("credential").is_none()); 965 + 966 + // Delegation-token shape (spec lines 152-171): typ 967 + // atproto-space-delegation+jwt, kid #atproto, sub == the space URI, 968 + // aud == <spaceDid>#atproto_space_host, no lxm, exp == iat + 60. 969 + let (dt_header, dt_payload) = decode_jwt(&grant); 970 + assert_eq!(dt_header["typ"], "atproto-space-delegation+jwt"); 971 + assert_eq!(dt_header["kid"], "#atproto"); 972 + assert_eq!(dt_payload["iss"], "did:plc:alice"); 973 + assert_eq!(dt_payload["sub"], uri); 974 + assert_eq!(dt_payload["aud"], "did:plc:owner#atproto_space_host"); 975 + assert!( 976 + dt_payload.get("lxm").is_none(), 977 + "delegation token has no lxm" 978 + ); 979 + let dt_iat = dt_payload["iat"].as_u64().unwrap(); 980 + let dt_exp = dt_payload["exp"].as_u64().unwrap(); 981 + assert_eq!(dt_exp, dt_iat + 60, "delegation token exp is iat + 60"); 982 + 983 + // Exchange the grant for a space credential (member-list + #open => allow). 984 + // The delegation token rides in the Authorization header; the body carries 985 + // only the target space. The response is {credential} (spec lines 200-251). 986 + let (status, body) = exchange_credential(&app, &grant, &uri, None).await; 987 + assert_eq!(status, StatusCode::OK, "getSpaceCredential: {body}"); 988 + let credential = body["credential"].as_str().unwrap(); 989 + // No legacy {token, expiresAt} shape. 990 + assert!(body.get("token").is_none()); 991 + assert!(body.get("expiresAt").is_none()); 992 + 993 + // Space-credential shape (spec lines 206-225): typ 994 + // atproto-space-credential+jwt, kid #atproto_space, iss == the authority, 995 + // sub == the space URI, NO aud. No attestation was presented, so client_id 996 + // is omitted (spec lines 221, 228). 997 + let (sc_header, sc_payload) = decode_jwt(credential); 998 + assert_eq!(sc_header["typ"], "atproto-space-credential+jwt"); 999 + assert_eq!(sc_header["kid"], "#atproto_space"); 1000 + assert_eq!(sc_payload["iss"], "did:plc:owner"); 1001 + assert_eq!(sc_payload["sub"], uri); 1002 + assert!( 1003 + sc_payload.get("aud").is_none(), 1004 + "space credential has no aud" 1005 + ); 1006 + assert!(sc_payload.get("client_id").is_none()); 1007 + } 492 1008 493 - let (status, body) = get_json( 1009 + #[tokio::test(flavor = "multi_thread")] 1010 + async fn delegation_token_requires_oauth_client_id() { 1011 + let (app, _tmp) = build_app().await; 1012 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1013 + let uri = create_space(&app, &owner_token, "default").await; 1014 + // App-password session token carries no client_id -> 403. 1015 + let (status, _) = get_json( 494 1016 app, 495 1017 &format!( 496 - "/xrpc/com.atproto.space.listRecords?space={}&collection=c&limit=2", 497 - urlencode(uri) 1018 + "/xrpc/com.atproto.space.getDelegationToken?space={}", 1019 + urlencode(&uri) 498 1020 ), 499 - Some(&alice_token), 1021 + Some(&owner_token), 500 1022 ) 501 1023 .await; 502 - assert_eq!(status, StatusCode::OK, "body: {body}"); 503 - assert_eq!(body["records"].as_array().unwrap().len(), 2); 504 - assert!(body["cursor"].as_str().is_some()); 1024 + assert_eq!(status, StatusCode::FORBIDDEN); 505 1025 } 506 1026 507 1027 #[tokio::test(flavor = "multi_thread")] 508 - async fn member_state_and_oplog() { 1028 + async fn space_credential_non_member_denied() { 509 1029 let (app, _tmp) = build_app().await; 510 1030 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 511 - post_json( 1031 + let _ = create_account_and_token(&app, "did:plc:eve", "eve.example").await; 1032 + let uri = create_space(&app, &owner_token, "default").await; 1033 + // Eve is NOT a member. She can still mint a delegation token (it's just a 1034 + // signed assertion), but the owner's getSpaceCredential mint authz must 1035 + // deny her under the default member-list policy. 1036 + let eve_oauth = mint_oauth_access("did:plc:eve", &read_scope()); 1037 + let (status, body) = get_json( 512 1038 app.clone(), 513 - "/xrpc/com.atproto.space.createSpace", 514 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 515 - Some(&owner_token), 1039 + &format!( 1040 + "/xrpc/com.atproto.space.getDelegationToken?space={}", 1041 + urlencode(&uri) 1042 + ), 1043 + Some(&eve_oauth), 516 1044 ) 517 1045 .await; 518 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 1046 + assert_eq!(status, StatusCode::OK, "getDelegationToken: {body}"); 1047 + let grant = body["token"].as_str().unwrap().to_string(); 1048 + 1049 + let (status, _) = exchange_credential(&app, &grant, &uri, None).await; 1050 + assert_eq!(status, StatusCode::FORBIDDEN, "non-member must be denied"); 1051 + } 1052 + 1053 + #[tokio::test(flavor = "multi_thread")] 1054 + async fn space_credential_app_allowlist_denies_unattested() { 1055 + let (app, _tmp) = build_app().await; 1056 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1057 + let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 1058 + let uri = create_space(&app, &owner_token, "default").await; 519 1059 post_json( 520 1060 app.clone(), 521 - "/xrpc/com.atproto.space.addMember", 1061 + "/xrpc/com.atproto.simplespace.addMember", 522 1062 json!({"space": uri, "did": "did:plc:alice"}), 523 1063 Some(&owner_token), 524 1064 ) 525 1065 .await; 1066 + // Tighten appAccess to an allow-list that excludes alice's client. 526 1067 post_json( 527 1068 app.clone(), 528 - "/xrpc/com.atproto.space.removeMember", 529 - json!({"space": uri, "did": "did:plc:alice"}), 1069 + "/xrpc/com.atproto.simplespace.updateSpace", 1070 + json!({ 1071 + "space": uri, 1072 + "appAccess": { 1073 + "$type": "com.atproto.simplespace.defs#allowList", 1074 + "allowed": ["https://other.example/cm.json"] 1075 + } 1076 + }), 530 1077 Some(&owner_token), 531 1078 ) 532 1079 .await; 533 1080 534 - let (status, body) = get_json( 1081 + let alice_oauth = mint_oauth_access("did:plc:alice", &read_scope()); 1082 + let (_, body) = get_json( 535 1083 app.clone(), 536 1084 &format!( 537 - "/xrpc/com.atproto.space.getMemberState?space={}", 538 - urlencode(uri) 1085 + "/xrpc/com.atproto.space.getDelegationToken?space={}", 1086 + urlencode(&uri) 539 1087 ), 540 - Some(&owner_token), 1088 + Some(&alice_oauth), 541 1089 ) 542 1090 .await; 543 - assert_eq!(status, StatusCode::OK, "body: {body}"); 544 - assert!(body["setHash"].as_str().is_some()); 1091 + let grant = body["token"].as_str().unwrap().to_string(); 545 1092 546 - let (status, body) = get_json( 547 - app, 548 - &format!( 549 - "/xrpc/com.atproto.space.getMemberOplog?space={}", 550 - urlencode(uri) 551 - ), 552 - Some(&owner_token), 553 - ) 554 - .await; 555 - assert_eq!(status, StatusCode::OK, "body: {body}"); 556 - let ops = body["ops"].as_array().unwrap(); 557 - assert_eq!(ops.len(), 2); 558 - assert_eq!(ops[0]["action"], "add"); 559 - assert_eq!(ops[1]["action"], "remove"); 560 - } 561 - 562 - fn urlencode(s: &str) -> String { 563 - s.chars() 564 - .map(|c| match c { 565 - 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(), 566 - _ => format!("%{:02X}", c as u8), 567 - }) 568 - .collect() 1093 + // No client attestation -> the #allowList app axis denies (spec lines 1094 + // 533-535: only the apps named in `allowed` may access the space). 1095 + let (status, _) = exchange_credential(&app, &grant, &uri, None).await; 1096 + assert_eq!(status, StatusCode::FORBIDDEN); 569 1097 } 570 1098 571 - /// acceptance: two `getSpaceCredential` calls from the 572 - /// same `clientId` produce one `space_credential_recipient` row (idempotent 573 - /// on `(space, service_did)`). 574 1099 #[tokio::test(flavor = "multi_thread")] 575 - async fn get_space_credential_registers_recipient_idempotently() { 1100 + async fn space_credential_registers_recipient_idempotently() { 1101 + // build_app hides the data dir, so this test constructs the app inline 1102 + // with a TempDir it can inspect afterwards. 576 1103 let tmp = TempDir::new().unwrap(); 577 - let dir = tmp.path().to_path_buf(); 578 - let accounts = AccountDirectory::open(&dir.join("accounts.sqlite")) 1104 + let data_dir = tmp.path().to_path_buf(); 1105 + let accounts = AccountDirectory::open(&data_dir.join("accounts.sqlite")) 579 1106 .await 580 1107 .unwrap(); 581 1108 let key_store: Arc<dyn KeyStore> = Arc::new(MemoryKeyStore::new()); 582 1109 let manager = Arc::new(AccountManager::new( 583 1110 accounts.pool().clone(), 584 - dir.clone(), 1111 + data_dir.clone(), 585 1112 key_store, 586 1113 KeyType::K256Private, 587 1114 )); 588 - let writer = Arc::new(RepoWriter::new(manager.clone(), dir.clone())); 589 - let reader = Arc::new(RepoReader::new(accounts, dir.clone())); 590 - let svc = Arc::new(SpaceService::with_accounts(dir.clone(), manager.clone())); 591 - let space_writer = Arc::new(SpaceWriter::new(manager.clone(), dir.clone())); 592 - let space_reader = Arc::new(SpaceReader::new(manager.clone(), dir.clone())); 593 - let space_sync = Arc::new(SpaceSync::new(dir.clone())); 1115 + let writer = Arc::new(RepoWriter::new(manager.clone(), data_dir.clone())); 1116 + let reader = Arc::new(RepoReader::new(accounts, data_dir.clone())); 1117 + let svc = Arc::new(SpaceService::with_accounts( 1118 + data_dir.clone(), 1119 + manager.clone(), 1120 + )); 1121 + let space_writer = Arc::new(SpaceWriter::new(manager.clone(), data_dir.clone())); 1122 + let space_reader = Arc::new(SpaceReader::new(manager.clone(), data_dir.clone())); 1123 + let space_sync = Arc::new(SpaceSync::new(data_dir.clone())); 594 1124 let state = HttpState::with_account_manager( 595 1125 reader, 596 - manager.clone(), 1126 + manager, 597 1127 "did:web:test.example".to_string(), 598 - b"test-secret-do-not-use-in-prod-32!".to_vec(), 1128 + JWT_SECRET.to_vec(), 599 1129 false, 600 1130 ) 601 1131 .with_writer(writer) ··· 603 1133 let app = build_router(state); 604 1134 605 1135 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 606 - let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 607 - 1136 + let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 1137 + let uri = create_space(&app, &owner_token, "default").await; 608 1138 post_json( 609 1139 app.clone(), 610 - "/xrpc/com.atproto.space.createSpace", 611 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 612 - Some(&owner_token), 613 - ) 614 - .await; 615 - let uri = "ats://did:plc:owner/app.bsky.group/default"; 616 - post_json( 617 - app.clone(), 618 - "/xrpc/com.atproto.space.addMember", 1140 + "/xrpc/com.atproto.simplespace.addMember", 619 1141 json!({"space": uri, "did": "did:plc:alice"}), 620 1142 Some(&owner_token), 621 1143 ) 622 1144 .await; 623 1145 624 - let client_id = "https://app.example/client-metadata.json"; 625 - 626 - // Call getSpaceCredential twice with the same clientId. The second 627 - // grant must be minted at a different `iat` second so its synthesized 628 - // jti differs (otherwise the replay guard rejects it). One second of 629 - // wall-clock sleep is the cheapest way to satisfy that. 630 1146 for i in 0..2 { 631 1147 if i > 0 { 632 1148 tokio::time::sleep(std::time::Duration::from_millis(1100)).await; 633 1149 } 634 - let (status, body) = post_json( 1150 + let alice_oauth = mint_oauth_access("did:plc:alice", &read_scope()); 1151 + let (_, body) = get_json( 635 1152 app.clone(), 636 - "/xrpc/com.atproto.space.getMemberGrant", 637 - json!({"space": uri, "clientId": client_id}), 638 - Some(&alice_token), 1153 + &format!( 1154 + "/xrpc/com.atproto.space.getDelegationToken?space={}", 1155 + urlencode(&uri) 1156 + ), 1157 + Some(&alice_oauth), 639 1158 ) 640 1159 .await; 641 - assert_eq!(status, StatusCode::OK, "getMemberGrant: {body}"); 642 1160 let grant = body["token"].as_str().unwrap().to_string(); 643 - 644 - let (status, body) = post_json( 645 - app.clone(), 646 - "/xrpc/com.atproto.space.getSpaceCredential", 647 - json!({"grant": grant}), 648 - None, 649 - ) 650 - .await; 1161 + let (status, body) = exchange_credential(&app, &grant, &uri, None).await; 651 1162 assert_eq!(status, StatusCode::OK, "getSpaceCredential: {body}"); 652 1163 } 653 1164 654 - // Inspect the owner's per-actor `space_credential_recipient` table — we 655 - // expect exactly one row, even after two credential mints. 656 - let owner_store = atproto_pds::actor_store::sql::SqlActorStore::open(&dir, "did:plc:owner") 657 - .await 658 - .unwrap(); 659 - let rows: Vec<(String, String, String)> = sqlx::query_as( 660 - "SELECT space, service_did, service_endpoint FROM space_credential_recipient", 661 - ) 662 - .fetch_all(owner_store.pool()) 663 - .await 664 - .unwrap(); 665 - assert_eq!( 666 - rows.len(), 667 - 1, 668 - "expected exactly one recipient row, got: {rows:?}" 669 - ); 1165 + let owner_store = 1166 + atproto_pds::actor_store::sql::SqlActorStore::open(&data_dir, "did:plc:owner") 1167 + .await 1168 + .unwrap(); 1169 + let rows: Vec<(String, String)> = 1170 + sqlx::query_as("SELECT space, service_did FROM space_credential_recipient") 1171 + .fetch_all(owner_store.pool()) 1172 + .await 1173 + .unwrap(); 1174 + assert_eq!(rows.len(), 1, "expected one recipient row, got: {rows:?}"); 670 1175 assert_eq!(rows[0].0, uri); 671 - assert_eq!(rows[0].2, "https://app.example"); 672 1176 } 673 1177 674 - /// acceptance (HTTP-end-to-end): a space write enqueues 675 - /// one `notify_attempt` row per registered recipient. We pre-seed the 676 - /// recipient table directly (so we don't need the full credential-mint 677 - /// flow), then assert the enqueue count. 1178 + // --------------------------------------------------------------------------- 1179 + // listRepos 1180 + // --------------------------------------------------------------------------- 1181 + 678 1182 #[tokio::test(flavor = "multi_thread")] 679 - async fn space_write_enqueues_one_notify_per_recipient() { 680 - let tmp = TempDir::new().unwrap(); 681 - let dir = tmp.path().to_path_buf(); 682 - let accounts = AccountDirectory::open(&dir.join("accounts.sqlite")) 683 - .await 684 - .unwrap(); 685 - let accounts_pool = accounts.pool().clone(); 686 - let key_store: Arc<dyn KeyStore> = Arc::new(MemoryKeyStore::new()); 687 - let manager = Arc::new(AccountManager::new( 688 - accounts.pool().clone(), 689 - dir.clone(), 690 - key_store, 691 - KeyType::K256Private, 692 - )); 693 - let writer = Arc::new(RepoWriter::new(manager.clone(), dir.clone())); 694 - let reader = Arc::new(RepoReader::new(accounts, dir.clone())); 695 - let svc = Arc::new(SpaceService::with_accounts(dir.clone(), manager.clone())); 696 - let space_writer = Arc::new(SpaceWriter::new(manager.clone(), dir.clone())); 697 - let space_reader = Arc::new(SpaceReader::new(manager.clone(), dir.clone())); 698 - let space_sync = Arc::new(SpaceSync::new(dir.clone())); 699 - let state = HttpState::with_account_manager( 700 - reader, 701 - manager.clone(), 702 - "did:web:test.example".to_string(), 703 - b"test-secret-do-not-use-in-prod-32!".to_vec(), 704 - false, 1183 + async fn list_repos_wrong_space_credential_rejected() { 1184 + let (app, _tmp) = build_app().await; 1185 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1186 + let uri = create_space(&app, &owner_token, "default").await; 1187 + // A credential minted for `default` does not authorize a different space 1188 + // URI: the `sub` claim must match the requested space, so the verify gate 1189 + // rejects it (401) before any SpaceNotFound lookup. 1190 + let credential = mint_space_credential(&app, "did:plc:owner", &uri).await; 1191 + let (status, _) = get_json( 1192 + app, 1193 + &format!( 1194 + "/xrpc/com.atproto.space.listRepos?space={}", 1195 + urlencode("ats://did:plc:owner/app.bsky.group/missing") 1196 + ), 1197 + Some(&credential), 705 1198 ) 706 - .with_writer(writer) 707 - .with_spaces(svc, space_writer, space_reader, space_sync); 708 - let app = build_router(state); 1199 + .await; 1200 + assert_eq!(status, StatusCode::UNAUTHORIZED); 1201 + } 709 1202 1203 + #[tokio::test(flavor = "multi_thread")] 1204 + async fn list_repos_empty_writer_set() { 1205 + let (app, _tmp) = build_app().await; 710 1206 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 711 - let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 712 - post_json( 1207 + let uri = create_space(&app, &owner_token, "default").await; 1208 + // listRepos is space-credential-only: mint a real, authority-signed 1209 + // credential for the owner (an implicit member of their own space). 1210 + let credential = mint_space_credential(&app, "did:plc:owner", &uri).await; 1211 + let (status, body) = get_json( 713 1212 app.clone(), 714 - "/xrpc/com.atproto.space.createSpace", 715 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 716 - Some(&owner_token), 1213 + &format!( 1214 + "/xrpc/com.atproto.space.listRepos?space={}", 1215 + urlencode(&uri) 1216 + ), 1217 + Some(&credential), 717 1218 ) 718 1219 .await; 719 - let uri_str = "ats://did:plc:owner/app.bsky.group/default"; 720 - post_json( 1220 + assert_eq!(status, StatusCode::OK, "body: {body}"); 1221 + // No inbound write receipts yet -> empty writer set. 1222 + assert_eq!(body["repos"].as_array().unwrap().len(), 0); 1223 + } 1224 + 1225 + #[tokio::test(flavor = "multi_thread")] 1226 + async fn list_repos_rejects_oauth_token() { 1227 + let (app, _tmp) = build_app().await; 1228 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1229 + let uri = create_space(&app, &owner_token, "default").await; 1230 + // listRepos accepts a space credential ONLY (spec XRPC table). An OAuth / 1231 + // session bearer — even the owner's — must be rejected with 401. 1232 + let oauth = mint_oauth_access("did:plc:owner", &read_scope()); 1233 + let (status, _) = get_json( 721 1234 app.clone(), 722 - "/xrpc/com.atproto.space.addMember", 723 - json!({"space": uri_str, "did": "did:plc:alice"}), 1235 + &format!( 1236 + "/xrpc/com.atproto.space.listRepos?space={}", 1237 + urlencode(&uri) 1238 + ), 1239 + Some(&oauth), 1240 + ) 1241 + .await; 1242 + assert_eq!(status, StatusCode::UNAUTHORIZED, "oauth must be rejected"); 1243 + // The owner's app-password session token is likewise rejected. 1244 + let (status, _) = get_json( 1245 + app, 1246 + &format!( 1247 + "/xrpc/com.atproto.space.listRepos?space={}", 1248 + urlencode(&uri) 1249 + ), 724 1250 Some(&owner_token), 725 1251 ) 726 1252 .await; 1253 + assert_eq!(status, StatusCode::UNAUTHORIZED, "session must be rejected"); 1254 + } 727 1255 728 - // Pre-seed two recipients on the owner's per-actor store. 729 - let owner_store = atproto_pds::actor_store::sql::SqlActorStore::open(&dir, "did:plc:owner") 730 - .await 731 - .unwrap(); 732 - let space_uri: atproto_space::types::SpaceUri = uri_str.parse().unwrap(); 733 - atproto_pds::space::notify::upsert_recipient( 734 - owner_store.pool(), 735 - &space_uri, 736 - "did:web:a.example", 737 - "https://a.example", 1256 + // --------------------------------------------------------------------------- 1257 + // Forged-credential rejection on the host/sync read methods (F1 security fix) 1258 + // --------------------------------------------------------------------------- 1259 + 1260 + /// A bearer that merely *classifies* as a space credential (correct `typ`) but 1261 + /// carries an invalid signature must be rejected — not admitted on its `typ` 1262 + /// string alone — on every host/sync read method. 1263 + #[tokio::test(flavor = "multi_thread")] 1264 + async fn forged_space_credential_rejected_on_read_methods() { 1265 + let (app, _tmp) = build_app().await; 1266 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1267 + let uri = create_space(&app, &owner_token, "default").await; 1268 + let forged = forge_space_credential("did:plc:owner", &uri); 1269 + 1270 + // getSpace 1271 + let (status, _) = get_json( 1272 + app.clone(), 1273 + &format!("/xrpc/com.atproto.space.getSpace?space={}", urlencode(&uri)), 1274 + Some(&forged), 738 1275 ) 739 - .await 740 - .unwrap(); 741 - atproto_pds::space::notify::upsert_recipient( 742 - owner_store.pool(), 743 - &space_uri, 744 - "did:web:b.example", 745 - "https://b.example", 746 - ) 747 - .await 748 - .unwrap(); 1276 + .await; 1277 + assert_eq!(status, StatusCode::UNAUTHORIZED, "getSpace forged"); 749 1278 750 - // Snapshot the queue length before the write. 751 - let before: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_attempt") 752 - .fetch_one(&accounts_pool) 753 - .await 754 - .unwrap(); 1279 + // getRepoState 1280 + let (status, _) = get_json( 1281 + app.clone(), 1282 + &format!( 1283 + "/xrpc/com.atproto.space.getRepoState?space={}&repo=did:plc:owner", 1284 + urlencode(&uri) 1285 + ), 1286 + Some(&forged), 1287 + ) 1288 + .await; 1289 + assert_eq!(status, StatusCode::UNAUTHORIZED, "getRepoState forged"); 755 1290 756 - // Alice writes a record. 757 - let (status, body) = post_json( 1291 + // listRepoOps 1292 + let (status, _) = get_json( 758 1293 app.clone(), 759 - "/xrpc/com.atproto.space.applyWrites", 760 - json!({ 761 - "space": uri_str, 762 - "writes": [{"action": "create", "collection": "c", "rkey": "k", "value": {"v": 1}}] 763 - }), 764 - Some(&alice_token), 1294 + &format!( 1295 + "/xrpc/com.atproto.space.listRepoOps?space={}&repo=did:plc:owner", 1296 + urlencode(&uri) 1297 + ), 1298 + Some(&forged), 765 1299 ) 766 1300 .await; 767 - assert_eq!(status, StatusCode::OK, "applyWrites: {body}"); 1301 + assert_eq!(status, StatusCode::UNAUTHORIZED, "listRepoOps forged"); 768 1302 769 - let after: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_attempt") 770 - .fetch_one(&accounts_pool) 771 - .await 772 - .unwrap(); 773 - // 2 new rows (one per recipient) for the write commit. addMember 774 - // happened before the seed so it didn't fan out (and it has no recipient 775 - // entries until we seeded them). 776 - assert_eq!(after.0 - before.0, 2); 1303 + // listRepos 1304 + let (status, _) = get_json( 1305 + app, 1306 + &format!( 1307 + "/xrpc/com.atproto.space.listRepos?space={}", 1308 + urlencode(&uri) 1309 + ), 1310 + Some(&forged), 1311 + ) 1312 + .await; 1313 + assert_eq!(status, StatusCode::UNAUTHORIZED, "listRepos forged"); 777 1314 } 778 1315 779 - /// acceptance (membership variant): owner-side 780 - /// `addMember` after the recipient table is populated enqueues one 781 - /// `notifyMembership` row per recipient. 1316 + // --------------------------------------------------------------------------- 1317 + // getBlob gating 1318 + // --------------------------------------------------------------------------- 1319 + 782 1320 #[tokio::test(flavor = "multi_thread")] 783 - async fn add_member_enqueues_membership_notification() { 784 - let tmp = TempDir::new().unwrap(); 785 - let dir = tmp.path().to_path_buf(); 786 - let accounts = AccountDirectory::open(&dir.join("accounts.sqlite")) 787 - .await 788 - .unwrap(); 789 - let accounts_pool = accounts.pool().clone(); 790 - let key_store: Arc<dyn KeyStore> = Arc::new(MemoryKeyStore::new()); 791 - let manager = Arc::new(AccountManager::new( 792 - accounts.pool().clone(), 793 - dir.clone(), 794 - key_store, 795 - KeyType::K256Private, 796 - )); 797 - let writer = Arc::new(RepoWriter::new(manager.clone(), dir.clone())); 798 - let reader = Arc::new(RepoReader::new(accounts, dir.clone())); 799 - let svc = Arc::new(SpaceService::with_accounts(dir.clone(), manager.clone())); 800 - let space_writer = Arc::new(SpaceWriter::new(manager.clone(), dir.clone())); 801 - let space_reader = Arc::new(SpaceReader::new(manager.clone(), dir.clone())); 802 - let space_sync = Arc::new(SpaceSync::new(dir.clone())); 803 - let state = HttpState::with_account_manager( 804 - reader, 805 - manager.clone(), 806 - "did:web:test.example".to_string(), 807 - b"test-secret-do-not-use-in-prod-32!".to_vec(), 808 - false, 1321 + async fn get_blob_requires_repo_and_gates_auth() { 1322 + let (app, _tmp) = build_app().await; 1323 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1324 + let uri = create_space(&app, &owner_token, "default").await; 1325 + 1326 + // Missing bearer -> unauthorized. 1327 + let (status, _) = get_json( 1328 + app.clone(), 1329 + &format!( 1330 + "/xrpc/com.atproto.space.getBlob?space={}&repo=did:plc:owner&cid=bafkreigh2akiscaildc", 1331 + urlencode(&uri) 1332 + ), 1333 + None, 809 1334 ) 810 - .with_writer(writer) 811 - .with_spaces(svc, space_writer, space_reader, space_sync); 812 - let app = build_router(state); 1335 + .await; 1336 + assert_eq!(status, StatusCode::UNAUTHORIZED); 813 1337 814 - let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 815 - let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await; 816 - post_json( 817 - app.clone(), 818 - "/xrpc/com.atproto.space.createSpace", 819 - json!({"spaceType": "app.bsky.group", "spaceKey": "default"}), 1338 + // Authed but no such blob -> BlobNotFound (404). This proves the auth 1339 + // gate passed and the reader was reached. 1340 + let (status, body) = get_json( 1341 + app, 1342 + &format!( 1343 + "/xrpc/com.atproto.space.getBlob?space={}&repo=did:plc:owner&cid=bafkreigh2akiscaildc", 1344 + urlencode(&uri) 1345 + ), 820 1346 Some(&owner_token), 821 1347 ) 822 1348 .await; 823 - let uri_str = "ats://did:plc:owner/app.bsky.group/default"; 824 - let space_uri: atproto_space::types::SpaceUri = uri_str.parse().unwrap(); 1349 + assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}"); 1350 + assert_eq!(body["error"], "BlobNotFound"); 1351 + } 1352 + 1353 + // --------------------------------------------------------------------------- 1354 + // registerNotify 1355 + // --------------------------------------------------------------------------- 825 1356 826 - // Seed one recipient before the membership op so the addMember below 827 - // produces exactly one notify row. 828 - let owner_store = atproto_pds::actor_store::sql::SqlActorStore::open(&dir, "did:plc:owner") 829 - .await 830 - .unwrap(); 831 - atproto_pds::space::notify::upsert_recipient( 832 - owner_store.pool(), 833 - &space_uri, 834 - "did:web:appview.example", 835 - "https://appview.example", 1357 + #[tokio::test(flavor = "multi_thread")] 1358 + async fn register_notify_requires_space_credential() { 1359 + let (app, _tmp) = build_app().await; 1360 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1361 + let uri = create_space(&app, &owner_token, "default").await; 1362 + // A plain session token is not a space credential -> 401. 1363 + let (status, _) = post_json( 1364 + app, 1365 + "/xrpc/com.atproto.space.registerNotify", 1366 + json!({"space": uri, "endpoint": "https://consumer.example"}), 1367 + Some(&owner_token), 836 1368 ) 837 - .await 838 - .unwrap(); 1369 + .await; 1370 + assert_eq!(status, StatusCode::UNAUTHORIZED); 1371 + } 839 1372 840 - let before: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_attempt") 841 - .fetch_one(&accounts_pool) 842 - .await 843 - .unwrap(); 1373 + // --------------------------------------------------------------------------- 1374 + // Inbound notify endpoints (service auth required) 1375 + // --------------------------------------------------------------------------- 844 1376 845 - post_json( 1377 + #[tokio::test(flavor = "multi_thread")] 1378 + async fn notify_write_requires_service_auth() { 1379 + let (app, _tmp) = build_app().await; 1380 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1381 + let uri = create_space(&app, &owner_token, "default").await; 1382 + // Contentless payload, but no bearer -> unauthorized. 1383 + let (status, _) = post_json( 846 1384 app.clone(), 847 - "/xrpc/com.atproto.space.addMember", 848 - json!({"space": uri_str, "did": "did:plc:alice"}), 849 - Some(&owner_token), 1385 + "/xrpc/com.atproto.space.notifyWrite", 1386 + json!({"space": uri, "repo": "did:plc:writer", "rev": "3kabc"}), 1387 + None, 850 1388 ) 851 1389 .await; 1390 + assert_eq!(status, StatusCode::UNAUTHORIZED); 852 1391 853 - let after: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_attempt") 854 - .fetch_one(&accounts_pool) 855 - .await 856 - .unwrap(); 857 - assert_eq!(after.0 - before.0, 1); 1392 + // A session token is not service auth -> rejected (4xx). 1393 + let (status, _) = post_json( 1394 + app, 1395 + "/xrpc/com.atproto.space.notifyWrite", 1396 + json!({"space": uri, "repo": "did:plc:writer", "rev": "3kabc"}), 1397 + Some(&owner_token), 1398 + ) 1399 + .await; 1400 + assert!( 1401 + status.is_client_error(), 1402 + "session token must not pass service auth" 1403 + ); 1404 + } 858 1405 859 - let (target_did, nsid): (String, String) = sqlx::query_as( 860 - "SELECT target_service_did, nsid FROM notify_attempt ORDER BY next_attempt_at DESC LIMIT 1", 1406 + #[tokio::test(flavor = "multi_thread")] 1407 + async fn notify_space_deleted_requires_service_auth() { 1408 + let (app, _tmp) = build_app().await; 1409 + let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await; 1410 + let uri = create_space(&app, &owner_token, "default").await; 1411 + let (status, _) = post_json( 1412 + app, 1413 + "/xrpc/com.atproto.space.notifySpaceDeleted", 1414 + json!({"space": uri}), 1415 + None, 861 1416 ) 862 - .fetch_one(&accounts_pool) 863 - .await 864 - .unwrap(); 865 - assert_eq!(target_did, "did:web:appview.example"); 866 - assert_eq!(nsid, "com.atproto.space.notifyMembership"); 1417 + .await; 1418 + assert_eq!(status, StatusCode::UNAUTHORIZED); 867 1419 }
+7 -4
crates/atproto-pds/tests/notifier_e2e.rs
··· 31 31 32 32 async fn spawn_test_server() -> (Counter, std::net::SocketAddr) { 33 33 let counter = Counter::default(); 34 + // The notifier delivers contentless `notifyWrite` events to registered 35 + // recipients (spec lines 343-351). The member-sync `notifyMembership` route 36 + // was removed in the 0016 re-alignment, so only `notifyWrite` is served. 34 37 let app: Router = Router::new() 35 38 .route("/xrpc/com.atproto.space.notifyWrite", post(count_handler)) 36 - .route( 37 - "/xrpc/com.atproto.space.notifyMembership", 38 - post(count_handler), 39 - ) 40 39 .with_state(counter.clone()); 41 40 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); 42 41 let addr = listener.local_addr().unwrap(); ··· 64 63 &endpoint, 65 64 b"payload-bytes".to_vec(), 66 65 "com.atproto.space.notifyWrite", 66 + "application/json", 67 + None, 67 68 ) 68 69 .await 69 70 .unwrap(); ··· 131 132 &format!("http://{addr}"), 132 133 b"x".to_vec(), 133 134 "com.atproto.space.notifyWrite", 135 + "application/json", 136 + None, 134 137 ) 135 138 .await 136 139 .unwrap();
+1 -1
crates/atproto-record/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-record" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol record signature operations - cryptographic signing and verification for AT Protocol records" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-repo/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-repo" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol repository handling - CAR v1 serialization and Merkle Search Tree operations" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+3 -14
crates/atproto-space/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-space" 3 - version = "0.15.0-alpha.1" 4 - description = "AT Protocol permissioned-data spaces — primitives for the Spaces Design Spec (SetHash, signed commits, MemberGrant/SpaceCredential JWTs)" 3 + version = "0.15.0-alpha.2" 4 + description = "AT Protocol permissioned-data spaces — primitives for the 0016 Permissioned Data draft (LtHash, signed commits, delegation-token/space-credential JWTs)" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates" 7 7 documentation = "https://docs.rs/atproto-space" ··· 22 22 anyhow.workspace = true 23 23 async-trait.workspace = true 24 24 base64.workspace = true 25 + blake3.workspace = true 25 26 chrono.workspace = true 26 27 hkdf = "0.13" 27 28 hmac = "0.13" ··· 33 34 thiserror.workspace = true 34 35 tracing.workspace = true 35 36 36 - # `ecmh` feature: pulls in k256 for the EcmhSetHash impl (per Spaces Design 37 - # Spec §ECMH). Gated so default builds stay lightweight; flip the default in 38 - # Phase 8 once ECMH is the chosen primitive workspace-wide. 39 - k256 = { workspace = true, optional = true, features = ["arithmetic"] } 40 - 41 37 [dev-dependencies] 42 - criterion.workspace = true 43 38 proptest.workspace = true 44 39 tokio = { workspace = true, features = ["macros", "rt"] } 45 40 46 41 [features] 47 42 default = [] 48 - ecmh = ["dep:k256"] 49 - 50 - [[bench]] 51 - name = "set_hash" 52 - harness = false 53 - required-features = ["ecmh"] 54 43 55 44 [lints] 56 45 workspace = true
+27 -48
crates/atproto-space/README.md
··· 3 3 AT Protocol permissioned-data spaces — protocol primitives. 4 4 5 5 This crate implements the cryptographic and orchestration primitives from the 6 - [Spaces Design Spec](https://github.com/bluesky-social/atproto/blob/main/docs/superpowers/specs/2026-04-22-permissioned-data-pds-design.md): 7 - SetHash commitments, signed commits with HKDF-derived HMAC + ECDSA + per-commit 8 - random IKM (for deniability), domain-separated record vs member commitments, 9 - and the two-step `MemberGrant` → `SpaceCredential` JWT exchange. 6 + [0016 Permissioned Data draft](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data), 7 + which is the authoritative alignment target for this crate: SetHash 8 + commitments, signed commits with HKDF-derived HMAC + ECDSA + per-commit random 9 + IKM (for deniability), and the two-step delegation-token → space-credential JWT 10 + exchange. 10 11 11 - > **Status: experimental**. The Spaces Design Spec is still settling; several 12 - > primitives (notably `SetHash`) are explicitly placeholder until upstream 13 - > picks ECMH or ltHash. The current default is `XorSha256SetHash`. 12 + > **Status: experimental**. The 0016 Permissioned Data draft is still settling. 13 + > The production `SetHash` is `LtHash`, the lattice hash the spec selects (spec 14 + > § "Commit digest"). 14 15 15 16 ## Modules 16 17 17 - - `set_hash` — `SetHash` trait + `XorSha256SetHash` placeholder. 18 - - `set_hash_ecmh` *(feature `ecmh`)* — `EcmhSetHash` over secp256k1: scalar-mul 19 - multiset hash with property-tested homomorphic invariants. SEC1-compressed 20 - digest. Pulls in k256. 21 - - `commit` — `SpaceContext`, `Commit`, `create_commit`, `verify_commit` with 22 - HKDF + HMAC + ECDSA construction. Domain-separated `Records` vs `Members` 23 - via `CommitScope`. 18 + - `set_hash` — `SetHash` trait + `LtHash` production primitive. 19 + - `commit` — `SpaceContext`, `Commit`, `create_commit`, `verify_commit`. A 20 + commit signs only the per-commit context (`ctx`) and binds the set-hash 21 + digest with an HKDF-keyed HMAC, so a leaked commit is deniable (spec 22 + § "Commit signature", lines 285-316). 24 23 - `space_repo` — `SpaceRepo<S, H>` orchestrator over the storage trait surface 25 24 for per-(user, space) record CRUD. 26 - - `space_members` — `SpaceMembers<S, H>` orchestrator (owner-only). 27 - - `credential` — `MemberGrant` and `SpaceCredential` JWT mint/verify. 28 - - `recon` — `Reconciler` / `Sketch` traits + `oplog_catchup` baseline impl. 29 - RIBLT impl is deferred; the trait surface preserves the call sites for the 30 - eventual swap. 25 + - `space_members` — `SpaceMembers<S, H>` member-list orchestrator backing the 26 + `simplespace` `member-list` mint policy (the spec carries no member commits). 27 + - `credential` — `DelegationToken` and `SpaceCredential` JWT mint/verify (spec 28 + § "Access control", lines 136-251). 31 29 - `storage` — `SpaceRepoStorage`, `SpaceMembersStorage` traits. 32 - - `types` — `SpaceUri`, `SpaceType`, `SpaceKey` newtypes. 30 + - `types` — `SpaceUri`, `RecordUri`, `SpaceType`, `SpaceKey` newtypes. 33 31 - `errors` — `SpaceError` enum with `error-atproto-space-<domain>-<n>` IDs. 34 32 35 - ## Cargo features 36 - 37 - | Feature | Default | Description | 38 - |---|---|---| 39 - | `ecmh` | | Build the `EcmhSetHash` impl over k256/secp256k1. | 40 - 41 - ## Benchmarks 42 - 43 - Criterion-driven comparison of `XorSha256SetHash` vs `EcmhSetHash` is in 44 - `benches/set_hash.rs`. Run with: 45 - 46 - ```bash 47 - cargo bench -p atproto-space --features ecmh 48 - ``` 49 - 50 - Three groups: `add_throughput` (1 / 100 / 1000 elements), `add_remove_round_trip` 51 - (single-element flush), and `digest_serialization` (digest + from_digest). 52 - 53 33 ## Quick start 54 34 55 35 ```rust,ignore 56 36 use atproto_space::{ 57 - SpaceUri, SpaceType, SpaceKey, SpaceContext, CommitScope, 58 - XorSha256SetHash, SetHash, create_commit, verify_commit, 37 + SpaceUri, SpaceType, SpaceKey, SpaceContext, 38 + LtHash, SetHash, create_commit, verify_commit, 59 39 }; 60 40 use atproto_identity::key::{KeyType, generate_key, identify_key}; 61 41 ··· 68 48 SpaceKey::new("default")?, 69 49 ); 70 50 71 - let mut hash = XorSha256SetHash::empty(); 72 - hash.add(b"app.bsky.feed.post/3jui:bafy123"); 51 + let mut hash = LtHash::empty(); 52 + // Each record element is `{collection}/{rkey}/{record_cid}` (spec line 270). 53 + hash.add(b"app.bsky.feed.post/3jui/bafy123"); 73 54 55 + // The signed context is the space URI + revision (spec lines 292-297); the 56 + // set-hash digest is bound by the commit's MAC, not signed directly. 74 57 let context = SpaceContext { 75 - space_did: "did:plc:owner".to_string(), 76 - space_type: "app.bsky.group".to_string(), 77 - space_key: "default".to_string(), 78 - user_did: "did:plc:alice".to_string(), 79 - scope: CommitScope::Records, 58 + space: space.to_string(), 80 59 rev: "3jui7kd2z2y2e".to_string(), 81 60 }; 82 61 83 62 let commit = create_commit(&hash, &context, &private_key)?; 84 - // commit.set_hash, commit.rev, commit.ikm, commit.tag, commit.sig 63 + // commit.hash, commit.ikm, commit.sig, commit.mac, commit.rev 85 64 86 65 # Ok::<(), anyhow::Error>(()) 87 66 ```
+241 -221
crates/atproto-space/src/commit.rs
··· 1 1 //! Permissioned-data commit construction. 2 2 //! 3 + //! A signed commit summarizes the current state of a permissioned repo. It is 4 + //! built to match the 0016 Permissioned Data draft (§ Commit signature): 3 5 //! 6 + //! ```text 7 + //! hash := setHash.digest() // sha256 of the 2048-byte LtHash state (32 bytes) 8 + //! ikm := random(32 bytes) // per-commit, fresh 9 + //! ctx := encode_ctx(space, rev, ikm) // TLS-1.3-style vlv (below) 10 + //! sig := sign(ctx) // user's atproto signing key, over the full ctx 11 + //! mac := HMAC-SHA256(HKDF-SHA256(ikm, ctx), hash) // binds the repo hash to this commit's context 12 + //! commit := { hash, mac, ikm, sig, rev } 13 + //! ``` 14 + //! 15 + //! `ctx` is the single context string reused for both the signature and the 16 + //! MAC's HKDF info. It uses the TLS 1.3 (§3.4) variable-length-vector encoding: 17 + //! a fixed protocol tag followed by each field length-prefixed with a 18 + //! big-endian `uint16`, per spec lines 288–297: 4 19 //! 5 20 //! ```text 6 - //! ikm := random(32 bytes) // per-commit, fresh 7 - //! hkdf_info := DAG-CBOR(SpaceContext) 8 - //! hmac_key := HKDF-Extract-then-Expand(SHA256, ikm, info=hkdf_info, len=32) 9 - //! tag := HMAC-SHA-256(hmac_key, set_hash || rev) 10 - //! sig_bytes := tag || rev.as_bytes() 11 - //! sig := ECDSA-low-S(user_signing_key, SHA256(sig_bytes)) 12 - //! commit := { set_hash, rev, ikm, tag, sig } 21 + //! ctx = "atproto-space-v1" // fixed protocol tag, NO length prefix 22 + //! || uint16be(len(space)) || space // space URI (ats://authority/type/skey) 23 + //! || uint16be(len(rev)) || rev // commit revision (TID) 24 + //! || uint16be(len(ikm)) || ikm // per-commit nonce 13 25 //! ``` 14 26 //! 15 - //! The IKM is included in the commit so a verifier with the relevant 16 - //! `SpaceContext` can recompute and check; outside that context, the IKM is 17 - //! meaningless. This is the deniability mechanism per the spec. 27 + //! Deniability: the signature covers only the random `ctx` (which binds 28 + //! `space`, `rev`, and the public `ikm` — never the repo hash), so a leaked 29 + //! commit proves nothing about repo contents. The repo hash is bound to the 30 + //! context by the *symmetric* MAC (key derived from the public `ikm`), so anyone 31 + //! holding the commit can recompute a valid MAC for any hash — authenticity of 32 + //! the *content* is not transferable (spec lines 286, 305). 18 33 //! 19 - //! `SpaceContext.scope` (Records vs Members) provides domain separation: a 20 - //! commit signed in one scope must fail verification in the other. 34 + //! [`verify_commit`] recomputes the MAC and compares it; a consumer that wants 35 + //! authenticity additionally calls [`verify_commit_signature`], which verifies 36 + //! `sig` over the reconstructed `ctx`. 21 37 22 38 use crate::errors::{SpaceError, SpaceResult}; 23 39 use crate::set_hash::SetHash; ··· 26 42 use hmac::{Hmac, KeyInit, Mac}; 27 43 use rand::RngExt; 28 44 use serde::{Deserialize, Serialize}; 29 - use sha2::{Digest, Sha256}; 45 + use sha2::Sha256; 30 46 31 47 type HmacSha256 = Hmac<Sha256>; 32 48 33 - /// Scope discriminator for `SpaceContext` — provides domain separation between 34 - /// record-set commits and member-list commits. 35 - /// 36 - /// **Critical security property**: a commit signed with `Records` scope must 37 - /// fail verification under `Members` scope and vice versa. 38 - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 39 - #[serde(rename_all = "lowercase")] 40 - pub enum CommitScope { 41 - /// Records-set commit (per-user, per-space). 42 - Records, 43 - /// Member-list commit (owner-only). 44 - Members, 45 - } 46 - 47 - impl CommitScope { 48 - /// Spec-defined string value used in the HKDF info bytes. 49 - #[must_use] 50 - pub fn as_str(self) -> &'static str { 51 - match self { 52 - CommitScope::Records => "records", 53 - CommitScope::Members => "members", 54 - } 55 - } 56 - } 49 + /// Fixed domain-separation tag prefixing the commit `ctx`. 50 + const DOMAIN_PREFIX: &[u8] = b"atproto-space-v1"; 57 51 58 - /// Context for HKDF derivation of the commit's HMAC key. 52 + /// Context bound into a commit via the signature and the MAC's HKDF info. 59 53 /// 60 - /// Encoded as DAG-CBOR and used as the HKDF `info` parameter. 54 + /// Per the 0016 Permissioned Data draft (spec lines 288–297) the on-the-wire 55 + /// `ctx` is `[space, rev, ikm]` length-prefixed; the `ikm` is supplied per 56 + /// commit (it is part of the [`Commit`]), so this struct carries only the 57 + /// stable `[space, rev]` pair. 61 58 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 62 59 pub struct SpaceContext { 63 - /// Owner DID of the space. 64 - #[serde(rename = "spaceDid")] 65 - pub space_did: String, 66 - /// Space type (NSID). 67 - #[serde(rename = "spaceType")] 68 - pub space_type: String, 69 - /// Space key. 70 - #[serde(rename = "spaceKey")] 71 - pub space_key: String, 72 - /// Committer DID — the user who wrote this commit. 73 - #[serde(rename = "userDid")] 74 - pub user_did: String, 75 - /// Scope discriminator (Records or Members). 76 - pub scope: CommitScope, 60 + /// Full space URI (`ats://{spaceDid}/{spaceType}/{skey}`). 61 + pub space: String, 77 62 /// Rev (TID) for this commit. 78 63 pub rev: String, 79 64 } 80 65 81 - impl SpaceContext { 82 - /// Encode as DAG-CBOR for use as the HKDF `info` parameter. 83 - fn to_cbor(&self) -> SpaceResult<Vec<u8>> { 84 - atproto_dasl::to_vec(self).map_err(|e| SpaceError::ContextEncoding { 85 - reason: e.to_string(), 86 - }) 66 + /// Encode the commit `ctx` (signature message + HKDF info). 67 + /// 68 + /// `"atproto-space-v1"` followed by each field length-prefixed with a 69 + /// big-endian `uint16`, in the order `[space, rev, ikm]` (spec lines 293–297). 70 + #[must_use] 71 + pub fn encode_ctx(context: &SpaceContext, ikm: &[u8]) -> Vec<u8> { 72 + let space = context.space.as_bytes(); 73 + let rev = context.rev.as_bytes(); 74 + let mut out = Vec::with_capacity(DOMAIN_PREFIX.len() + space.len() + rev.len() + ikm.len() + 6); 75 + out.extend_from_slice(DOMAIN_PREFIX); 76 + for field in [space, rev, ikm] { 77 + out.extend_from_slice(&(field.len() as u16).to_be_bytes()); 78 + out.extend_from_slice(field); 87 79 } 80 + out 88 81 } 89 82 90 - /// A signed permissioned-data commit. 83 + /// A signed permissioned-data commit (`com.atproto.space.defs#signedCommit`). 91 84 /// 92 - /// Includes the IKM in the clear so verifiers with `SpaceContext` can 93 - /// recompute the HMAC; the IKM-randomness gives deniability outside context. 85 + /// Wire field order matches the lexicon required set `[hash, mac, ikm, sig, rev]`. 94 86 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 95 87 pub struct Commit { 96 - /// SetHash digest at this commit. 97 - #[serde(rename = "setHash", with = "serde_bytes")] 98 - pub set_hash: Vec<u8>, 99 - /// Rev (TID). 100 - pub rev: String, 101 - /// Per-commit fresh IKM (32 bytes). 88 + /// `sha256` of the LtHash state (32 bytes). 89 + #[serde(with = "serde_bytes")] 90 + pub hash: Vec<u8>, 91 + /// `HMAC-SHA256` over `hash`, keyed by `HKDF-Expand(ikm, ctx)`. 92 + #[serde(with = "serde_bytes")] 93 + pub mac: Vec<u8>, 94 + /// Per-commit fresh IKM (32 bytes), also bound into the `ctx`. 102 95 #[serde(with = "serde_bytes")] 103 96 pub ikm: Vec<u8>, 104 - /// HMAC-SHA-256 tag over `set_hash || rev` keyed by HKDF(ikm, SpaceContext). 105 - #[serde(with = "serde_bytes")] 106 - pub tag: Vec<u8>, 107 - /// ECDSA signature over SHA256(tag || rev), low-S normalized. 97 + /// Signature over `ctx` by the user's atproto signing key. 108 98 #[serde(with = "serde_bytes")] 109 99 pub sig: Vec<u8>, 100 + /// Commit revision (TID), also bound into the `ctx`. 101 + pub rev: String, 110 102 } 111 103 112 - /// Construct a signed commit from a SetHash digest and SpaceContext. 104 + /// Construct a signed commit from a SetHash and `SpaceContext`. 113 105 /// 114 - /// Uses a fresh per-commit IKM drawn from the OS RNG (`rand::rng()`). 106 + /// Uses a fresh per-commit IKM drawn from the OS RNG. 115 107 /// 116 108 /// # Errors 117 109 /// 118 110 /// - [`SpaceError::Hkdf`] — HKDF derivation failure. 119 - /// - [`SpaceError::Signature`] — signing failure (key/curve mismatch, etc.). 120 - /// - [`SpaceError::ContextEncoding`] — CBOR encoding of `SpaceContext` failed. 111 + /// - [`SpaceError::Signature`] — signing failure (e.g. a public key was given). 112 + /// - [`SpaceError::ContextEncoding`] — `rev` is empty. 121 113 pub fn create_commit<H: SetHash>( 122 114 set_hash: &H, 123 115 context: &SpaceContext, ··· 128 120 create_commit_with_ikm(&set_hash.digest(), context, signing_key, &ikm) 129 121 } 130 122 131 - /// Like [`create_commit`] but takes an explicit IKM. For deterministic tests 132 - /// or for re-creating commits with caller-controlled randomness. 123 + /// Like [`create_commit`] but takes the commit `hash` and an explicit IKM. 124 + /// 125 + /// For deterministic tests or caller-controlled randomness. 133 126 #[doc(hidden)] 134 127 pub fn create_commit_with_ikm( 135 - set_hash: &[u8], 128 + hash: &[u8], 136 129 context: &SpaceContext, 137 130 signing_key: &KeyData, 138 131 ikm: &[u8; 32], 139 132 ) -> SpaceResult<Commit> { 140 - if context.rev != context.rev.trim() || context.rev.is_empty() { 133 + if context.rev.is_empty() { 141 134 return Err(SpaceError::ContextEncoding { 142 - reason: "rev must be non-empty and untrimmed".to_string(), 135 + reason: "rev must be non-empty".to_string(), 143 136 }); 144 137 } 145 138 146 - // 2. HKDF-derive HMAC key with DAG-CBOR(SpaceContext) as info. 147 - let info = context.to_cbor()?; 148 - let hkdf = Hkdf::<Sha256>::new(None, ikm); 139 + let ctx = encode_ctx(context, ikm); 140 + let mac = derive_mac(ikm, hash, &ctx)?; 141 + // Per spec line 302: the signature is over the full `ctx` (space, rev, ikm) 142 + // — never the hash — so a leaked commit is deniable. 143 + let sig = identity_sign(signing_key, &ctx).map_err(|e| SpaceError::Signature { 144 + reason: e.to_string(), 145 + })?; 146 + 147 + Ok(Commit { 148 + hash: hash.to_vec(), 149 + mac, 150 + ikm: ikm.to_vec(), 151 + sig, 152 + rev: context.rev.clone(), 153 + }) 154 + } 155 + 156 + /// Derive `HMAC-SHA256(HKDF-Expand(ikm, ctx), hash)`. 157 + /// 158 + /// HKDF is **expand-only** (ikm is the PRK; no extract step, no salt), per spec 159 + /// line 303 (`HKDF-SHA256(ikm, ctx)`). 160 + fn derive_mac(ikm: &[u8], hash: &[u8], ctx: &[u8]) -> SpaceResult<Vec<u8>> { 161 + let hkdf = Hkdf::<Sha256>::from_prk(ikm).map_err(|e| SpaceError::Hkdf { 162 + reason: format!("invalid prk: {e}"), 163 + })?; 149 164 let mut hmac_key = [0u8; 32]; 150 - hkdf.expand(&info, &mut hmac_key) 165 + hkdf.expand(ctx, &mut hmac_key) 151 166 .map_err(|e| SpaceError::Hkdf { 152 167 reason: e.to_string(), 153 168 })?; 154 - 155 - // 3. HMAC-SHA-256 over set_hash || rev. 156 169 let mut mac = 157 170 <HmacSha256 as KeyInit>::new_from_slice(&hmac_key).map_err(|e| SpaceError::Hkdf { 158 - reason: format!("hmac key length: {}", e), 171 + reason: format!("hmac key length: {e}"), 159 172 })?; 160 - mac.update(set_hash); 161 - mac.update(context.rev.as_bytes()); 162 - let tag = mac.finalize().into_bytes(); 163 - 164 - // 4. ECDSA sign SHA256(tag || rev) with the user's signing key. 165 - let mut sig_input = Vec::with_capacity(tag.len() + context.rev.len()); 166 - sig_input.extend_from_slice(&tag); 167 - sig_input.extend_from_slice(context.rev.as_bytes()); 168 - let sig_hash = Sha256::digest(&sig_input); 169 - 170 - let sig = identity_sign(signing_key, &sig_hash).map_err(|e| SpaceError::Signature { 171 - reason: e.to_string(), 172 - })?; 173 - 174 - Ok(Commit { 175 - set_hash: set_hash.to_vec(), 176 - rev: context.rev.clone(), 177 - ikm: ikm.to_vec(), 178 - tag: tag.to_vec(), 179 - sig, 180 - }) 173 + mac.update(hash); 174 + Ok(mac.finalize().into_bytes().to_vec()) 181 175 } 182 176 183 - /// Verify a commit against the supplied `SpaceContext` and verifying key. 177 + /// Verify a commit's MAC against the supplied `SpaceContext`. 184 178 /// 185 - /// Returns `Ok(())` on success. The verifying key must be the public half of 186 - /// the signing key used during `create_commit`. 179 + /// Recomputes `ctx` from `space`, `rev`, and the commit's `ikm`, then recomputes 180 + /// the MAC over `hash` and compares it constant-time (spec line 305). It does 181 + /// **not** verify the signature (see [`verify_commit_signature`]). 187 182 /// 188 183 /// # Errors 189 184 /// 190 - /// - [`SpaceError::CommitTagMismatch`] — HMAC tag does not match (commit 191 - /// tampered or wrong context). 192 - /// - [`SpaceError::CommitSignatureInvalid`] — ECDSA signature does not verify. 193 - /// - [`SpaceError::ContextEncoding`] / [`SpaceError::Hkdf`] — internal failure. 194 - /// 195 - /// # Domain separation 196 - /// 197 - /// A commit signed with `scope: Records` will fail verification when the 198 - /// supplied `context.scope` is `Members` (and vice versa) because the HKDF 199 - /// info bytes differ — this is the load-bearing security property. 200 - pub fn verify_commit( 201 - context: &SpaceContext, 202 - commit: &Commit, 203 - verifying_key: &KeyData, 204 - ) -> SpaceResult<()> { 205 - // Cheap structural checks first. 206 - if commit.rev != context.rev { 207 - return Err(SpaceError::JwtClaimMismatch { 208 - field: "rev".to_string(), 209 - expected: context.rev.clone(), 210 - actual: commit.rev.clone(), 211 - }); 212 - } 185 + /// - [`SpaceError::CommitTagMismatch`] — the MAC does not match (commit 186 + /// tampered, or wrong context). 187 + /// - [`SpaceError::Hkdf`] — `ikm` is not 32 bytes, or derivation failed. 188 + pub fn verify_commit(context: &SpaceContext, commit: &Commit) -> SpaceResult<()> { 213 189 if commit.ikm.len() != 32 { 214 190 return Err(SpaceError::Hkdf { 215 191 reason: format!("ikm must be 32 bytes, got {}", commit.ikm.len()), 216 192 }); 217 193 } 218 - 219 - // Recompute HMAC. 220 - let info = context.to_cbor()?; 221 - let hkdf = Hkdf::<Sha256>::new(None, &commit.ikm); 194 + let ctx = encode_ctx(context, &commit.ikm); 195 + let mac = derive_mac(&commit.ikm, &commit.hash, &ctx)?; 196 + if mac.len() != commit.mac.len() { 197 + return Err(SpaceError::CommitTagMismatch); 198 + } 199 + // Constant-time compare via HMAC's verify path. 200 + let hkdf = Hkdf::<Sha256>::from_prk(&commit.ikm).map_err(|e| SpaceError::Hkdf { 201 + reason: format!("invalid prk: {e}"), 202 + })?; 222 203 let mut hmac_key = [0u8; 32]; 223 - hkdf.expand(&info, &mut hmac_key) 204 + hkdf.expand(&ctx, &mut hmac_key) 224 205 .map_err(|e| SpaceError::Hkdf { 225 206 reason: e.to_string(), 226 207 })?; 227 - 228 - let mut mac = 208 + let mut verifier = 229 209 <HmacSha256 as KeyInit>::new_from_slice(&hmac_key).map_err(|e| SpaceError::Hkdf { 230 - reason: format!("hmac key length: {}", e), 210 + reason: format!("hmac key length: {e}"), 231 211 })?; 232 - mac.update(&commit.set_hash); 233 - mac.update(commit.rev.as_bytes()); 234 - mac.verify_slice(&commit.tag) 235 - .map_err(|_| SpaceError::CommitTagMismatch)?; 212 + verifier.update(&commit.hash); 213 + verifier 214 + .verify_slice(&commit.mac) 215 + .map_err(|_| SpaceError::CommitTagMismatch) 216 + } 236 217 237 - // Verify ECDSA over SHA256(tag || rev). 238 - let mut sig_input = Vec::with_capacity(commit.tag.len() + commit.rev.len()); 239 - sig_input.extend_from_slice(&commit.tag); 240 - sig_input.extend_from_slice(commit.rev.as_bytes()); 241 - let sig_hash = Sha256::digest(&sig_input); 242 - 243 - identity_validate(verifying_key, &commit.sig, &sig_hash) 244 - .map_err(|_| SpaceError::CommitSignatureInvalid)?; 245 - 246 - Ok(()) 218 + /// Verify that a commit's `sig` was produced over its `ctx` by `verifying_key`. 219 + /// 220 + /// Per spec line 305 a reader verifies `sig` against the user's signing key for 221 + /// authenticity. The `ctx` is reconstructed from `context` (`space`, `rev`) and 222 + /// the commit's `ikm`. 223 + /// 224 + /// # Errors 225 + /// 226 + /// Returns [`SpaceError::CommitSignatureInvalid`] if the signature does not 227 + /// verify over the reconstructed `ctx`. 228 + pub fn verify_commit_signature( 229 + context: &SpaceContext, 230 + commit: &Commit, 231 + verifying_key: &KeyData, 232 + ) -> SpaceResult<()> { 233 + let ctx = encode_ctx(context, &commit.ikm); 234 + identity_validate(verifying_key, &commit.sig, &ctx) 235 + .map_err(|_| SpaceError::CommitSignatureInvalid) 247 236 } 248 237 249 238 #[cfg(test)] 250 239 mod tests { 251 240 use super::*; 252 - use crate::set_hash::XorSha256SetHash; 241 + use crate::set_hash::LtHash; 253 242 use atproto_identity::key::{KeyType, generate_key, to_public}; 254 243 255 - fn test_context(scope: CommitScope) -> SpaceContext { 244 + fn test_context() -> SpaceContext { 256 245 SpaceContext { 257 - space_did: "did:plc:owner".to_string(), 258 - space_type: "app.bsky.group".to_string(), 259 - space_key: "default".to_string(), 260 - user_did: "did:plc:alice".to_string(), 261 - scope, 246 + space: "ats://did:plc:owner/app.bsky.group/default".to_string(), 262 247 rev: "3jui7kd2z2y2e".to_string(), 263 248 } 264 249 } ··· 269 254 (private, public) 270 255 } 271 256 272 - fn fresh_set_hash() -> XorSha256SetHash { 273 - let mut h = XorSha256SetHash::empty(); 257 + fn fresh_set_hash() -> LtHash { 258 + let mut h = LtHash::empty(); 274 259 h.add(b"app.bsky.feed.post/3jui:bafy123"); 275 260 h 276 261 } 277 262 278 263 #[test] 279 264 fn round_trip_create_and_verify() { 280 - let (priv_key, pub_key) = test_keypair(); 281 - let ctx = test_context(CommitScope::Records); 265 + let (priv_key, _pub_key) = test_keypair(); 266 + let ctx = test_context(); 282 267 let h = fresh_set_hash(); 283 268 284 269 let commit = create_commit(&h, &ctx, &priv_key).unwrap(); 285 - verify_commit(&ctx, &commit, &pub_key).expect("commit must verify"); 270 + assert_eq!(commit.hash, h.digest()); 271 + assert_eq!(commit.ikm.len(), 32); 272 + verify_commit(&ctx, &commit).expect("commit MAC must verify"); 273 + } 274 + 275 + #[test] 276 + fn signature_is_over_ctx() { 277 + let (priv_key, pub_key) = test_keypair(); 278 + let ctx = test_context(); 279 + let h = fresh_set_hash(); 280 + let commit = create_commit(&h, &ctx, &priv_key).unwrap(); 281 + // sig verifies over the reconstructed ctx... 282 + verify_commit_signature(&ctx, &commit, &pub_key).expect("sig over ctx must verify"); 283 + // ...and the signed message is the ctx, not the hash: an independent 284 + // signature over ctx equals the commit's sig (ECDSA is deterministic here). 285 + let direct = identity_sign(&priv_key, &encode_ctx(&ctx, &commit.ikm)).unwrap(); 286 + assert_eq!(direct, commit.sig); 286 287 } 287 288 288 289 #[test] 289 290 fn ikm_uniqueness_across_two_commits() { 290 291 let (priv_key, _) = test_keypair(); 291 - let ctx = test_context(CommitScope::Records); 292 + let ctx = test_context(); 292 293 let h = fresh_set_hash(); 293 294 let c1 = create_commit(&h, &ctx, &priv_key).unwrap(); 294 295 let c2 = create_commit(&h, &ctx, &priv_key).unwrap(); 295 296 assert_ne!(c1.ikm, c2.ikm, "IKM must be fresh per commit"); 296 - assert_ne!(c1.tag, c2.tag, "tag should change with IKM"); 297 - assert_ne!(c1.sig, c2.sig, "signature should change with tag"); 297 + assert_ne!(c1.mac, c2.mac, "mac should change with IKM"); 298 + assert_eq!(c1.hash, c2.hash, "hash is stable for the same set"); 298 299 } 299 300 300 - /// **Domain separation** — the load-bearing security property. 301 - /// 302 - /// A commit signed with `scope: Records` MUST fail verification when the 303 - /// verifier presents `scope: Members`. 301 + /// Domain separation — a commit bound to one space must fail verification 302 + /// under a different space, because `space` is part of the `ctx`. 304 303 #[test] 305 - fn domain_separation_records_vs_members() { 306 - let (priv_key, pub_key) = test_keypair(); 307 - let records_ctx = test_context(CommitScope::Records); 308 - let members_ctx = SpaceContext { 309 - scope: CommitScope::Members, 310 - ..records_ctx.clone() 304 + fn domain_separation_by_space() { 305 + let (priv_key, _) = test_keypair(); 306 + let ctx = test_context(); 307 + let other = SpaceContext { 308 + space: "ats://did:plc:owner/app.bsky.group/other".to_string(), 309 + ..ctx.clone() 311 310 }; 312 311 let h = fresh_set_hash(); 312 + let commit = create_commit(&h, &ctx, &priv_key).unwrap(); 313 + assert!(matches!( 314 + verify_commit(&other, &commit), 315 + Err(SpaceError::CommitTagMismatch) 316 + )); 317 + } 313 318 314 - let commit = create_commit(&h, &records_ctx, &priv_key).unwrap(); 315 - let result = verify_commit(&members_ctx, &commit, &pub_key); 316 - assert!(matches!(result, Err(SpaceError::CommitTagMismatch))); 319 + #[test] 320 + fn tampered_mac_rejected() { 321 + let (priv_key, _) = test_keypair(); 322 + let ctx = test_context(); 323 + let mut commit = create_commit(&fresh_set_hash(), &ctx, &priv_key).unwrap(); 324 + commit.mac[0] ^= 0xff; 325 + assert!(matches!( 326 + verify_commit(&ctx, &commit), 327 + Err(SpaceError::CommitTagMismatch) 328 + )); 317 329 } 318 330 319 331 #[test] 320 - fn tampered_tag_rejected() { 321 - let (priv_key, pub_key) = test_keypair(); 322 - let ctx = test_context(CommitScope::Records); 323 - let h = fresh_set_hash(); 324 - let mut commit = create_commit(&h, &ctx, &priv_key).unwrap(); 325 - commit.tag[0] ^= 0xff; 326 - let result = verify_commit(&ctx, &commit, &pub_key); 327 - assert!(matches!(result, Err(SpaceError::CommitTagMismatch))); 332 + fn tampered_hash_rejected() { 333 + let (priv_key, _) = test_keypair(); 334 + let ctx = test_context(); 335 + let mut commit = create_commit(&fresh_set_hash(), &ctx, &priv_key).unwrap(); 336 + commit.hash[0] ^= 0xff; 337 + // mac was over the original hash → mismatch. 338 + assert!(matches!( 339 + verify_commit(&ctx, &commit), 340 + Err(SpaceError::CommitTagMismatch) 341 + )); 328 342 } 329 343 330 344 #[test] 331 - fn tampered_rev_rejected_via_mismatch() { 332 - let (priv_key, pub_key) = test_keypair(); 333 - let ctx = test_context(CommitScope::Records); 334 - let h = fresh_set_hash(); 335 - let mut commit = create_commit(&h, &ctx, &priv_key).unwrap(); 336 - commit.rev = "different".to_string(); 337 - // Field-level mismatch happens first. 338 - let result = verify_commit(&ctx, &commit, &pub_key); 339 - assert!(matches!(result, Err(SpaceError::JwtClaimMismatch { .. }))); 345 + fn wrong_rev_context_rejected() { 346 + let (priv_key, _) = test_keypair(); 347 + let ctx = test_context(); 348 + let commit = create_commit(&fresh_set_hash(), &ctx, &priv_key).unwrap(); 349 + let wrong = SpaceContext { 350 + rev: "3zzzzzzzzzzzz".to_string(), 351 + ..ctx 352 + }; 353 + assert!(matches!( 354 + verify_commit(&wrong, &commit), 355 + Err(SpaceError::CommitTagMismatch) 356 + )); 340 357 } 341 358 342 359 #[test] 343 - fn tampered_set_hash_rejected() { 360 + fn tampered_signature_rejected() { 344 361 let (priv_key, pub_key) = test_keypair(); 345 - let ctx = test_context(CommitScope::Records); 346 - let h = fresh_set_hash(); 347 - let mut commit = create_commit(&h, &ctx, &priv_key).unwrap(); 348 - commit.set_hash[0] ^= 0xff; 349 - // The HMAC was over the original set_hash; mutated set_hash → tag mismatch. 350 - let result = verify_commit(&ctx, &commit, &pub_key); 351 - assert!(matches!(result, Err(SpaceError::CommitTagMismatch))); 362 + let ctx = test_context(); 363 + let mut commit = create_commit(&fresh_set_hash(), &ctx, &priv_key).unwrap(); 364 + commit.sig[0] ^= 0xff; 365 + assert!(matches!( 366 + verify_commit_signature(&ctx, &commit, &pub_key), 367 + Err(SpaceError::CommitSignatureInvalid) 368 + )); 352 369 } 353 370 371 + /// The `ctx` byte layout: tag, then uint16be-length-prefixed fields in the 372 + /// fixed order `[space, rev, ikm]` (spec lines 293–297). 354 373 #[test] 355 - fn wrong_user_context_rejected() { 356 - let (priv_key, pub_key) = test_keypair(); 357 - let ctx = test_context(CommitScope::Records); 358 - let h = fresh_set_hash(); 359 - let commit = create_commit(&h, &ctx, &priv_key).unwrap(); 360 - 361 - let wrong_ctx = SpaceContext { 362 - user_did: "did:plc:eve".to_string(), 363 - ..ctx 374 + fn commit_ctx_layout() { 375 + let ctx = SpaceContext { 376 + space: "s".to_string(), 377 + rev: "r".to_string(), 364 378 }; 365 - let result = verify_commit(&wrong_ctx, &commit, &pub_key); 366 - assert!(matches!(result, Err(SpaceError::CommitTagMismatch))); 379 + let ikm = [0xABu8; 4]; 380 + let encoded = encode_ctx(&ctx, &ikm); 381 + let mut expected = b"atproto-space-v1".to_vec(); 382 + for f in [b"s".as_slice(), b"r".as_slice(), &ikm] { 383 + expected.extend_from_slice(&(f.len() as u16).to_be_bytes()); 384 + expected.extend_from_slice(f); 385 + } 386 + assert_eq!(encoded, expected); 367 387 } 368 388 }
+342 -152
crates/atproto-space/src/credential.rs
··· 1 - //! `MemberGrant` and `SpaceCredential` JWTs. 1 + //! `DelegationToken` and `SpaceCredential` JWTs (0016 Permissioned Data). 2 2 //! 3 - //! a syncing app obtains a 4 - //! `SpaceCredential` via a two-step flow: 3 + //! A syncing app obtains a `SpaceCredential` via a two-step flow, per the 4 + //! 0016 spec "Credential flow" (README lines 232-254): 5 5 //! 6 - //! 1. App with OAuth on a member's PDS calls `getMemberGrant {space, clientId}`. 7 - //! The member's PDS returns a `MemberGrant` JWT signed with the member's 8 - //! atproto signing key, scoped to `clientId`, `aud=owner_did`, 9 - //! `lxm=com.atproto.space.getSpaceCredential`. ~5 minute TTL. 10 - //! 2. App calls `getSpaceCredential {grant}` against the owner's PDS. Owner 11 - //! verifies the grant (resolving member's DID doc, checking signature, 12 - //! `lxm`, `clientId`, expiration), then mints a `SpaceCredential` JWT 13 - //! signed with the owner's atproto signing key. 2–4h TTL (default 3h). 6 + //! 1. An app holding an OAuth session on a member's PDS calls 7 + //! [`com.atproto.space.getDelegationToken`]. The member's PDS mints a 8 + //! **delegation token** (spec "Delegation token", lines 147-176): a JWT with 9 + //! header `typ=atproto-space-delegation+jwt`, `kid="#atproto"`, signed by 10 + //! the member's atproto signing key. Claims: `iss` (member DID), 11 + //! `aud=<spaceDid>#atproto_space_host`, `sub` (the space `ats://` URI), 12 + //! `iat`, `exp=iat+60`, `jti`. It carries no `lxm` claim and says nothing 13 + //! about the app. Single-use, default 60-second TTL. 14 + //! 2. The app presents that delegation token (in the `Authorization: Bearer` 15 + //! header) plus an optional client attestation to the space authority at 16 + //! [`com.atproto.space.getSpaceCredential`]. The authority verifies it and 17 + //! mints a **space credential** (spec "Space credential", lines 200-230): a 18 + //! JWT with header `typ=atproto-space-credential+jwt`, 19 + //! `kid="#atproto_space"`, signed by the authority's space signing key. 20 + //! Claims: `iss` (authority DID), `sub` (the space `ats://` URI), 21 + //! `client_id` (the attested app, omitted when no attestation), `iat`, 22 + //! `exp=iat+7200`, `jti`. It has no `aud`. Default 2-hour TTL. 14 23 //! 15 - //! Both JWTs use the same compact-form encoding: `b64url(header).b64url(payload).b64url(sig)`, 16 - //! signed with ECDSA over the user's atproto signing key (P-256 → ES256, K-256 → ES256K). 24 + //! Both JWTs use the same compact-form encoding: 25 + //! `b64url(header).b64url(payload).b64url(sig)`, signed with ECDSA over an 26 + //! atproto signing key (P-256 → ES256, K-256 → ES256K). 27 + //! 28 + //! [`com.atproto.space.getDelegationToken`]: https://atproto.com 29 + //! [`com.atproto.space.getSpaceCredential`]: https://atproto.com 17 30 18 31 use crate::errors::{SpaceError, SpaceResult}; 19 32 use crate::types::SpaceUri; 20 33 use atproto_identity::key::{KeyData, sign as identity_sign, validate as identity_validate}; 21 34 use base64::{Engine as _, engine::general_purpose}; 35 + use rand::RngExt; 22 36 use serde::{Deserialize, Serialize}; 23 37 use std::time::{SystemTime, UNIX_EPOCH}; 24 38 25 - /// `typ` header value for MemberGrant. 26 - pub const TYP_MEMBER_GRANT: &str = "space_member_grant"; 39 + /// `typ` header value for a delegation token (spec line 152). 40 + pub const TYP_DELEGATION_TOKEN: &str = "atproto-space-delegation+jwt"; 27 41 28 - /// `typ` header value for SpaceCredential. 29 - pub const TYP_SPACE_CREDENTIAL: &str = "space_credential"; 42 + /// `typ` header value for a space credential (spec line 206). 43 + pub const TYP_SPACE_CREDENTIAL: &str = "atproto-space-credential+jwt"; 30 44 31 - /// `lxm` value required on MemberGrant for use at `getSpaceCredential`. 32 - pub const LXM_GET_SPACE_CREDENTIAL: &str = "com.atproto.space.getSpaceCredential"; 45 + /// `kid` header value a delegation token MUST carry (spec line 162). 46 + pub const KID_DELEGATION_TOKEN: &str = "#atproto"; 33 47 34 - /// MemberGrant default TTL: 5 minutes. 35 - pub const MEMBER_GRANT_TTL_SECS: u64 = 300; 48 + /// `kid` header value a space credential MUST carry (spec line 216). 49 + pub const KID_SPACE_CREDENTIAL: &str = "#atproto_space"; 36 50 37 - /// SpaceCredential default TTL: 3 hours (within spec's 2–4h window). 38 - pub const SPACE_CREDENTIAL_TTL_SECS: u64 = 10800; 51 + /// Delegation-token default TTL: 60 seconds (spec lines 149, 169). 52 + pub const DELEGATION_TOKEN_TTL_SECS: u64 = 60; 53 + 54 + /// SpaceCredential default TTL: 2 hours / 7200 seconds (spec line 223). 55 + pub const SPACE_CREDENTIAL_TTL_SECS: u64 = 7200; 56 + 57 + /// The `aud` of a delegation token: the space host service fragment of the 58 + /// authority DID (`<spaceDid>#atproto_space_host`, spec line 166). 59 + #[must_use] 60 + pub fn space_host_audience(space_did: &str) -> String { 61 + format!("{space_did}#atproto_space_host") 62 + } 39 63 40 64 #[derive(Debug, Clone, Serialize, Deserialize)] 41 65 struct JwtHeader { 42 66 alg: String, 43 67 typ: String, 68 + kid: String, 44 69 } 45 70 46 - /// Decoded MemberGrant payload. 71 + /// Decoded delegation-token payload (spec lines 164-171). 47 72 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 48 - pub struct MemberGrant { 49 - /// Issuer DID — the member. 73 + pub struct DelegationToken { 74 + /// Issuer DID — the member (user) delegating to the app. 50 75 pub iss: String, 51 - /// Audience DID — the space owner. 76 + /// Audience — the space host service fragment 77 + /// (`<spaceDid>#atproto_space_host`). 52 78 pub aud: String, 53 - /// Space URI. 54 - pub space: String, 55 - /// OAuth `client_id` of the requesting app. 56 - #[serde(rename = "clientId")] 57 - pub client_id: String, 58 - /// Lexicon method this grant is good for. 59 - pub lxm: String, 79 + /// Subject — the space being requested, an `ats://` URI. 80 + pub sub: String, 60 81 /// Issued-at timestamp (seconds since epoch). 61 82 pub iat: u64, 62 83 /// Expiration timestamp (seconds since epoch). 63 84 pub exp: u64, 85 + /// Random nonce (UUIDv4) for single-use enforcement. 86 + pub jti: String, 64 87 } 65 88 66 - /// Decoded SpaceCredential payload. 89 + /// Decoded SpaceCredential payload (spec lines 218-225). 67 90 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 68 91 pub struct SpaceCredential { 69 - /// Issuer DID — the space owner. 92 + /// Issuer DID — the space authority. 70 93 pub iss: String, 71 - /// Space URI. 72 - pub space: String, 73 - /// OAuth `client_id` the credential is bound to. 74 - #[serde(rename = "clientId")] 75 - pub client_id: String, 94 + /// Subject — the space the credential reads, an `ats://` URI. 95 + pub sub: String, 96 + /// Attested application identity (the verified client attestation's 97 + /// `iss`). Omitted on the wire when the request carried no attestation 98 + /// (spec lines 221, 228). 99 + #[serde(skip_serializing_if = "Option::is_none", default)] 100 + pub client_id: Option<String>, 76 101 /// Issued-at timestamp. 77 102 pub iat: u64, 78 103 /// Expiration timestamp. 79 104 pub exp: u64, 105 + /// Random nonce (UUIDv4). 106 + pub jti: String, 80 107 } 81 108 82 109 fn now_secs() -> u64 { ··· 86 113 .as_secs() 87 114 } 88 115 89 - /// Re-export of the shared `jws_alg` helper. Keeps existing call sites 90 - /// inside this module unchanged when they delegate via the alias. 91 - fn jws_alg(key: &KeyData) -> &'static str { 92 - atproto_identity::key::jws_alg(key) 116 + /// Generate a random UUIDv4-shaped nonce for the `jti` claim. 117 + /// 118 + /// `jti` is an opaque nonce (it is never parsed), so we mint a v4-formatted 119 + /// string from the OS RNG without a dedicated UUID dependency. 120 + fn random_jti() -> String { 121 + let mut b = [0u8; 16]; 122 + rand::rng().fill(&mut b); 123 + b[6] = (b[6] & 0x0f) | 0x40; // version 4 124 + b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant 125 + format!( 126 + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", 127 + b[0], 128 + b[1], 129 + b[2], 130 + b[3], 131 + b[4], 132 + b[5], 133 + b[6], 134 + b[7], 135 + b[8], 136 + b[9], 137 + b[10], 138 + b[11], 139 + b[12], 140 + b[13], 141 + b[14], 142 + b[15] 143 + ) 144 + } 145 + 146 + /// Resolve the JWS `alg` header for `key`, restricted to the two algorithms 147 + /// the spec permits in space-token headers (ES256 / ES256K, spec lines 161, 148 + /// 215). Other key types (P-384, Ed25519) are rejected here so the minted 149 + /// header can never carry a non-conformant `alg`. 150 + fn space_jws_alg(key: &KeyData) -> SpaceResult<&'static str> { 151 + match atproto_identity::key::jws_alg(key) { 152 + alg @ ("ES256" | "ES256K") => Ok(alg), 153 + other => Err(SpaceError::Signature { 154 + reason: format!("space tokens require an ES256 or ES256K signing key; got alg {other}"), 155 + }), 156 + } 93 157 } 94 158 95 159 fn b64url_encode(bytes: &[u8]) -> String { ··· 104 168 }) 105 169 } 106 170 107 - fn mint_jwt<P: Serialize>(typ: &str, payload: &P, signing_key: &KeyData) -> SpaceResult<String> { 171 + fn mint_jwt<P: Serialize>( 172 + typ: &str, 173 + kid: &str, 174 + payload: &P, 175 + signing_key: &KeyData, 176 + ) -> SpaceResult<String> { 108 177 let header = JwtHeader { 109 - alg: jws_alg(signing_key).to_string(), 178 + alg: space_jws_alg(signing_key)?.to_string(), 110 179 typ: typ.to_string(), 180 + kid: kid.to_string(), 111 181 }; 112 182 let header_json = serde_json::to_vec(&header).map_err(|e| SpaceError::JwtEncoding { 113 183 reason: e.to_string(), ··· 131 201 fn verify_jwt<P: for<'de> Deserialize<'de>>( 132 202 token: &str, 133 203 expected_typ: &str, 204 + expected_kid: &str, 134 205 verifying_key: &KeyData, 135 206 ) -> SpaceResult<P> { 136 207 let parts: Vec<&str> = token.split('.').collect(); ··· 152 223 actual: header.typ, 153 224 }); 154 225 } 155 - let expected_alg = jws_alg(verifying_key); 226 + if header.kid != expected_kid { 227 + return Err(SpaceError::JwtClaimMismatch { 228 + field: "kid".to_string(), 229 + expected: expected_kid.to_string(), 230 + actual: header.kid, 231 + }); 232 + } 233 + let expected_alg = space_jws_alg(verifying_key)?; 156 234 if header.alg != expected_alg { 157 235 return Err(SpaceError::JwtClaimMismatch { 158 236 field: "alg".to_string(), ··· 182 260 Ok(()) 183 261 } 184 262 185 - /// Mint a MemberGrant signed by the member's atproto signing key. 263 + /// Mint a delegation token signed by the member's atproto signing key. 264 + /// 265 + /// The token's `aud` is set to the space host service fragment 266 + /// (`<spaceDid>#atproto_space_host`) and `sub` to the space `ats://` URI, per 267 + /// spec lines 166-167. The header carries `kid="#atproto"`. 186 268 /// 187 269 /// # Errors 188 270 /// 189 - /// Returns [`SpaceError::JwtEncoding`] / [`SpaceError::Signature`] on failure. 190 - pub fn create_member_grant( 271 + /// Returns [`SpaceError::JwtEncoding`] / [`SpaceError::Signature`] on failure, 272 + /// including when `member_signing_key` is not an ES256/ES256K key. 273 + pub fn create_delegation_token( 191 274 member_did: &str, 192 - owner_did: &str, 193 275 space: &SpaceUri, 194 - client_id: &str, 195 276 member_signing_key: &KeyData, 196 277 ttl_secs: u64, 197 278 ) -> SpaceResult<String> { 198 279 let iat = now_secs(); 199 280 let exp = iat + ttl_secs; 200 - let payload = MemberGrant { 281 + let payload = DelegationToken { 201 282 iss: member_did.to_string(), 202 - aud: owner_did.to_string(), 203 - space: space.to_string(), 204 - client_id: client_id.to_string(), 205 - lxm: LXM_GET_SPACE_CREDENTIAL.to_string(), 283 + aud: space_host_audience(&space.space_did), 284 + sub: space.to_string(), 206 285 iat, 207 286 exp, 287 + jti: random_jti(), 208 288 }; 209 - mint_jwt(TYP_MEMBER_GRANT, &payload, member_signing_key) 289 + mint_jwt( 290 + TYP_DELEGATION_TOKEN, 291 + KID_DELEGATION_TOKEN, 292 + &payload, 293 + member_signing_key, 294 + ) 210 295 } 211 296 212 - /// Verify a MemberGrant against the member's verifying key and expected claims. 297 + /// Verify a delegation token against the member's verifying key and expected 298 + /// claims. 213 299 /// 214 - /// Checks: signature, `typ`, `alg`, `aud=owner_did`, `space`, `lxm`, `clientId`, `exp`. 300 + /// Checks: signature, header `typ`/`kid`/`alg`, `aud` (the space host service 301 + /// fragment of the authority DID), `sub` (the space URI), and `exp`. The 302 + /// caller is responsible for enforcing single-use via `jti`. 215 303 /// 216 304 /// # Errors 217 305 /// 218 306 /// Returns the relevant `SpaceError` on any check failure. 219 - pub fn verify_member_grant( 307 + pub fn verify_delegation_token( 220 308 token: &str, 221 - expected_owner_did: &str, 309 + expected_authority_did: &str, 222 310 expected_space: &SpaceUri, 223 - expected_client_id: &str, 224 311 member_verifying_key: &KeyData, 225 - ) -> SpaceResult<MemberGrant> { 226 - let payload: MemberGrant = verify_jwt(token, TYP_MEMBER_GRANT, member_verifying_key)?; 312 + ) -> SpaceResult<DelegationToken> { 313 + let payload: DelegationToken = verify_jwt( 314 + token, 315 + TYP_DELEGATION_TOKEN, 316 + KID_DELEGATION_TOKEN, 317 + member_verifying_key, 318 + )?; 227 319 228 - if payload.aud != expected_owner_did { 320 + let expected_aud = space_host_audience(expected_authority_did); 321 + if payload.aud != expected_aud { 229 322 return Err(SpaceError::JwtClaimMismatch { 230 323 field: "aud".to_string(), 231 - expected: expected_owner_did.to_string(), 324 + expected: expected_aud, 232 325 actual: payload.aud, 233 326 }); 234 327 } 235 - let expected_space_str = expected_space.to_string(); 236 - if payload.space != expected_space_str { 237 - return Err(SpaceError::JwtClaimMismatch { 238 - field: "space".to_string(), 239 - expected: expected_space_str, 240 - actual: payload.space, 241 - }); 242 - } 243 - if payload.lxm != LXM_GET_SPACE_CREDENTIAL { 244 - return Err(SpaceError::JwtClaimMismatch { 245 - field: "lxm".to_string(), 246 - expected: LXM_GET_SPACE_CREDENTIAL.to_string(), 247 - actual: payload.lxm, 248 - }); 249 - } 250 - if payload.client_id != expected_client_id { 328 + let expected_sub = expected_space.to_string(); 329 + if payload.sub != expected_sub { 251 330 return Err(SpaceError::JwtClaimMismatch { 252 - field: "clientId".to_string(), 253 - expected: expected_client_id.to_string(), 254 - actual: payload.client_id, 331 + field: "sub".to_string(), 332 + expected: expected_sub, 333 + actual: payload.sub, 255 334 }); 256 335 } 257 336 check_exp(payload.exp)?; 258 337 Ok(payload) 259 338 } 260 339 261 - /// Mint a SpaceCredential signed by the space owner's atproto signing key. 340 + /// Mint a space credential signed by the space authority's `#atproto_space` 341 + /// signing key. 342 + /// 343 + /// `client_id` is the attested application identity (the verified client 344 + /// attestation's `iss`); pass `None` when the request carried no attestation, 345 + /// in which case the claim is omitted (spec lines 221, 228). The header 346 + /// carries `kid="#atproto_space"` and the payload has no `aud`. 347 + /// 348 + /// # Errors 349 + /// 350 + /// Returns [`SpaceError::JwtEncoding`] / [`SpaceError::Signature`] on failure. 262 351 pub fn create_space_credential( 263 - owner_did: &str, 352 + authority_did: &str, 264 353 space: &SpaceUri, 265 - client_id: &str, 266 - owner_signing_key: &KeyData, 354 + client_id: Option<&str>, 355 + authority_signing_key: &KeyData, 267 356 ttl_secs: u64, 268 357 ) -> SpaceResult<String> { 269 358 let iat = now_secs(); 270 359 let exp = iat + ttl_secs; 271 360 let payload = SpaceCredential { 272 - iss: owner_did.to_string(), 273 - space: space.to_string(), 274 - client_id: client_id.to_string(), 361 + iss: authority_did.to_string(), 362 + sub: space.to_string(), 363 + client_id: client_id.map(str::to_string), 275 364 iat, 276 365 exp, 366 + jti: random_jti(), 277 367 }; 278 - mint_jwt(TYP_SPACE_CREDENTIAL, &payload, owner_signing_key) 368 + mint_jwt( 369 + TYP_SPACE_CREDENTIAL, 370 + KID_SPACE_CREDENTIAL, 371 + &payload, 372 + authority_signing_key, 373 + ) 279 374 } 280 375 281 - /// Verify a SpaceCredential against the owner's verifying key and expected claims. 376 + /// Verify a space credential against the authority's verifying key and 377 + /// expected claims. 378 + /// 379 + /// Checks: signature, header `typ`/`kid`/`alg`, `iss` (the authority DID), 380 + /// `sub` (the space URI), and `exp`. 381 + /// 382 + /// # Errors 383 + /// 384 + /// Returns the relevant `SpaceError` on any check failure. 282 385 pub fn verify_space_credential( 283 386 token: &str, 284 - expected_owner_did: &str, 387 + expected_authority_did: &str, 285 388 expected_space: &SpaceUri, 286 - owner_verifying_key: &KeyData, 389 + authority_verifying_key: &KeyData, 287 390 ) -> SpaceResult<SpaceCredential> { 288 - let payload: SpaceCredential = verify_jwt(token, TYP_SPACE_CREDENTIAL, owner_verifying_key)?; 391 + let payload: SpaceCredential = verify_jwt( 392 + token, 393 + TYP_SPACE_CREDENTIAL, 394 + KID_SPACE_CREDENTIAL, 395 + authority_verifying_key, 396 + )?; 289 397 290 - if payload.iss != expected_owner_did { 398 + if payload.iss != expected_authority_did { 291 399 return Err(SpaceError::JwtClaimMismatch { 292 400 field: "iss".to_string(), 293 - expected: expected_owner_did.to_string(), 401 + expected: expected_authority_did.to_string(), 294 402 actual: payload.iss, 295 403 }); 296 404 } 297 - let expected_space_str = expected_space.to_string(); 298 - if payload.space != expected_space_str { 405 + let expected_sub = expected_space.to_string(); 406 + if payload.sub != expected_sub { 299 407 return Err(SpaceError::JwtClaimMismatch { 300 - field: "space".to_string(), 301 - expected: expected_space_str, 302 - actual: payload.space, 408 + field: "sub".to_string(), 409 + expected: expected_sub, 410 + actual: payload.sub, 303 411 }); 304 412 } 305 413 check_exp(payload.exp)?; ··· 327 435 } 328 436 329 437 #[test] 330 - fn member_grant_round_trip() { 438 + fn delegation_token_round_trip() { 331 439 let (member_priv, member_pub) = keypair(); 332 440 let space = test_space(); 333 - let token = create_member_grant( 441 + let token = create_delegation_token( 334 442 "did:plc:alice", 335 - "did:plc:owner", 336 443 &space, 337 - "https://app.example/client-metadata.json", 338 444 &member_priv, 339 - MEMBER_GRANT_TTL_SECS, 445 + DELEGATION_TOKEN_TTL_SECS, 340 446 ) 341 447 .unwrap(); 342 448 343 - let payload = verify_member_grant( 344 - &token, 345 - "did:plc:owner", 449 + let payload = 450 + verify_delegation_token(&token, "did:plc:owner", &space, &member_pub).unwrap(); 451 + assert_eq!(payload.iss, "did:plc:alice"); 452 + assert_eq!(payload.aud, "did:plc:owner#atproto_space_host"); 453 + assert_eq!(payload.sub, space.to_string()); 454 + } 455 + 456 + #[test] 457 + fn delegation_token_header_is_spec_exact() { 458 + let (member_priv, _) = keypair(); 459 + let space = test_space(); 460 + let token = create_delegation_token( 461 + "did:plc:alice", 346 462 &space, 347 - "https://app.example/client-metadata.json", 348 - &member_pub, 463 + &member_priv, 464 + DELEGATION_TOKEN_TTL_SECS, 349 465 ) 350 466 .unwrap(); 351 - assert_eq!(payload.iss, "did:plc:alice"); 352 - assert_eq!(payload.aud, "did:plc:owner"); 467 + let header_b64 = token.split('.').next().unwrap(); 468 + let header: serde_json::Value = 469 + serde_json::from_slice(&b64url_decode(header_b64).unwrap()).unwrap(); 470 + assert_eq!(header["typ"], "atproto-space-delegation+jwt"); 471 + assert_eq!(header["kid"], "#atproto"); 472 + assert_eq!(header["alg"], "ES256"); 353 473 } 354 474 355 475 #[test] 356 - fn member_grant_wrong_aud_rejected() { 357 - let (member_priv, member_pub) = keypair(); 476 + fn delegation_token_has_no_lxm_or_client_id() { 477 + let (member_priv, _) = keypair(); 358 478 let space = test_space(); 359 - let token = create_member_grant( 479 + let token = create_delegation_token( 360 480 "did:plc:alice", 361 - "did:plc:owner", 362 481 &space, 363 - "client", 364 482 &member_priv, 365 - MEMBER_GRANT_TTL_SECS, 483 + DELEGATION_TOKEN_TTL_SECS, 366 484 ) 367 485 .unwrap(); 368 - let result = 369 - verify_member_grant(&token, "did:plc:other-owner", &space, "client", &member_pub); 370 - assert!(matches!(result, Err(SpaceError::JwtClaimMismatch { .. }))); 486 + let payload_b64 = token.split('.').nth(1).unwrap(); 487 + let payload: serde_json::Value = 488 + serde_json::from_slice(&b64url_decode(payload_b64).unwrap()).unwrap(); 489 + assert!(payload.get("lxm").is_none()); 490 + assert!(payload.get("clientId").is_none()); 491 + assert!(payload.get("client_id").is_none()); 492 + assert!(payload.get("space").is_none()); 493 + assert!(payload.get("sub").is_some()); 371 494 } 372 495 373 496 #[test] 374 - fn member_grant_wrong_client_rejected() { 375 - let (member_priv, member_pub) = keypair(); 497 + fn delegation_token_default_ttl_is_60s() { 498 + let (member_priv, _) = keypair(); 376 499 let space = test_space(); 377 - let token = create_member_grant( 500 + let token = create_delegation_token( 378 501 "did:plc:alice", 379 - "did:plc:owner", 380 502 &space, 381 - "client-A", 382 503 &member_priv, 383 - MEMBER_GRANT_TTL_SECS, 504 + DELEGATION_TOKEN_TTL_SECS, 384 505 ) 385 506 .unwrap(); 386 - let result = verify_member_grant(&token, "did:plc:owner", &space, "client-B", &member_pub); 387 - assert!(matches!(result, Err(SpaceError::JwtClaimMismatch { .. }))); 507 + let payload_b64 = token.split('.').nth(1).unwrap(); 508 + let payload: DelegationToken = 509 + serde_json::from_slice(&b64url_decode(payload_b64).unwrap()).unwrap(); 510 + assert_eq!(payload.exp - payload.iat, 60); 388 511 } 389 512 390 513 #[test] 391 - fn member_grant_expired_rejected() { 514 + fn delegation_token_wrong_authority_rejected() { 392 515 let (member_priv, member_pub) = keypair(); 393 516 let space = test_space(); 394 - let token = create_member_grant( 517 + let token = create_delegation_token( 395 518 "did:plc:alice", 396 - "did:plc:owner", 397 519 &space, 398 - "client", 399 520 &member_priv, 400 - 0, 521 + DELEGATION_TOKEN_TTL_SECS, 401 522 ) 402 523 .unwrap(); 403 - // Sleep one second to force expiration. 524 + let result = verify_delegation_token(&token, "did:plc:other-owner", &space, &member_pub); 525 + assert!(matches!(result, Err(SpaceError::JwtClaimMismatch { .. }))); 526 + } 527 + 528 + #[test] 529 + fn delegation_token_expired_rejected() { 530 + let (member_priv, member_pub) = keypair(); 531 + let space = test_space(); 532 + let token = create_delegation_token("did:plc:alice", &space, &member_priv, 0).unwrap(); 404 533 std::thread::sleep(std::time::Duration::from_millis(1100)); 405 - let result = verify_member_grant(&token, "did:plc:owner", &space, "client", &member_pub); 534 + let result = verify_delegation_token(&token, "did:plc:owner", &space, &member_pub); 406 535 assert!(matches!(result, Err(SpaceError::JwtExpired { .. }))); 407 536 } 408 537 409 538 #[test] 410 - fn member_grant_tampered_payload_rejected() { 539 + fn delegation_token_tampered_payload_rejected() { 411 540 let (member_priv, member_pub) = keypair(); 412 541 let space = test_space(); 413 - let token = create_member_grant( 542 + let token = create_delegation_token( 414 543 "did:plc:alice", 415 - "did:plc:owner", 416 544 &space, 417 - "client", 418 545 &member_priv, 419 - MEMBER_GRANT_TTL_SECS, 546 + DELEGATION_TOKEN_TTL_SECS, 420 547 ) 421 548 .unwrap(); 422 - // Flip a character in the payload portion. 423 549 let mut parts: Vec<String> = token.split('.').map(String::from).collect(); 424 550 parts[1] = parts[1].chars().rev().collect::<String>(); 425 551 let tampered = parts.join("."); 426 - let result = verify_member_grant(&tampered, "did:plc:owner", &space, "client", &member_pub); 552 + let result = verify_delegation_token(&tampered, "did:plc:owner", &space, &member_pub); 427 553 assert!(result.is_err()); 428 554 } 429 555 430 556 #[test] 431 - fn space_credential_round_trip() { 557 + fn space_credential_round_trip_with_client_id() { 432 558 let (owner_priv, owner_pub) = keypair(); 433 559 let space = test_space(); 434 560 let token = create_space_credential( 435 561 "did:plc:owner", 436 562 &space, 437 - "client", 563 + Some("https://app.example/client-metadata.json"), 438 564 &owner_priv, 439 565 SPACE_CREDENTIAL_TTL_SECS, 440 566 ) 441 567 .unwrap(); 442 568 let payload = verify_space_credential(&token, "did:plc:owner", &space, &owner_pub).unwrap(); 443 569 assert_eq!(payload.iss, "did:plc:owner"); 570 + assert_eq!(payload.sub, space.to_string()); 571 + assert_eq!( 572 + payload.client_id.as_deref(), 573 + Some("https://app.example/client-metadata.json") 574 + ); 575 + } 576 + 577 + #[test] 578 + fn space_credential_header_is_spec_exact() { 579 + let (owner_priv, _) = keypair(); 580 + let space = test_space(); 581 + let token = create_space_credential( 582 + "did:plc:owner", 583 + &space, 584 + None, 585 + &owner_priv, 586 + SPACE_CREDENTIAL_TTL_SECS, 587 + ) 588 + .unwrap(); 589 + let header_b64 = token.split('.').next().unwrap(); 590 + let header: serde_json::Value = 591 + serde_json::from_slice(&b64url_decode(header_b64).unwrap()).unwrap(); 592 + assert_eq!(header["typ"], "atproto-space-credential+jwt"); 593 + assert_eq!(header["kid"], "#atproto_space"); 594 + } 595 + 596 + #[test] 597 + fn space_credential_omits_client_id_when_absent() { 598 + let (owner_priv, _) = keypair(); 599 + let space = test_space(); 600 + let token = create_space_credential( 601 + "did:plc:owner", 602 + &space, 603 + None, 604 + &owner_priv, 605 + SPACE_CREDENTIAL_TTL_SECS, 606 + ) 607 + .unwrap(); 608 + let payload_b64 = token.split('.').nth(1).unwrap(); 609 + let payload: serde_json::Value = 610 + serde_json::from_slice(&b64url_decode(payload_b64).unwrap()).unwrap(); 611 + assert!(payload.get("client_id").is_none()); 612 + assert!(payload.get("clientId").is_none()); 613 + assert!(payload.get("aud").is_none()); 614 + assert_eq!(payload["sub"], space.to_string()); 615 + } 616 + 617 + #[test] 618 + fn space_credential_uses_snake_case_client_id() { 619 + let (owner_priv, _) = keypair(); 620 + let space = test_space(); 621 + let token = create_space_credential( 622 + "did:plc:owner", 623 + &space, 624 + Some("https://app.example/cm"), 625 + &owner_priv, 626 + SPACE_CREDENTIAL_TTL_SECS, 627 + ) 628 + .unwrap(); 629 + let payload_b64 = token.split('.').nth(1).unwrap(); 630 + let payload: serde_json::Value = 631 + serde_json::from_slice(&b64url_decode(payload_b64).unwrap()).unwrap(); 632 + assert_eq!(payload["client_id"], "https://app.example/cm"); 633 + assert!(payload.get("clientId").is_none()); 444 634 } 445 635 446 636 #[test] ··· 455 645 let token = create_space_credential( 456 646 "did:plc:owner", 457 647 &space, 458 - "client", 648 + None, 459 649 &owner_priv, 460 650 SPACE_CREDENTIAL_TTL_SECS, 461 651 )
+9 -1
crates/atproto-space/src/errors.rs
··· 22 22 value: String, 23 23 }, 24 24 25 - /// error-atproto-space-types-3: invalid space key (must be non-empty, no slashes). 25 + /// error-atproto-space-types-3: invalid space key (must satisfy `rkey` syntax: 26 + /// 1-512 bytes, charset `[A-Za-z0-9._:~-]`, not `.` or `..`). 26 27 #[error("error-atproto-space-types-3 invalid space key: {value}")] 27 28 InvalidSpaceKey { 28 29 /// The value that was not a valid space key. ··· 158 159 since: Option<String>, 159 160 /// Earliest rev still retained. 160 161 earliest: Option<String>, 162 + }, 163 + 164 + /// error-atproto-space-storage-3: malformed oplog cursor token. 165 + #[error("error-atproto-space-storage-3 invalid oplog cursor: {token}")] 166 + InvalidCursor { 167 + /// The cursor token that failed to parse. 168 + token: String, 161 169 }, 162 170 } 163 171
+25 -23
crates/atproto-space/src/lib.rs
··· 1 1 //! AT Protocol permissioned-data spaces — primitives. 2 2 //! 3 3 //! This crate implements the protocol primitives from the 4 - //! [Spaces Design Spec](https://github.com/bluesky-social/atproto/blob/main/docs/superpowers/specs/2026-04-22-permissioned-data-pds-design.md): 4 + //! [0016 Permissioned Data][spec] draft, which is the authoritative alignment 5 + //! target for this crate: 5 6 //! 6 - //! - **`SetHash`** trait + `XorSha256SetHash` placeholder + `EcmhSetHash` (production target). 7 - //! - **`Commit`** — HKDF-derived HMAC + ECDSA-signed commit over a `SetHash` digest with 8 - //! per-commit random IKM for deniability and `scope` domain separation. 9 - //! - **`SpaceRepo`** / **`SpaceMembers`** — orchestrators over storage trait surfaces. 10 - //! - **`MemberGrant`** / **`SpaceCredential`** — JWT shapes for the two-step credential flow. 7 + //! - **`SetHash`** trait + **`LtHash`** (the production primitive). 8 + //! - **`Commit`** — a signed commit (`com.atproto.space.defs#signedCommit`): 9 + //! `mac = HMAC(HKDF(ikm, ctx), hash)` binds the repo hash to the per-commit 10 + //! context, while `sig` covers only `ctx` (the space URI, rev, and per-commit 11 + //! random `ikm`) — never the hash — so a leaked commit is deniable (spec 12 + //! lines 285-316). 13 + //! - **`SpaceRepo`** — per-(user, space) record orchestrator over the storage 14 + //! trait surface. **`SpaceMembers`** — the `simplespace` member-list 15 + //! orchestrator (the spec carries no member commits or member sync). 16 + //! - **`DelegationToken`** / **`SpaceCredential`** — JWT shapes for the two-step credential flow. 11 17 //! 12 18 //! The crate is **server-agnostic** — it does no IO directly and has no network 13 19 //! dependencies. It composes with `atproto-pds` (and AppView consumers) via the ··· 15 21 //! 16 22 //! # Status 17 23 //! 18 - //! Experimental. The Spaces Design Spec is still settling. Several primitives 19 - //! (notably the `SetHash` algorithm) are explicitly marked as "placeholder until 20 - //! upstream picks ECMH or ltHash" by the spec itself. 24 + //! Experimental. 0016 is a draft; details may still change upstream. 21 25 //! 22 26 //! # References 23 27 //! 24 - //! - [Spaces Design Spec](https://github.com/bluesky-social/atproto/blob/main/docs/superpowers/specs/2026-04-22-permissioned-data-pds-design.md) 25 - //! - Design document: see `` §15. 28 + //! - [0016 Permissioned Data][spec] 29 + //! 30 + //! [spec]: https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md 26 31 27 32 #![forbid(unsafe_code)] 28 33 #![warn(missing_docs)] ··· 30 35 pub mod commit; 31 36 pub mod credential; 32 37 pub mod errors; 33 - pub mod recon; 34 38 pub mod set_hash; 35 - #[cfg(feature = "ecmh")] 36 - pub mod set_hash_ecmh; 37 39 pub mod space_members; 38 40 pub mod space_repo; 39 41 pub mod storage; 40 42 pub mod types; 41 43 42 44 // Re-exports for the canonical public API surface. 43 - pub use commit::{Commit, CommitScope, SpaceContext, create_commit, verify_commit}; 45 + pub use commit::{ 46 + Commit, SpaceContext, create_commit, encode_ctx, verify_commit, verify_commit_signature, 47 + }; 44 48 pub use credential::{ 45 - MemberGrant, SpaceCredential, create_member_grant, create_space_credential, 46 - verify_member_grant, verify_space_credential, 49 + DelegationToken, SpaceCredential, create_delegation_token, create_space_credential, 50 + verify_delegation_token, verify_space_credential, 47 51 }; 48 52 pub use errors::SpaceError; 49 - pub use set_hash::{SetHash, XorSha256SetHash}; 50 - #[cfg(feature = "ecmh")] 51 - pub use set_hash_ecmh::EcmhSetHash; 53 + pub use set_hash::{LtHash, SetHash}; 52 54 pub use space_members::{MemberOp, MemberOpAction, SpaceMembers}; 53 55 pub use space_repo::{Op, OpAction, PreparedCommit, SpaceRepo}; 54 56 pub use storage::{ 55 - MemberPage, MemberRow, MemberState, OplogEntry, OplogPage, RecordPage, RecordRow, RepoState, 56 - SpaceMembersStorage, SpaceRepoStorage, 57 + MemberPage, MemberRow, MemberState, OplogCursor, OplogEntry, OplogPage, RecordPage, RecordRow, 58 + RepoState, SpaceMembersStorage, SpaceRepoStorage, 57 59 }; 58 - pub use types::{SpaceKey, SpaceType, SpaceUri}; 60 + pub use types::{RecordUri, SpaceKey, SpaceType, SpaceUri};
+166 -136
crates/atproto-space/src/set_hash.rs
··· 1 1 //! Set hash primitives for permissioned-data spaces. 2 2 //! 3 3 //! The [`SetHash`] trait abstracts over a homomorphic, order-independent set 4 - //! commitment. Two impls are planned: 4 + //! commitment. The production primitive is [`LtHash`], per the 5 + //! [0016 Permissioned Data][spec] draft (§ "Commit digest", lines 263-282): 5 6 //! 6 - //! - [`XorSha256SetHash`] — XOR-of-SHA256 placeholder, matches the Spaces 7 - //! Design Spec's current default. Add and remove are both XOR (self-inverse), 8 - //! so add-then-remove reverts to the empty digest. 9 - //! - `EcmhSetHash` — production target via the first-party `ecmh-rs` crate. 10 - //! Implemented in Phase 8 conditional on upstream picking ECMH (vs. ltHash). 7 + //! - [`LtHash`] — the **production** primitive. A homomorphic lattice hash 8 + //! (BLAKE3-XOF over a 2048-byte / 1024-lane `u16` state, add/sub mod 2^16). 9 + //! The carried commitment is `sha256(state)`. Quantum-secure per the spec. 11 10 //! 12 11 //! All impls share the same algebraic invariants: 13 - //! - Order-independent: add(a); add(b) == add(b); add(a) 14 - //! - Inverse: add(x); remove(x) returns to the prior digest 15 - //! - Composable: digests can be compared by byte equality 12 + //! - Order-independent: `add(a); add(b) == add(b); add(a)` 13 + //! - Inverse: `add(x); remove(x)` returns to the prior state 14 + //! - Composable: states/digests compare by byte equality 16 15 //! 17 - //! Different impls produce **different digest values** — a SetHash from one 18 - //! impl is not comparable with another. The upstream picks one global default; 19 - //! deployments mirror it. 16 + //! Two byte forms are distinguished, because for `LtHash` they differ: 17 + //! - [`SetHash::state_bytes`] — the full lattice **state**, persisted by a repo 18 + //! host and rehydrated via [`SetHash::from_state_bytes`] (2048 bytes for 19 + //! `LtHash`). 20 + //! - [`SetHash::digest`] — the 32-byte **commitment** carried in a 21 + //! [`crate::commit::Commit`] (`hash` field). For `LtHash` this is 22 + //! `sha256(state_bytes())`. 23 + //! 24 + //! [spec]: https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md 20 25 21 26 use crate::errors::{SpaceError, SpaceResult}; 22 27 use sha2::{Digest, Sha256}; 23 28 29 + /// Number of `u16` lanes in an [`LtHash`] state (per the reference). 30 + const LANES: usize = 1024; 31 + /// Byte length of an [`LtHash`] state (`LANES * 2`). 32 + const STATE_BYTES: usize = LANES * 2; 33 + 24 34 /// Trait for set-hash commitment primitives. 25 35 /// 26 36 /// Implementations must satisfy the algebraic invariants documented at the 27 - /// crate level: order-independence, add/remove inverse, byte-equality of digests. 37 + /// crate level: order-independence, add/remove inverse, byte-equality of 38 + /// states. 28 39 pub trait SetHash: Sized + Clone + Send + Sync { 29 40 /// Construct an empty set hash. 30 41 fn empty() -> Self; ··· 40 51 /// membership data structure if you need that. 41 52 fn remove(&mut self, element: &[u8]); 42 53 43 - /// Get the digest as raw bytes. 44 - fn digest(&self) -> Vec<u8>; 54 + /// The persistable lattice **state** bytes. 55 + /// 56 + /// A repo host stores this and rehydrates via [`SetHash::from_state_bytes`]. 57 + /// For [`LtHash`] this is the full 2048-byte state, **not** the 32-byte 58 + /// commitment. 59 + fn state_bytes(&self) -> Vec<u8>; 45 60 46 - /// Reconstruct from a serialized digest. 61 + /// Reconstruct from previously persisted [`SetHash::state_bytes`]. 47 62 /// 48 63 /// # Errors 49 64 /// 50 - /// Returns [`SpaceError::SetHashCodec`] if the bytes do not deserialize 51 - /// for this impl's expected digest size or shape. 52 - fn from_digest(bytes: &[u8]) -> SpaceResult<Self>; 65 + /// Returns [`SpaceError::SetHashCodec`] if the bytes are not the expected 66 + /// length for this impl's state. 67 + fn from_state_bytes(bytes: &[u8]) -> SpaceResult<Self>; 68 + 69 + /// The 32-byte commitment digest carried in a [`crate::commit::Commit`]. 70 + /// 71 + /// For [`LtHash`] this is `sha256(state_bytes())`. 72 + fn digest(&self) -> Vec<u8>; 53 73 } 54 74 55 - /// XOR-of-SHA256 set hash — the Spaces Design Spec's current placeholder primitive. 75 + /// Homomorphic lattice set hash ([LtHash]), the production primitive. 56 76 /// 57 - /// Hashes each element with SHA-256, then XOR-folds the 32-byte digest into 58 - /// the running accumulator. Add and remove are both XOR (self-inverse), so 59 - /// the algebra is naturally homomorphic over multiset deltas. 77 + /// State is a fixed 2048-byte buffer interpreted as 1024 little-endian `u16` 78 + /// lanes. An element is expanded to 2048 bytes with BLAKE3 in XOF mode and its 79 + /// 1024 lanes are added into (or subtracted from) the state, modulo 2^16. The 80 + /// commitment [`digest`](SetHash::digest) is `sha256` of the 2048-byte state. 81 + /// 82 + /// This follows the 0016 spec's LtHash construction (§ "Commit digest", lines 83 + /// 263-282) exactly, so digests are comparable across implementations. 60 84 /// 61 - /// **Not cryptographically strong** — XOR-of-hash is forgeable by anyone who 62 - /// can choose `~2^128` element pairs (birthday-bound). The Spec marks this as 63 - /// "to be replaced by ECMH or ltHash before production." 85 + /// [LtHash]: https://eprint.iacr.org/2019/227 64 86 #[derive(Debug, Clone, PartialEq, Eq)] 65 - pub struct XorSha256SetHash([u8; 32]); 87 + pub struct LtHash { 88 + /// 1024 little-endian `u16` lanes. Arithmetic wraps mod 2^16. 89 + lanes: [u16; LANES], 90 + } 66 91 67 - impl XorSha256SetHash { 68 - /// Construct from raw 32-byte digest. 69 - #[must_use] 70 - pub fn from_bytes(bytes: [u8; 32]) -> Self { 71 - Self(bytes) 72 - } 73 - 74 - /// Get the digest as a fixed-size array. 75 - #[must_use] 76 - pub fn as_bytes(&self) -> &[u8; 32] { 77 - &self.0 92 + impl LtHash { 93 + /// Expand an element to 1024 `u16` lanes via BLAKE3 in XOF mode. 94 + fn expand(element: &[u8]) -> [u16; LANES] { 95 + let mut buf = [0u8; STATE_BYTES]; 96 + let mut hasher = blake3::Hasher::new(); 97 + hasher.update(element); 98 + hasher.finalize_xof().fill(&mut buf); 99 + let mut lanes = [0u16; LANES]; 100 + for (i, lane) in lanes.iter_mut().enumerate() { 101 + *lane = u16::from_le_bytes([buf[2 * i], buf[2 * i + 1]]); 102 + } 103 + lanes 78 104 } 79 105 } 80 106 81 - impl SetHash for XorSha256SetHash { 107 + impl SetHash for LtHash { 82 108 fn empty() -> Self { 83 - Self([0u8; 32]) 109 + Self { 110 + lanes: [0u16; LANES], 111 + } 84 112 } 85 113 86 114 fn add(&mut self, element: &[u8]) { 87 - let mut hasher = Sha256::new(); 88 - hasher.update(element); 89 - let h = hasher.finalize(); 90 - for (i, b) in h.iter().enumerate() { 91 - self.0[i] ^= *b; 115 + let other = Self::expand(element); 116 + for (lane, add) in self.lanes.iter_mut().zip(other.iter()) { 117 + *lane = lane.wrapping_add(*add); 92 118 } 93 119 } 94 120 95 121 fn remove(&mut self, element: &[u8]) { 96 - // XOR is self-inverse: remove == add. 97 - self.add(element); 122 + let other = Self::expand(element); 123 + for (lane, sub) in self.lanes.iter_mut().zip(other.iter()) { 124 + *lane = lane.wrapping_sub(*sub); 125 + } 98 126 } 99 127 100 - fn digest(&self) -> Vec<u8> { 101 - self.0.to_vec() 128 + fn state_bytes(&self) -> Vec<u8> { 129 + let mut out = vec![0u8; STATE_BYTES]; 130 + for (i, lane) in self.lanes.iter().enumerate() { 131 + let [lo, hi] = lane.to_le_bytes(); 132 + out[2 * i] = lo; 133 + out[2 * i + 1] = hi; 134 + } 135 + out 102 136 } 103 137 104 - fn from_digest(bytes: &[u8]) -> SpaceResult<Self> { 105 - if bytes.len() != 32 { 138 + fn from_state_bytes(bytes: &[u8]) -> SpaceResult<Self> { 139 + if bytes.len() != STATE_BYTES { 106 140 return Err(SpaceError::SetHashCodec { 107 - reason: format!("expected 32 bytes, got {}", bytes.len()), 141 + reason: format!( 142 + "LtHash state must be {STATE_BYTES} bytes, got {}", 143 + bytes.len() 144 + ), 108 145 }); 109 146 } 110 - let mut arr = [0u8; 32]; 111 - arr.copy_from_slice(bytes); 112 - Ok(Self(arr)) 147 + let mut lanes = [0u16; LANES]; 148 + for (i, lane) in lanes.iter_mut().enumerate() { 149 + *lane = u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]); 150 + } 151 + Ok(Self { lanes }) 152 + } 153 + 154 + fn digest(&self) -> Vec<u8> { 155 + Sha256::digest(self.state_bytes()).to_vec() 113 156 } 114 157 } 115 158 116 - /// Format the spec-defined element bytes for a record entry in the SetHash. 159 + /// Format the element bytes for a record entry in the SetHash. 117 160 /// 118 - /// records are hashed as 119 - /// `<collection>/<rkey>:<cid>` (UTF-8 bytes). 161 + /// Each record maps to one element, the UTF-8 bytes of 162 + /// `{collection}/{rkey}/{record_cid}` — three slash-joined components, per the 163 + /// 0016 spec (§ "Commit digest", line 270). The slash before the CID is 164 + /// load-bearing: a spec-conformant peer's digest only agrees with this one when 165 + /// the element byte strings match exactly. 120 166 #[must_use] 121 167 pub fn record_element_bytes(collection: &str, rkey: &str, cid: &str) -> Vec<u8> { 122 - format!("{collection}/{rkey}:{cid}").into_bytes() 168 + format!("{collection}/{rkey}/{cid}").into_bytes() 123 169 } 124 170 125 - /// Format the spec-defined element bytes for a member entry in the SetHash. 171 + /// Format the element bytes for a member entry in the SetHash. 126 172 /// 127 - /// members are hashed as 128 - /// the DID's UTF-8 bytes. 173 + /// Members are hashed as the bare DID's UTF-8 bytes. Used by the `simplespace` 174 + /// member-list orchestrator's local digest; the spec carries no member commits. 129 175 #[must_use] 130 176 pub fn member_element_bytes(did: &str) -> Vec<u8> { 131 177 did.as_bytes().to_vec() ··· 134 180 #[cfg(test)] 135 181 mod tests { 136 182 use super::*; 137 - use proptest::prelude::*; 183 + 184 + // ----- LtHash ----- 138 185 139 - /// Helper: apply a sequence of adds and check the digest after permutation. 140 - fn digest_after_adds(elements: &[&[u8]]) -> Vec<u8> { 141 - let mut h = XorSha256SetHash::empty(); 142 - for e in elements { 143 - h.add(e); 144 - } 145 - h.digest() 186 + /// Empty state is 2048 zero bytes; its commitment is `sha256(2048 zeros)`. 187 + #[test] 188 + fn lthash_empty_state_and_digest() { 189 + let h = LtHash::empty(); 190 + assert_eq!(h.state_bytes(), vec![0u8; STATE_BYTES]); 191 + // Known-answer: sha256 of 2048 zero bytes — the empty repo's state is 192 + // all zeroes per the spec (line 280), so its commitment is 193 + // `sha256(zeros)`, NOT 32 zero bytes. 194 + let expected = 195 + hex_to_vec("e5a00aa9991ac8a5ee3109844d84a55583bd20572ad3ffcd42792f3c36b183ad"); 196 + assert_eq!(h.digest(), expected); 197 + assert_eq!(h.digest().len(), 32); 146 198 } 147 199 148 200 #[test] 149 - fn empty_digest_is_zero() { 150 - assert_eq!(XorSha256SetHash::empty().digest(), vec![0u8; 32]); 201 + fn lthash_add_remove_inverse() { 202 + let mut h = LtHash::empty(); 203 + let base = h.state_bytes(); 204 + h.add(b"app.bsky.feed.post/3jui:bafy123"); 205 + assert_ne!(h.state_bytes(), base); 206 + h.remove(b"app.bsky.feed.post/3jui:bafy123"); 207 + assert_eq!(h.state_bytes(), base); 151 208 } 152 209 153 210 #[test] 154 - fn add_remove_inverse() { 155 - let mut h = XorSha256SetHash::empty(); 156 - h.add(b"alice"); 157 - h.remove(b"alice"); 158 - assert_eq!(h.digest(), vec![0u8; 32]); 211 + fn lthash_order_independence() { 212 + let mut a = LtHash::empty(); 213 + a.add(b"one"); 214 + a.add(b"two"); 215 + let mut b = LtHash::empty(); 216 + b.add(b"two"); 217 + b.add(b"one"); 218 + assert_eq!(a.state_bytes(), b.state_bytes()); 219 + assert_eq!(a.digest(), b.digest()); 159 220 } 160 221 161 222 #[test] 162 - fn order_independence_two_elements() { 163 - let d1 = digest_after_adds(&[b"a", b"b"]); 164 - let d2 = digest_after_adds(&[b"b", b"a"]); 165 - assert_eq!(d1, d2); 223 + fn lthash_state_round_trip() { 224 + let mut h = LtHash::empty(); 225 + h.add(b"x"); 226 + h.add(b"y"); 227 + let state = h.state_bytes(); 228 + assert_eq!(state.len(), STATE_BYTES); 229 + let h2 = LtHash::from_state_bytes(&state).unwrap(); 230 + assert_eq!(h, h2); 231 + assert_eq!(h.digest(), h2.digest()); 166 232 } 167 233 168 234 #[test] 169 - fn add_b_then_remove_a_equals_just_b() { 170 - let mut h1 = XorSha256SetHash::empty(); 171 - h1.add(b"a"); 172 - h1.add(b"b"); 173 - h1.remove(b"a"); 174 - 175 - let mut h2 = XorSha256SetHash::empty(); 176 - h2.add(b"b"); 177 - 178 - assert_eq!(h1.digest(), h2.digest()); 235 + fn lthash_from_state_bad_length_rejected() { 236 + assert!(LtHash::from_state_bytes(&[0u8; 2047]).is_err()); 237 + assert!(LtHash::from_state_bytes(&[0u8; 32]).is_err()); 238 + assert!(LtHash::from_state_bytes(&[0u8; STATE_BYTES]).is_ok()); 179 239 } 180 240 241 + /// Lane arithmetic wraps mod 2^16: adding the same element 65536 times 242 + /// returns to the empty state. 181 243 #[test] 182 - fn from_digest_round_trip() { 183 - let mut h = XorSha256SetHash::empty(); 184 - h.add(b"x"); 185 - h.add(b"y"); 186 - let bytes = h.digest(); 187 - let h2 = XorSha256SetHash::from_digest(&bytes).unwrap(); 188 - assert_eq!(h, h2); 244 + fn lthash_lane_wraparound() { 245 + let mut h = LtHash::empty(); 246 + for _ in 0..65_536u32 { 247 + h.add(b"wrap"); 248 + } 249 + assert_eq!(h.state_bytes(), vec![0u8; STATE_BYTES]); 189 250 } 190 251 191 - #[test] 192 - fn from_digest_bad_length_rejected() { 193 - assert!(XorSha256SetHash::from_digest(&[0u8; 31]).is_err()); 194 - assert!(XorSha256SetHash::from_digest(&[0u8; 33]).is_err()); 252 + fn hex_to_vec(s: &str) -> Vec<u8> { 253 + (0..s.len()) 254 + .step_by(2) 255 + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) 256 + .collect() 195 257 } 196 258 197 259 #[test] 198 260 fn record_element_format() { 261 + // Spec line 270: `{collection}/{rkey}/{record_cid}` — slash before CID. 199 262 let bytes = record_element_bytes("app.bsky.feed.post", "3jui", "bafy..."); 200 - assert_eq!(bytes, b"app.bsky.feed.post/3jui:bafy..."); 263 + assert_eq!(bytes, b"app.bsky.feed.post/3jui/bafy..."); 201 264 } 202 265 203 266 #[test] 204 267 fn member_element_format() { 205 268 let bytes = member_element_bytes("did:plc:alice"); 206 269 assert_eq!(bytes, b"did:plc:alice"); 207 - } 208 - 209 - proptest! { 210 - /// Property: any permutation of N adds yields the same digest. 211 - #[test] 212 - fn permutation_invariance(items in prop::collection::vec(any::<u32>(), 0..16)) { 213 - let bytes_vec: Vec<Vec<u8>> = items.iter().map(|n| n.to_be_bytes().to_vec()).collect(); 214 - let mut h1 = XorSha256SetHash::empty(); 215 - for b in &bytes_vec { 216 - h1.add(b); 217 - } 218 - // Permute by reversing. 219 - let mut h2 = XorSha256SetHash::empty(); 220 - for b in bytes_vec.iter().rev() { 221 - h2.add(b); 222 - } 223 - prop_assert_eq!(h1.digest(), h2.digest()); 224 - } 225 - 226 - /// Property: add(x) followed by remove(x) is identity for any x and any prior state. 227 - #[test] 228 - fn add_remove_is_identity(prior in prop::collection::vec(any::<u32>(), 0..8), x: u64) { 229 - let prior_bytes: Vec<Vec<u8>> = prior.iter().map(|n| n.to_be_bytes().to_vec()).collect(); 230 - let x_bytes = x.to_be_bytes().to_vec(); 231 - let mut h1 = XorSha256SetHash::empty(); 232 - for b in &prior_bytes { 233 - h1.add(b); 234 - } 235 - let baseline = h1.digest(); 236 - h1.add(&x_bytes); 237 - h1.remove(&x_bytes); 238 - prop_assert_eq!(h1.digest(), baseline); 239 - } 240 270 } 241 271 }
+11 -48
crates/atproto-space/src/space_members.rs
··· 2 2 //! 3 3 //! Mirrors `SpaceRepo` in shape but operates over the owner's `space_member` 4 4 //! commitment. Add and Remove are the two op kinds; the SetHash is over DID 5 - //! UTF-8 bytes per the Spaces Design Spec. 5 + //! UTF-8 bytes per the 0016 Permissioned Data draft. 6 6 7 7 use crate::errors::{SpaceError, SpaceResult}; 8 8 use crate::set_hash::{SetHash, member_element_bytes}; 9 9 use crate::storage::{ 10 - MemberChange, MemberPage, MemberRow, MemberState, OplogEntry, OplogPage, PreparedCommitMembers, 10 + MemberChange, MemberPage, MemberRow, MemberState, OplogEntry, PreparedCommitMembers, 11 11 SpaceMembersStorage, 12 12 }; 13 13 use crate::types::SpaceUri; ··· 94 94 pub async fn format_commit(&self, ops: &[MemberOp]) -> SpaceResult<PreparedMemberCommit<H>> { 95 95 let state = self.storage.current_state(&self.space).await?; 96 96 let mut set_hash = match state.set_hash { 97 - Some(bytes) => H::from_digest(&bytes)?, 97 + Some(bytes) => H::from_state_bytes(&bytes)?, 98 98 None => H::empty(), 99 99 }; 100 100 ··· 143 143 }); 144 144 } 145 145 146 - let new_digest = set_hash.digest(); 146 + // Persist the full lattice state (rehydrated via `from_state_bytes`). 147 + let new_state = set_hash.state_bytes(); 147 148 Ok(PreparedMemberCommit { 148 149 set_hash, 149 150 rev: rev.clone(), 150 151 storage_commit: PreparedCommitMembers { 151 - new_set_hash: new_digest, 152 + new_set_hash: new_state, 152 153 rev, 153 154 member_changes, 154 155 oplog_entries, ··· 162 163 .apply_commit(&self.space, prepared.storage_commit) 163 164 .await 164 165 } 165 - 166 - /// Read member-oplog entries since the given rev. 167 - pub async fn read_oplog(&self, since: Option<&str>, limit: u32) -> SpaceResult<OplogPage> { 168 - self.storage.read_oplog(&self.space, since, limit).await 169 - } 170 166 } 171 167 172 168 /// In-memory `SpaceMembersStorage` for testing. ··· 184 180 struct Inner { 185 181 members: BTreeMap<(SpaceUri, String), MemberRow>, 186 182 states: BTreeMap<SpaceUri, MemberState>, 187 - oplog: BTreeMap<(SpaceUri, String, u32), OplogEntry>, 188 183 } 189 184 190 185 impl InMemorySpaceMembersStorage { ··· 194 189 inner: Mutex::new(Inner { 195 190 members: BTreeMap::new(), 196 191 states: BTreeMap::new(), 197 - oplog: BTreeMap::new(), 198 192 }), 199 193 } 200 194 } ··· 274 268 } 275 269 } 276 270 } 277 - for entry in commit.oplog_entries { 278 - inner 279 - .oplog 280 - .insert((space.clone(), entry.rev.clone(), entry.idx), entry); 281 - } 271 + // The 0016 Permissioned Data draft has no member-list oplog read 272 + // path, so `commit.oplog_entries` is not retained in this test 273 + // store; only the member set + state commitment are updated. 282 274 inner.states.insert( 283 275 space.clone(), 284 276 MemberState { ··· 288 280 ); 289 281 Ok(()) 290 282 } 291 - 292 - async fn read_oplog( 293 - &self, 294 - space: &SpaceUri, 295 - since: Option<&str>, 296 - limit: u32, 297 - ) -> SpaceResult<OplogPage> { 298 - let inner = self.inner.lock().unwrap(); 299 - let mut ops: Vec<OplogEntry> = inner 300 - .oplog 301 - .iter() 302 - .filter(|((s, rev, _), _)| { 303 - s == space 304 - && match since { 305 - Some(cur) => rev.as_str() > cur, 306 - None => true, 307 - } 308 - }) 309 - .map(|(_, e)| e.clone()) 310 - .collect(); 311 - ops.sort_by(|a, b| (a.rev.as_str(), a.idx).cmp(&(b.rev.as_str(), b.idx))); 312 - ops.truncate(limit as usize); 313 - let state = inner 314 - .states 315 - .get(space) 316 - .cloned() 317 - .unwrap_or_else(MemberState::empty); 318 - Ok(OplogPage { ops, state }) 319 - } 320 283 } 321 284 } 322 285 ··· 324 287 mod tests { 325 288 use super::memory::InMemorySpaceMembersStorage; 326 289 use super::*; 327 - use crate::set_hash::XorSha256SetHash; 290 + use crate::set_hash::LtHash; 328 291 use crate::types::{SpaceKey, SpaceType}; 329 292 330 293 fn test_space() -> SpaceUri { ··· 335 298 ) 336 299 } 337 300 338 - type TestMembers = SpaceMembers<InMemorySpaceMembersStorage, XorSha256SetHash>; 301 + type TestMembers = SpaceMembers<InMemorySpaceMembersStorage, LtHash>; 339 302 340 303 #[tokio::test] 341 304 async fn add_then_remove() {
+72 -12
crates/atproto-space/src/space_repo.rs
··· 10 10 use crate::errors::{SpaceError, SpaceResult}; 11 11 use crate::set_hash::{SetHash, record_element_bytes}; 12 12 use crate::storage::{ 13 - OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, RepoState, 14 - SpaceRepoStorage, 13 + OplogCursor, OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, 14 + RepoState, SpaceRepoStorage, 15 15 }; 16 16 use crate::types::SpaceUri; 17 17 use atproto_record::tid::Tid; ··· 127 127 // Load current state to derive the new SetHash incrementally. 128 128 let state = self.storage.current_state(&self.space).await?; 129 129 let mut set_hash = match state.set_hash { 130 - Some(bytes) => H::from_digest(&bytes)?, 130 + Some(bytes) => H::from_state_bytes(&bytes)?, 131 131 None => H::empty(), 132 132 }; 133 133 ··· 258 258 } 259 259 } 260 260 261 - let new_digest = set_hash.digest(); 261 + // The persisted value is the full lattice STATE (2048 bytes for 262 + // LtHash), rehydrated via `from_state_bytes`. The 32-byte commitment 263 + // (`sha256(state)`) is computed separately by `create_commit`. 264 + let new_state = set_hash.state_bytes(); 262 265 Ok(PreparedCommit { 263 266 set_hash, 264 267 rev: rev.clone(), 265 268 storage_commit: PreparedCommitRecords { 266 - new_set_hash: new_digest, 269 + new_set_hash: new_state, 267 270 rev, 268 271 record_changes, 269 272 oplog_entries, ··· 278 281 .await 279 282 } 280 283 281 - /// Read oplog entries since the given rev (exclusive). `None` = from start. 282 - pub async fn read_oplog(&self, since: Option<&str>, limit: u32) -> SpaceResult<OplogPage> { 284 + /// Read oplog entries strictly after the given `(rev, idx)` cursor. 285 + /// `None` = from start. 286 + pub async fn read_oplog( 287 + &self, 288 + since: Option<&OplogCursor>, 289 + limit: u32, 290 + ) -> SpaceResult<OplogPage> { 283 291 self.storage.read_oplog(&self.space, since, limit).await 284 292 } 285 293 } ··· 436 444 async fn read_oplog( 437 445 &self, 438 446 space: &SpaceUri, 439 - since: Option<&str>, 447 + since: Option<&OplogCursor>, 440 448 limit: u32, 441 449 ) -> SpaceResult<OplogPage> { 442 450 let inner = self.inner.lock().unwrap(); 443 451 let mut ops: Vec<OplogEntry> = inner 444 452 .oplog 445 453 .iter() 446 - .filter(|((s, rev, _), _)| { 454 + .filter(|((s, rev, idx), _)| { 447 455 s == space 448 456 && match since { 449 - Some(cur) => rev.as_str() > cur, 457 + Some(cur) => (rev.as_str(), *idx) > (cur.rev.as_str(), cur.idx), 450 458 None => true, 451 459 } 452 460 }) ··· 468 476 mod tests { 469 477 use super::memory::InMemorySpaceRepoStorage; 470 478 use super::*; 471 - use crate::set_hash::XorSha256SetHash; 479 + use crate::set_hash::LtHash; 472 480 use crate::types::{SpaceKey, SpaceType}; 473 481 474 482 fn test_space() -> SpaceUri { ··· 479 487 ) 480 488 } 481 489 482 - type TestRepo = SpaceRepo<InMemorySpaceRepoStorage, XorSha256SetHash>; 490 + type TestRepo = SpaceRepo<InMemorySpaceRepoStorage, LtHash>; 483 491 484 492 #[tokio::test] 485 493 async fn create_then_read() { ··· 617 625 assert_eq!(page.ops[0].rev, page.ops[1].rev, "shared rev"); 618 626 assert_eq!(page.ops[0].idx, 0); 619 627 assert_eq!(page.ops[1].idx, 1); 628 + } 629 + 630 + /// Regression: a single atomic batch (all ops share one rev) larger than the 631 + /// page `limit` must page fully via the `(rev, idx)` cursor without dropping 632 + /// the batch's tail. A bare-rev cursor would advance past the whole rev after 633 + /// page one, permanently skipping ops `limit..N`. 634 + #[tokio::test] 635 + async fn batch_larger_than_limit_pages_fully() { 636 + let space = test_space(); 637 + let repo: TestRepo = SpaceRepo::new(space, InMemorySpaceRepoStorage::new()); 638 + 639 + const N: usize = 7; 640 + const LIMIT: u32 = 3; 641 + 642 + // One commit, N creates -> N oplog entries sharing a single rev. 643 + let ops: Vec<Op> = (0..N) 644 + .map(|i| Op { 645 + action: OpAction::Create, 646 + collection: "c".to_string(), 647 + rkey: format!("k{i}"), 648 + cid: Some(format!("cid{i}")), 649 + value: Some(vec![]), 650 + }) 651 + .collect(); 652 + let prepared = repo.format_commit(&ops).await.unwrap(); 653 + repo.apply_commit(prepared).await.unwrap(); 654 + 655 + // Page through with the (rev, idx) cursor until caught up. 656 + let mut seen: Vec<(String, u32)> = Vec::new(); 657 + let mut cursor: Option<OplogCursor> = None; 658 + loop { 659 + let page = repo.read_oplog(cursor.as_ref(), LIMIT).await.unwrap(); 660 + for op in &page.ops { 661 + seen.push((op.rev.clone(), op.idx)); 662 + } 663 + let caught_up = (page.ops.len() as u32) < LIMIT; 664 + cursor = page 665 + .ops 666 + .last() 667 + .map(|o| OplogCursor::new(o.rev.clone(), o.idx)); 668 + if caught_up { 669 + break; 670 + } 671 + } 672 + 673 + // All N ops seen exactly once, in order, all within the same rev. 674 + assert_eq!(seen.len(), N, "all batch ops delivered, none skipped"); 675 + let rev = &seen[0].0; 676 + for (i, (r, idx)) in seen.iter().enumerate() { 677 + assert_eq!(r, rev, "all ops share the batch rev"); 678 + assert_eq!(*idx as usize, i, "idx is dense and monotonic"); 679 + } 620 680 } 621 681 }
+97 -10
crates/atproto-space/src/storage.rs
··· 6 6 //! `fjall` Cargo feature). In-memory impls live in [`crate::space_repo::memory`] 7 7 //! and [`crate::space_members::memory`] for testing. 8 8 9 - use crate::errors::SpaceResult; 9 + use crate::errors::{SpaceError, SpaceResult}; 10 10 use crate::types::SpaceUri; 11 11 use async_trait::async_trait; 12 12 use serde::{Deserialize, Serialize}; 13 + 14 + /// A `(rev, idx)`-granular oplog cursor. 15 + /// 16 + /// The oplog `since` cursor must be `(rev, idx)`-granular rather than 17 + /// rev-granular: an atomic batch (entries sharing a `rev`) can exceed a single 18 + /// page's `limit`, so paging by bare `rev` would skip the batch's tail. The 19 + /// cursor names the last op delivered, and the next page resumes strictly after 20 + /// it (`(rev, idx) > (cursor.rev, cursor.idx)`). 21 + #[derive(Debug, Clone, PartialEq, Eq)] 22 + pub struct OplogCursor { 23 + /// Rev (TID) of the last delivered op. 24 + pub rev: String, 25 + /// Index of the last delivered op within its `rev` batch. 26 + pub idx: u32, 27 + } 28 + 29 + impl OplogCursor { 30 + /// Construct a cursor from a `rev` and `idx`. 31 + #[must_use] 32 + pub fn new(rev: String, idx: u32) -> Self { 33 + Self { rev, idx } 34 + } 35 + 36 + /// Encode as an opaque wire token `"<rev>__<idx>"`. 37 + #[must_use] 38 + pub fn to_token(&self) -> String { 39 + format!("{}__{}", self.rev, self.idx) 40 + } 41 + 42 + /// Decode an opaque wire token `"<rev>__<idx>"`. 43 + /// 44 + /// Returns [`SpaceError::InvalidCursor`] if the token is malformed. 45 + pub fn from_token(token: &str) -> SpaceResult<Self> { 46 + let (rev, idx) = token 47 + .rsplit_once("__") 48 + .ok_or_else(|| SpaceError::InvalidCursor { 49 + token: token.to_string(), 50 + })?; 51 + let idx: u32 = idx.parse().map_err(|_| SpaceError::InvalidCursor { 52 + token: token.to_string(), 53 + })?; 54 + if rev.is_empty() { 55 + return Err(SpaceError::InvalidCursor { 56 + token: token.to_string(), 57 + }); 58 + } 59 + Ok(Self { 60 + rev: rev.to_string(), 61 + idx, 62 + }) 63 + } 64 + } 13 65 14 66 /// Current per-space record commitment. 15 67 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] ··· 215 267 commit: PreparedCommitRecords, 216 268 ) -> SpaceResult<()>; 217 269 218 - /// Read oplog entries since the given rev (exclusive). `None` = from the start. 270 + /// Read oplog entries strictly after the given `(rev, idx)` cursor. `None` = 271 + /// from the start. 272 + /// 273 + /// The predicate is `(rev, idx) > (since.rev, since.idx)`, ordered by 274 + /// `(rev, idx)`. This keeps atomic batches (entries sharing a `rev`) 275 + /// coherent across paging even when a batch exceeds `limit`. 219 276 /// 220 277 /// If `since` predates the retained range, returns [`SpaceError::OplogGap`]. 221 278 async fn read_oplog( 222 279 &self, 223 280 space: &SpaceUri, 224 - since: Option<&str>, 281 + since: Option<&OplogCursor>, 225 282 limit: u32, 226 283 ) -> SpaceResult<OplogPage>; 227 284 } ··· 249 306 space: &SpaceUri, 250 307 commit: PreparedCommitMembers, 251 308 ) -> SpaceResult<()>; 309 + } 310 + 311 + #[cfg(test)] 312 + mod tests { 313 + use super::*; 252 314 253 - /// Read member-oplog entries since the given rev. 254 - async fn read_oplog( 255 - &self, 256 - space: &SpaceUri, 257 - since: Option<&str>, 258 - limit: u32, 259 - ) -> SpaceResult<OplogPage>; 315 + #[test] 316 + fn oplog_cursor_token_round_trip() { 317 + let cur = OplogCursor::new("3kabc".to_string(), 5); 318 + let token = cur.to_token(); 319 + assert_eq!(token, "3kabc__5"); 320 + assert_eq!(OplogCursor::from_token(&token).unwrap(), cur); 321 + } 322 + 323 + #[test] 324 + fn oplog_cursor_token_round_trip_idx_zero() { 325 + let cur = OplogCursor::new("3kabc".to_string(), 0); 326 + assert_eq!(OplogCursor::from_token(&cur.to_token()).unwrap(), cur); 327 + } 328 + 329 + #[test] 330 + fn oplog_cursor_rejects_malformed() { 331 + // Missing separator. 332 + assert!(matches!( 333 + OplogCursor::from_token("3kabc"), 334 + Err(SpaceError::InvalidCursor { .. }) 335 + )); 336 + // Non-numeric idx. 337 + assert!(matches!( 338 + OplogCursor::from_token("3kabc__x"), 339 + Err(SpaceError::InvalidCursor { .. }) 340 + )); 341 + // Empty rev. 342 + assert!(matches!( 343 + OplogCursor::from_token("__3"), 344 + Err(SpaceError::InvalidCursor { .. }) 345 + )); 346 + } 260 347 }
+190 -19
crates/atproto-space/src/types.rs
··· 1 1 //! Core types for permissioned-data spaces. 2 2 //! 3 - //! `SpaceUri`, `SpaceType`, `SpaceKey` are wrapper newtypes around strings 4 - //! that validate at construction time. They are abstracted so the URI scheme 5 - //! (`ats://` per the Spaces Design Spec, provisional) can change without 3 + //! `SpaceUri`, `SpaceType`, `SpaceKey`, `RecordUri` are wrapper newtypes around 4 + //! strings that validate at construction time. They are abstracted so the URI 5 + //! scheme (`ats://` per the 0016 Permissioned Data draft) can change without 6 6 //! callers needing to update. 7 7 8 8 use crate::errors::{SpaceError, SpaceResult}; 9 9 use serde::{Deserialize, Serialize}; 10 10 use std::fmt; 11 11 12 - /// URI scheme for permissioned data spaces (provisional per Spaces Design Spec). 12 + /// URI scheme for permissioned data spaces (per the 0016 Permissioned Data draft). 13 13 pub const ATS_SCHEME: &str = "ats://"; 14 14 15 15 /// A space type — an NSID describing the space modality (e.g., `app.bsky.group`). ··· 44 44 } 45 45 } 46 46 47 - /// A space key — an arbitrary identifier scoped under (owner, type). 47 + /// Maximum `skey` length in UTF-8 bytes (per `com.atproto.simplespace.createSpace`). 48 + pub const SPACE_KEY_MAX_BYTES: usize = 512; 49 + 50 + /// A space key — an arbitrary identifier scoped under (authority, type). 48 51 /// 49 - /// Cannot contain `/` (would corrupt the URI segment structure) and cannot be empty. 52 + /// Per the 0016 Permissioned Data draft (line 134), an `skey` has a maximum 53 + /// length of 512 bytes and the same syntax requirements as an `rkey`: the 54 + /// characters are restricted to `[A-Za-z0-9._:~-]`, and the reserved values 55 + /// `.` and `..` are disallowed. 50 56 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 51 57 #[serde(transparent)] 52 58 pub struct SpaceKey(String); 53 59 54 60 impl SpaceKey { 55 - /// Construct a new `SpaceKey`. Validates non-empty and slash-free. 61 + /// Construct a new `SpaceKey`. Validates that the value is non-empty, within 62 + /// the 512-byte length cap, restricted to the record-key character set 63 + /// `[A-Za-z0-9._:~-]`, and is not a reserved value (`.` or `..`). 56 64 /// 57 65 /// # Errors 58 66 /// 59 67 /// Returns [`SpaceError::InvalidSpaceKey`] if validation fails. 60 68 pub fn new(value: impl Into<String>) -> SpaceResult<Self> { 61 69 let value = value.into(); 62 - if value.is_empty() || value.contains('/') { 70 + if value.len() > SPACE_KEY_MAX_BYTES || !is_valid_record_key(&value) { 63 71 return Err(SpaceError::InvalidSpaceKey { value }); 64 72 } 65 73 Ok(Self(value)) ··· 78 86 } 79 87 } 80 88 81 - /// A space URI: `ats://<owner-did>/<space-type>/<space-key>`. 89 + /// A space URI: `ats://<authority-did>/<space-type>/<space-key>`. 82 90 /// 83 91 /// The components are validated at construction. The full URI is round-trip 84 92 /// stable through `Display` and `parse`. 85 93 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] 86 94 #[serde(into = "String", try_from = "String")] 87 95 pub struct SpaceUri { 88 - /// DID of the space owner. 89 - pub owner_did: String, 96 + /// Space authority DID. 97 + pub space_did: String, 90 98 /// Space type (NSID). 91 99 pub space_type: SpaceType, 92 100 /// Space key. ··· 95 103 96 104 impl SpaceUri { 97 105 /// Construct a new `SpaceUri` from validated components. 98 - pub fn new(owner_did: String, space_type: SpaceType, space_key: SpaceKey) -> Self { 106 + pub fn new(space_did: String, space_type: SpaceType, space_key: SpaceKey) -> Self { 99 107 Self { 100 - owner_did, 108 + space_did, 101 109 space_type, 102 110 space_key, 103 111 } ··· 114 122 .strip_prefix(ATS_SCHEME) 115 123 .ok_or_else(|| SpaceError::InvalidSpaceUri { uri: s.to_string() })?; 116 124 117 - // Split into 3 parts: owner_did, space_type, space_key. 118 - // owner_did itself may contain `:` but no `/`, so we split on `/`. 125 + // Split into 3 parts: space_did, space_type, space_key. 126 + // space_did itself may contain `:` but no `/`, so we split on `/`. 119 127 let mut parts = stripped.splitn(3, '/'); 120 - let owner_did = parts 128 + let space_did = parts 121 129 .next() 122 130 .filter(|s| !s.is_empty()) 123 131 .ok_or_else(|| SpaceError::InvalidSpaceUri { uri: s.to_string() })? ··· 131 139 .filter(|s| !s.is_empty()) 132 140 .ok_or_else(|| SpaceError::InvalidSpaceUri { uri: s.to_string() })?; 133 141 134 - if !owner_did.starts_with("did:") { 142 + if !space_did.starts_with("did:") { 135 143 return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() }); 136 144 } 137 145 138 146 Ok(Self { 139 - owner_did, 147 + space_did, 140 148 space_type: SpaceType::new(space_type_str)?, 141 149 space_key: SpaceKey::new(space_key_str)?, 142 150 }) ··· 148 156 write!( 149 157 f, 150 158 "{}{}/{}/{}", 151 - ATS_SCHEME, self.owner_did, self.space_type, self.space_key 159 + ATS_SCHEME, self.space_did, self.space_type, self.space_key 152 160 ) 153 161 } 154 162 } ··· 175 183 } 176 184 } 177 185 186 + /// A permissioned **record** URI of six components: 187 + /// `ats://<spaceDid>/<spaceType>/<skey>/<authorDid>/<collection>/<rkey>`. 188 + /// 189 + /// The first three components are the [`SpaceUri`]; the remaining three 190 + /// identify a record authored by `author_did` within that space. All six 191 + /// segments are required to identify a permissioned record (the space does not 192 + /// colocate records — each author's records live in their own permissioned 193 + /// repo, so the author DID is part of the address). 194 + #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] 195 + pub struct RecordUri { 196 + /// The space the record belongs to (first three URI segments). 197 + pub space: SpaceUri, 198 + /// DID of the record's author. 199 + pub author_did: String, 200 + /// Record collection (NSID). 201 + pub collection: String, 202 + /// Record key. 203 + pub rkey: String, 204 + } 205 + 206 + impl RecordUri { 207 + /// Construct a record URI from its parts. 208 + #[must_use] 209 + pub fn new(space: SpaceUri, author_did: String, collection: String, rkey: String) -> Self { 210 + Self { 211 + space, 212 + author_did, 213 + collection, 214 + rkey, 215 + } 216 + } 217 + 218 + /// Parse from the canonical six-segment wire form. 219 + /// 220 + /// # Errors 221 + /// 222 + /// Returns [`SpaceError::InvalidSpaceUri`] if the URI does not have exactly 223 + /// six non-empty segments or a component fails validation. 224 + pub fn parse(s: &str) -> SpaceResult<Self> { 225 + let stripped = s 226 + .strip_prefix(ATS_SCHEME) 227 + .ok_or_else(|| SpaceError::InvalidSpaceUri { uri: s.to_string() })?; 228 + // A record URI has EXACTLY six segments; a 7th `/` (or any extra 229 + // segment) is rejected rather than absorbed into the rkey. 230 + let parts: Vec<&str> = stripped.split('/').collect(); 231 + if parts.len() != 6 || parts.iter().any(|p| p.is_empty()) { 232 + return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() }); 233 + } 234 + let space = SpaceUri::new( 235 + { 236 + if !parts[0].starts_with("did:") { 237 + return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() }); 238 + } 239 + parts[0].to_string() 240 + }, 241 + SpaceType::new(parts[1])?, 242 + SpaceKey::new(parts[2])?, 243 + ); 244 + if !parts[3].starts_with("did:") { 245 + return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() }); 246 + } 247 + // The collection segment is typed as an NSID by the spec (Addressing, 248 + // line 73). 249 + if !is_valid_nsid(parts[4]) { 250 + return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() }); 251 + } 252 + Ok(Self { 253 + space, 254 + author_did: parts[3].to_string(), 255 + collection: parts[4].to_string(), 256 + rkey: parts[5].to_string(), 257 + }) 258 + } 259 + } 260 + 261 + impl fmt::Display for RecordUri { 262 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 263 + write!( 264 + f, 265 + "{}/{}/{}/{}", 266 + self.space, self.author_did, self.collection, self.rkey 267 + ) 268 + } 269 + } 270 + 271 + /// Record-key syntax validation per AT Protocol (the `rkey` baseline the 0016 272 + /// draft references for `skey`, line 134): 1-512 characters drawn from 273 + /// `[A-Za-z0-9._:~-]`, and not the reserved values `.` or `..`. 274 + fn is_valid_record_key(s: &str) -> bool { 275 + if s.is_empty() || s.len() > SPACE_KEY_MAX_BYTES { 276 + return false; 277 + } 278 + if s == "." || s == ".." { 279 + return false; 280 + } 281 + s.chars() 282 + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~' | ':')) 283 + } 284 + 178 285 /// Minimal NSID validation per AT Protocol — at least three dot-separated segments, 179 286 /// each non-empty alphanumeric/hyphen-only. 180 287 fn is_valid_nsid(s: &str) -> bool { ··· 218 325 } 219 326 220 327 #[test] 328 + fn space_key_valid_rkey_charset() { 329 + // Full record-key charset is accepted. 330 + assert!(SpaceKey::new("key-with-hyphens").is_ok()); 331 + assert!(SpaceKey::new("key_with_underscores").is_ok()); 332 + assert!(SpaceKey::new("key~with~tildes").is_ok()); 333 + assert!(SpaceKey::new("key:with:colons").is_ok()); 334 + assert!(SpaceKey::new("example.com").is_ok()); 335 + assert!(SpaceKey::new("self").is_ok()); 336 + } 337 + 338 + #[test] 221 339 fn space_key_invalid() { 222 340 assert!(SpaceKey::new("").is_err()); 223 341 assert!(SpaceKey::new("with/slash").is_err()); 342 + // Reserved record-key values. 343 + assert!(SpaceKey::new(".").is_err()); 344 + assert!(SpaceKey::new("..").is_err()); 345 + // Out-of-charset values rejected (rkey syntax, spec line 134). 346 + assert!(SpaceKey::new("has space").is_err()); 347 + assert!(SpaceKey::new("a@b").is_err()); 348 + assert!(SpaceKey::new("emoji\u{1f600}").is_err()); 349 + } 350 + 351 + #[test] 352 + fn space_key_length_cap() { 353 + assert!(SpaceKey::new("a".repeat(SPACE_KEY_MAX_BYTES)).is_ok()); 354 + assert!(SpaceKey::new("a".repeat(SPACE_KEY_MAX_BYTES + 1)).is_err()); 355 + } 356 + 357 + #[test] 358 + fn record_uri_round_trip() { 359 + let s = "ats://did:plc:auth/app.bsky.group/default/did:plc:alice/app.bsky.feed.post/3jui"; 360 + let parsed = RecordUri::parse(s).unwrap(); 361 + assert_eq!(parsed.author_did, "did:plc:alice"); 362 + assert_eq!(parsed.collection, "app.bsky.feed.post"); 363 + assert_eq!(parsed.rkey, "3jui"); 364 + assert_eq!(parsed.space.space_did, "did:plc:auth"); 365 + assert_eq!(parsed.to_string(), s); 366 + } 367 + 368 + #[test] 369 + fn record_uri_parse_failures() { 370 + // 3-segment (space only) is not a record URI. 371 + assert!(RecordUri::parse("ats://did:plc:auth/app.bsky.group/default").is_err()); 372 + // author segment must be a DID. 373 + assert!( 374 + RecordUri::parse( 375 + "ats://did:plc:auth/app.bsky.group/default/alice/app.bsky.feed.post/k" 376 + ) 377 + .is_err() 378 + ); 379 + // wrong scheme. 380 + assert!(RecordUri::parse("https://x/y/z/a/b/c").is_err()); 381 + // collection segment must be a valid NSID (spec line 73). 382 + assert!( 383 + RecordUri::parse( 384 + "ats://did:plc:auth/app.bsky.group/default/did:plc:alice/notanNSID/rk" 385 + ) 386 + .is_err() 387 + ); 388 + // a 7th segment is rejected rather than absorbed into the rkey. 389 + assert!( 390 + RecordUri::parse( 391 + "ats://did:plc:auth/app.bsky.group/default/did:plc:alice/app.bsky.feed.post/rk/extra" 392 + ) 393 + .is_err() 394 + ); 224 395 } 225 396 226 397 #[test]
+1 -1
crates/atproto-tap/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-tap" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol TAP (Trusted Attestation Protocol) service consumer" 5 5 readme = "README.md" 6 6 homepage = "https://tangled.org/ngerakines.me/atproto-crates"
+1 -1
crates/atproto-xrpcs-helloworld/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-xrpcs-helloworld" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "Complete example implementation of an AT Protocol XRPC service with DID web functionality and JWT authentication" 5 5 homepage = "https://tangled.org/ngerakines.me/atproto-crates" 6 6 documentation = "https://docs.rs/atproto-xrpcs-helloworld"
+1 -1
crates/atproto-xrpcs/Cargo.toml
··· 1 1 [package] 2 2 name = "atproto-xrpcs" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "Core building blocks for implementing AT Protocol XRPC services with JWT authorization" 5 5 homepage = "https://tangled.org/ngerakines.me/atproto-crates" 6 6 documentation = "https://docs.rs/atproto-xrpcs"
+1 -1
crates/atpxrpc/Cargo.toml
··· 1 1 [package] 2 2 name = "atpxrpc" 3 - version = "0.15.0-alpha.1" 3 + version = "0.15.0-alpha.2" 4 4 description = "AT Protocol XRPC client with persistent session management" 5 5 homepage = "https://tangled.org/ngerakines.me/atproto-crates" 6 6 documentation = "https://docs.rs/atpxrpc"