From c8b0a0e20a3af42e2c886e3e48e05fbbffae2a2e Mon Sep 17 00:00:00 2001 From: Nick Gerakines <12125+ngerakines@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:44:31 -0400 Subject: [PATCH] release: 0.15.0-alpha.2 --- CHANGELOG.md | 9 + Cargo.lock | 60 +- Cargo.toml | 34 +- Dockerfile | 2 +- README.md | 22 +- crates/atpmcp/Cargo.toml | 2 +- crates/atproto-attestation/Cargo.toml | 2 +- crates/atproto-client/Cargo.toml | 2 +- crates/atproto-dasl/Cargo.toml | 2 +- crates/atproto-extras/Cargo.toml | 2 +- crates/atproto-identity/Cargo.toml | 2 +- .../src/bin/atproto-identity-plc-audit.rs | 2 +- .../src/bin/atproto-identity-plc-fork-viz.rs | 2 +- crates/atproto-identity/src/model.rs | 73 + crates/atproto-jetstream/Cargo.toml | 2 +- crates/atproto-lexicon/Cargo.toml | 2 +- crates/atproto-lexicon/README.md | 2 +- .../src/validation/data_errors.rs | 33 + .../src/validation/schema/mod.rs | 140 +- .../src/validation/schema_file.rs | 247 +- .../src/validation/validate.rs | 3 +- crates/atproto-oauth-aip/Cargo.toml | 2 +- crates/atproto-oauth-axum/Cargo.toml | 2 +- crates/atproto-oauth/Cargo.toml | 2 +- crates/atproto-oauth/src/errors.rs | 21 + crates/atproto-oauth/src/scopes.rs | 289 ++ .../src/scopes/space_permission.rs | 1392 ++++++++ crates/atproto-pds/Cargo.toml | 7 +- crates/atproto-pds/README.md | 30 +- .../accounts/20260501000001_init.sql | 6 + .../migrations/actor/20260501000001_init.sql | 31 +- .../postgres/20260507000001_init.sql | 2 + .../src/actor_store/fjall/keyspace.rs | 25 +- .../fjall/space_members_storage.rs | 74 +- .../actor_store/fjall/space_repo_storage.rs | 21 +- .../actor_store/sql/space_members_storage.rs | 62 +- .../src/actor_store/sql/space_repo_storage.rs | 84 +- crates/atproto-pds/src/admin/handlers.rs | 2 +- crates/atproto-pds/src/bin/pds.rs | 44 +- crates/atproto-pds/src/errors.rs | 17 + crates/atproto-pds/src/gc.rs | 2 +- crates/atproto-pds/src/http/auth.rs | 33 + crates/atproto-pds/src/http/errors.rs | 10 + crates/atproto-pds/src/http/router.rs | 81 +- crates/atproto-pds/src/http/space_auth.rs | 497 ++- crates/atproto-pds/src/http/space_handlers.rs | 2865 +++++++++++++---- crates/atproto-pds/src/http/state.rs | 38 +- crates/atproto-pds/src/notifier.rs | 105 +- crates/atproto-pds/src/oauth/consent.rs | 486 ++- crates/atproto-pds/src/plc.rs | 72 +- crates/atproto-pds/src/realm.rs | 110 +- crates/atproto-pds/src/security.rs | 4 +- crates/atproto-pds/src/space/config.rs | 450 +++ crates/atproto-pds/src/space/declaration.rs | 391 +++ crates/atproto-pds/src/space/inbound.rs | 261 +- crates/atproto-pds/src/space/mint_authz.rs | 643 ++++ crates/atproto-pds/src/space/mod.rs | 20 +- crates/atproto-pds/src/space/notify.rs | 366 ++- crates/atproto-pds/src/space/reader.rs | 356 +- crates/atproto-pds/src/space/recipient.rs | 63 +- crates/atproto-pds/src/space/service.rs | 796 ++++- crates/atproto-pds/src/space/service_auth.rs | 244 ++ crates/atproto-pds/src/space/sync.rs | 116 +- crates/atproto-pds/src/space/writer.rs | 398 ++- crates/atproto-pds/tests/http_phase2.rs | 3 +- .../atproto-pds/tests/http_phase7_spaces.rs | 1484 ++++++--- crates/atproto-pds/tests/notifier_e2e.rs | 11 +- crates/atproto-record/Cargo.toml | 2 +- crates/atproto-repo/Cargo.toml | 2 +- crates/atproto-space/Cargo.toml | 17 +- crates/atproto-space/README.md | 75 +- crates/atproto-space/src/commit.rs | 462 +-- crates/atproto-space/src/credential.rs | 498 ++- crates/atproto-space/src/errors.rs | 10 +- crates/atproto-space/src/lib.rs | 48 +- crates/atproto-space/src/set_hash.rs | 302 +- crates/atproto-space/src/space_members.rs | 59 +- crates/atproto-space/src/space_repo.rs | 84 +- crates/atproto-space/src/storage.rs | 107 +- crates/atproto-space/src/types.rs | 209 +- crates/atproto-tap/Cargo.toml | 2 +- crates/atproto-xrpcs-helloworld/Cargo.toml | 2 +- crates/atproto-xrpcs/Cargo.toml | 2 +- crates/atpxrpc/Cargo.toml | 2 +- 84 files changed, 11251 insertions(+), 3295 deletions(-) create mode 100644 crates/atproto-oauth/src/scopes/space_permission.rs create mode 100644 crates/atproto-pds/src/space/config.rs create mode 100644 crates/atproto-pds/src/space/declaration.rs create mode 100644 crates/atproto-pds/src/space/mint_authz.rs create mode 100644 crates/atproto-pds/src/space/service_auth.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 80fb7dc..b4d3709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.15.0-alpha.2] - 2026-06-26 +### Changed +- 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. +- Unified space-declaration resolution across the OAuth consent screen and the `space:` scope gate behind a shared resolver. + +### Removed +- Permissioned-data member-sync machinery (`getMemberState` / `getMemberOplog` / `notifyMembership`); member-list management (`addMember` / `removeMember` / `listMembers`) is retained. + ## [0.15.0-alpha.1] - 2026-05-09 ### Added - 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,6 +250,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Core DID document handling - Cryptographic key operations for P-256 curves +[0.15.0-alpha.2]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.15.0-alpha.2 [0.15.0-alpha.1]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.15.0-alpha.1 [0.14.6]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.6 [0.14.5]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.5 diff --git a/Cargo.lock b/Cargo.lock index 1fe9b56..9f80e86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,7 +170,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atpmcp" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -195,7 +195,7 @@ dependencies = [ [[package]] name = "atproto-attestation" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -221,7 +221,7 @@ dependencies = [ [[package]] name = "atproto-client" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -245,7 +245,7 @@ dependencies = [ [[package]] name = "atproto-dasl" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "axum", @@ -272,7 +272,7 @@ dependencies = [ [[package]] name = "atproto-extras" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -287,7 +287,7 @@ dependencies = [ [[package]] name = "atproto-identity" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "atproto-jetstream" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -341,7 +341,7 @@ dependencies = [ [[package]] name = "atproto-lexicon" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -367,7 +367,7 @@ dependencies = [ [[package]] name = "atproto-oauth" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -398,7 +398,7 @@ dependencies = [ [[package]] name = "atproto-oauth-aip" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "atproto-identity", @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "atproto-oauth-axum" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -441,13 +441,14 @@ dependencies = [ [[package]] name = "atproto-pds" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "argon2", "async-trait", "atproto-dasl", "atproto-identity", + "atproto-lexicon", "atproto-oauth", "atproto-record", "atproto-repo", @@ -494,7 +495,7 @@ dependencies = [ [[package]] name = "atproto-record" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -515,7 +516,7 @@ dependencies = [ [[package]] name = "atproto-repo" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "atproto-dasl", @@ -535,7 +536,7 @@ dependencies = [ [[package]] name = "atproto-space" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -543,11 +544,10 @@ dependencies = [ "atproto-identity", "atproto-record", "base64", + "blake3", "chrono", - "criterion", "hkdf 0.13.0", "hmac 0.13.0", - "k256", "proptest", "rand 0.10.0", "serde", @@ -561,7 +561,7 @@ dependencies = [ [[package]] name = "atproto-tap" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "atproto-client", "atproto-identity", @@ -584,7 +584,7 @@ dependencies = [ [[package]] name = "atproto-xrpcs" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -609,7 +609,7 @@ dependencies = [ [[package]] name = "atproto-xrpcs-helloworld" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "async-trait", @@ -635,7 +635,7 @@ dependencies = [ [[package]] name = "atpxrpc" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" dependencies = [ "anyhow", "atproto-client", @@ -3105,6 +3105,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4923,6 +4932,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "smallvec" +version = "2.0.0-alpha.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef784004ca8777809dcdad6ac37629f0a97caee4c685fcea805278d81dd8b857" +dependencies = [ + "serde_core", +] + [[package]] name = "socket2" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index e5c3f47..9a54155 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,24 +35,24 @@ keywords = ["atprotocol"] categories = ["command-line-utilities", "web-programming"] [workspace.dependencies] -atproto-attestation = { version = "0.15.0-alpha.1", path = "crates/atproto-attestation" } -atproto-client = { version = "0.15.0-alpha.1", path = "crates/atproto-client" } -atproto-dasl = { version = "0.15.0-alpha.1", path = "crates/atproto-dasl" } -atproto-extras = { version = "0.15.0-alpha.1", path = "crates/atproto-extras" } -atproto-identity = { version = "0.15.0-alpha.1", path = "crates/atproto-identity" } -atproto-jetstream = { version = "0.15.0-alpha.1", path = "crates/atproto-jetstream" } -atproto-lexicon = { version = "0.15.0-alpha.1", path = "crates/atproto-lexicon" } -atproto-oauth = { version = "0.15.0-alpha.1", path = "crates/atproto-oauth" } -atproto-oauth-aip = { version = "0.15.0-alpha.1", path = "crates/atproto-oauth-aip" } -atproto-oauth-axum = { version = "0.15.0-alpha.1", path = "crates/atproto-oauth-axum" } -atproto-pds = { version = "0.15.0-alpha.1", path = "crates/atproto-pds" } -atproto-record = { version = "0.15.0-alpha.1", path = "crates/atproto-record" } -atproto-repo = { version = "0.15.0-alpha.1", path = "crates/atproto-repo" } -atproto-space = { version = "0.15.0-alpha.1", path = "crates/atproto-space" } -atproto-tap = { version = "0.15.0-alpha.1", path = "crates/atproto-tap" } -atproto-xrpcs = { version = "0.15.0-alpha.1", path = "crates/atproto-xrpcs" } +atproto-attestation = { version = "0.15.0-alpha.2", path = "crates/atproto-attestation" } +atproto-client = { version = "0.15.0-alpha.2", path = "crates/atproto-client" } +atproto-dasl = { version = "0.15.0-alpha.2", path = "crates/atproto-dasl" } +atproto-extras = { version = "0.15.0-alpha.2", path = "crates/atproto-extras" } +atproto-identity = { version = "0.15.0-alpha.2", path = "crates/atproto-identity" } +atproto-jetstream = { version = "0.15.0-alpha.2", path = "crates/atproto-jetstream" } +atproto-lexicon = { version = "0.15.0-alpha.2", path = "crates/atproto-lexicon" } +atproto-oauth = { version = "0.15.0-alpha.2", path = "crates/atproto-oauth" } +atproto-oauth-aip = { version = "0.15.0-alpha.2", path = "crates/atproto-oauth-aip" } +atproto-oauth-axum = { version = "0.15.0-alpha.2", path = "crates/atproto-oauth-axum" } +atproto-pds = { version = "0.15.0-alpha.2", path = "crates/atproto-pds" } +atproto-record = { version = "0.15.0-alpha.2", path = "crates/atproto-record" } +atproto-repo = { version = "0.15.0-alpha.2", path = "crates/atproto-repo" } +atproto-space = { version = "0.15.0-alpha.2", path = "crates/atproto-space" } +atproto-tap = { version = "0.15.0-alpha.2", path = "crates/atproto-tap" } +atproto-xrpcs = { version = "0.15.0-alpha.2", path = "crates/atproto-xrpcs" } -atpxrpc = { version = "0.15.0-alpha.1", path = "crates/atpxrpc" } +atpxrpc = { version = "0.15.0-alpha.2", path = "crates/atpxrpc" } bitflags = "2" anyhow = "1.0" diff --git a/Dockerfile b/Dockerfile index 8994309..fd7a949 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,7 +73,7 @@ LABEL org.opencontainers.image.title="atproto-identity-rs" LABEL org.opencontainers.image.description="AT Protocol identity management tools" LABEL org.opencontainers.image.authors="Nick Gerakines " LABEL org.opencontainers.image.source="https://tangled.org/ngerakines.me/atproto-crates" -LABEL org.opencontainers.image.version="0.15.0-alpha.1" +LABEL org.opencontainers.image.version="0.15.0-alpha.2" LABEL org.opencontainers.image.licenses="MIT" # Document available binaries diff --git a/README.md b/README.md index 55fd5aa..eb9327f 100644 --- a/README.md +++ b/README.md @@ -55,17 +55,17 @@ Add the crates to your `Cargo.toml`: ```toml [dependencies] -atproto-dasl = "0.15.0-alpha.1" -atproto-identity = "0.15.0-alpha.1" -atproto-attestation = "0.15.0-alpha.1" -atproto-record = "0.15.0-alpha.1" -atproto-repo = "0.15.0-alpha.1" -atproto-lexicon = "0.15.0-alpha.1" -atproto-oauth = "0.15.0-alpha.1" -atproto-oauth-aip = "0.15.0-alpha.1" -atproto-client = "0.15.0-alpha.1" -atproto-extras = "0.15.0-alpha.1" -atproto-tap = "0.15.0-alpha.1" +atproto-dasl = "0.15.0-alpha.2" +atproto-identity = "0.15.0-alpha.2" +atproto-attestation = "0.15.0-alpha.2" +atproto-record = "0.15.0-alpha.2" +atproto-repo = "0.15.0-alpha.2" +atproto-lexicon = "0.15.0-alpha.2" +atproto-oauth = "0.15.0-alpha.2" +atproto-oauth-aip = "0.15.0-alpha.2" +atproto-client = "0.15.0-alpha.2" +atproto-extras = "0.15.0-alpha.2" +atproto-tap = "0.15.0-alpha.2" # Add others as needed ``` diff --git a/crates/atpmcp/Cargo.toml b/crates/atpmcp/Cargo.toml index e2d048f..9b0a3c6 100644 --- a/crates/atpmcp/Cargo.toml +++ b/crates/atpmcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atpmcp" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "AT Protocol MCP server for DAG-CBOR CID generation" homepage = "https://tangled.org/ngerakines.me/atproto-crates" documentation = "https://docs.rs/atpmcp" diff --git a/crates/atproto-attestation/Cargo.toml b/crates/atproto-attestation/Cargo.toml index 0fe8733..381bd47 100644 --- a/crates/atproto-attestation/Cargo.toml +++ b/crates/atproto-attestation/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-attestation" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "AT Protocol attestation utilities for creating and verifying record signatures" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-client/Cargo.toml b/crates/atproto-client/Cargo.toml index c18622a..f82937f 100644 --- a/crates/atproto-client/Cargo.toml +++ b/crates/atproto-client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-client" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "HTTP client for AT Protocol services with OAuth and identity integration" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-dasl/Cargo.toml b/crates/atproto-dasl/Cargo.toml index f47a844..1962ff0 100644 --- a/crates/atproto-dasl/Cargo.toml +++ b/crates/atproto-dasl/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-dasl" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "DASL (Data-Addressed Structures & Links) implementation for AT Protocol" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-extras/Cargo.toml b/crates/atproto-extras/Cargo.toml index 3feeed0..b482050 100644 --- a/crates/atproto-extras/Cargo.toml +++ b/crates/atproto-extras/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-extras" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "AT Protocol extras - facet parsing and rich text utilities" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-identity/Cargo.toml b/crates/atproto-identity/Cargo.toml index 10c26b6..134e8dd 100644 --- a/crates/atproto-identity/Cargo.toml +++ b/crates/atproto-identity/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-identity" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "AT Protocol identity management - DID resolution, handle resolution, and cryptographic operations" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-identity/src/bin/atproto-identity-plc-audit.rs b/crates/atproto-identity/src/bin/atproto-identity-plc-audit.rs index 71d9a80..3e9ea34 100644 --- a/crates/atproto-identity/src/bin/atproto-identity-plc-audit.rs +++ b/crates/atproto-identity/src/bin/atproto-identity-plc-audit.rs @@ -487,7 +487,7 @@ async fn fetch_audit_log( let url = format!("{}/{}/log/audit", plc_url, did); let client = reqwest::Client::builder() - .user_agent("atproto-identity-plc-audit/0.15.0-alpha.1") + .user_agent("atproto-identity-plc-audit/0.15.0-alpha.2") .timeout(std::time::Duration::from_secs(30)) .build()?; diff --git a/crates/atproto-identity/src/bin/atproto-identity-plc-fork-viz.rs b/crates/atproto-identity/src/bin/atproto-identity-plc-fork-viz.rs index f7900f7..e131ed0 100644 --- a/crates/atproto-identity/src/bin/atproto-identity-plc-fork-viz.rs +++ b/crates/atproto-identity/src/bin/atproto-identity-plc-fork-viz.rs @@ -594,7 +594,7 @@ async fn fetch_audit_log( let url = format!("{}/{}/log/audit", plc_url, did); let client = reqwest::Client::builder() - .user_agent("atproto-identity-plc-fork-viz/0.15.0-alpha.1") + .user_agent("atproto-identity-plc-fork-viz/0.15.0-alpha.2") .timeout(std::time::Duration::from_secs(30)) .build()?; diff --git a/crates/atproto-identity/src/model.rs b/crates/atproto-identity/src/model.rs index d45682f..4b2e18d 100644 --- a/crates/atproto-identity/src/model.rs +++ b/crates/atproto-identity/src/model.rs @@ -251,6 +251,38 @@ impl Document { }) } + /// Returns the `publicKeyMultibase` of the Multikey verification method + /// whose id ends with `#{fragment}` (e.g. `atproto` or `atproto_space`). + /// + /// DID documents render verification-method ids as either the absolute + /// `did:plc:xxx#fragment` or the relative `#fragment`; both forms match. + pub fn verification_method_multibase(&self, fragment: &str) -> Option<&str> { + let suffix = format!("#{fragment}"); + self.verification_method + .iter() + .find_map(|method| match method { + VerificationMethod::Multikey { + id, + public_key_multibase, + .. + } if id.ends_with(&suffix) => Some(public_key_multibase.as_str()), + _ => None, + }) + } + + /// Returns the endpoint of the service entry whose id ends with + /// `#{fragment}` (e.g. `atproto_pds` or `atproto_space_host`). + /// + /// Service ids are rendered as either the absolute `did:plc:xxx#fragment` + /// or the relative `#fragment`; both forms match. + pub fn service_endpoint(&self, fragment: &str) -> Option<&str> { + let suffix = format!("#{fragment}"); + self.service + .iter() + .find(|svc| svc.id.ends_with(&suffix)) + .map(|svc| svc.service_endpoint.as_str()) + } + /// Extracts multibase public keys from verification methods. /// Returns public keys from Multikey verification methods only. pub fn did_keys(&self) -> Vec<&str> { @@ -361,6 +393,47 @@ mod tests { assert_eq!(doc.service[0].r#type, "CustomService"); } + #[test] + fn test_verification_method_and_service_lookup_by_fragment() { + // A DID document exposing both the public-data entries and the + // dedicated 0016 space entries (lines 87-92). + let document = serde_json::from_str::( + r##"{ + "id":"did:plc:authority", + "verificationMethod":[ + {"id":"did:plc:authority#atproto","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zATPROTO"}, + {"id":"#atproto_space","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zSPACE"} + ], + "service":[ + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}, + {"id":"did:plc:authority#atproto_space_host","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://host.example.com"} + ] + }"##, + ) + .expect("document parses"); + + // Absolute-form id and relative-form id both resolve by fragment. + assert_eq!( + document.verification_method_multibase("atproto"), + Some("zATPROTO") + ); + assert_eq!( + document.verification_method_multibase("atproto_space"), + Some("zSPACE") + ); + assert_eq!(document.verification_method_multibase("missing"), None); + + assert_eq!( + document.service_endpoint("atproto_pds"), + Some("https://pds.example.com") + ); + assert_eq!( + document.service_endpoint("atproto_space_host"), + Some("https://host.example.com") + ); + assert_eq!(document.service_endpoint("missing"), None); + } + #[test] fn test_document_builder_missing_id() { let result = Document::builder() diff --git a/crates/atproto-jetstream/Cargo.toml b/crates/atproto-jetstream/Cargo.toml index 7c7d6b7..adcfd21 100644 --- a/crates/atproto-jetstream/Cargo.toml +++ b/crates/atproto-jetstream/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-jetstream" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "AT Protocol Jetstream event consumer library with WebSocket streaming and compression support" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-lexicon/Cargo.toml b/crates/atproto-lexicon/Cargo.toml index d1a2ce2..2373fb7 100644 --- a/crates/atproto-lexicon/Cargo.toml +++ b/crates/atproto-lexicon/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-lexicon" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "AT Protocol lexicon resolution and validation" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-lexicon/README.md b/crates/atproto-lexicon/README.md index 5b2f8df..735fd27 100644 --- a/crates/atproto-lexicon/README.md +++ b/crates/atproto-lexicon/README.md @@ -27,7 +27,7 @@ Add this to your `Cargo.toml`: ```toml [dependencies] -atproto-lexicon = "0.15.0-alpha.1" +atproto-lexicon = "0.15.0-alpha.2" ``` ## Usage diff --git a/crates/atproto-lexicon/src/validation/data_errors.rs b/crates/atproto-lexicon/src/validation/data_errors.rs index 650bcc7..2711fe3 100644 --- a/crates/atproto-lexicon/src/validation/data_errors.rs +++ b/crates/atproto-lexicon/src/validation/data_errors.rs @@ -521,4 +521,37 @@ pub enum DataValidationError { /// The expected namespace. namespace: String, }, + + /// A space definition has a `name` whose length is outside the 1..=64 range. + #[error( + "error-atproto-lexicon-data-validation-56 Space name length out of range: expected 1..=64, got {length}" + )] + SpaceNameLengthInvalid { + /// The actual length of the name. + length: usize, + }, + + /// A space definition's collection NSID is invalid. + #[error( + "error-atproto-lexicon-data-validation-57 Space has invalid collection NSID '{nsid}': {reason}" + )] + SpaceInvalidCollectionNsid { + /// The invalid NSID string. + nsid: String, + /// Description of why the NSID is invalid. + reason: String, + }, + + /// A space permission is missing the required `spaceType` field. + #[error( + "error-atproto-lexicon-data-validation-58 Space permission missing required 'spaceType' field" + )] + SpacePermissionMissingSpaceType, + + /// A space permission uses the `*` wildcard for `spaceType`, which is not + /// allowed inside a permission set. + #[error( + "error-atproto-lexicon-data-validation-59 Space permission 'spaceType' must not be the '*' wildcard" + )] + SpacePermissionWildcardSpaceType, } diff --git a/crates/atproto-lexicon/src/validation/schema/mod.rs b/crates/atproto-lexicon/src/validation/schema/mod.rs index 8922142..117ebb2 100644 --- a/crates/atproto-lexicon/src/validation/schema/mod.rs +++ b/crates/atproto-lexicon/src/validation/schema/mod.rs @@ -21,6 +21,8 @@ pub enum SchemaDef { /// Permission set #[serde(rename = "permission-set")] PermissionSet(PermissionSetSchema), + /// Space (permissioned-data space type definition) + Space(SpaceSchema), /// Boolean type Boolean(BooleanSchema), /// Integer type @@ -59,6 +61,7 @@ impl SchemaDef { SchemaDef::Procedure(_) => "procedure", SchemaDef::Subscription(_) => "subscription", SchemaDef::PermissionSet(_) => "permission-set", + SchemaDef::Space(_) => "space", SchemaDef::Boolean(_) => "boolean", SchemaDef::Integer(_) => "integer", SchemaDef::String(_) => "string", @@ -75,7 +78,8 @@ impl SchemaDef { } } - /// Check if this is a primary type (record, query, procedure, subscription) + /// Check if this is a primary type (record, query, procedure, + /// subscription, permission-set, space) pub fn is_primary(&self) -> bool { matches!( self, @@ -84,6 +88,7 @@ impl SchemaDef { | SchemaDef::Procedure(_) | SchemaDef::Subscription(_) | SchemaDef::PermissionSet(_) + | SchemaDef::Space(_) ) } @@ -156,6 +161,7 @@ impl SchemaDef { } // These types don't contain refs SchemaDef::PermissionSet(_) + | SchemaDef::Space(_) | SchemaDef::Boolean(_) | SchemaDef::Integer(_) | SchemaDef::String(_) @@ -315,14 +321,72 @@ pub struct Permission { /// Whether to inherit audience for rpc resources #[serde(rename = "inheritAud", skip_serializing_if = "Option::is_none")] pub inherit_aud: Option, + + /// Space type NSID for `space` resources (the `spaceType` field). + /// + /// Identifies the concrete space type a permission applies to. Inside a + /// permission set this must be a concrete NSID and not the `*` wildcard. + #[serde(rename = "spaceType", skip_serializing_if = "Option::is_none")] + pub space_type: Option, + + /// Owner DID for `space` resources. + /// + /// Scopes the permission to spaces owned by a specific DID. May be the + /// `*` wildcard to match any owner. + #[serde(skip_serializing_if = "Option::is_none")] + pub did: Option, + + /// Record key for `space` resources. + /// + /// Scopes the permission to a specific space instance. May be the `*` + /// wildcard to match any space key. + #[serde(skip_serializing_if = "Option::is_none")] + pub skey: Option, } /// Valid resource types for permissions -pub const PERMISSION_RESOURCES: &[&str] = &["repo", "rpc", "blob", "identity", "account"]; +pub const PERMISSION_RESOURCES: &[&str] = &["repo", "rpc", "blob", "identity", "account", "space"]; /// Valid actions for repo permissions pub const REPO_ACTIONS: &[&str] = &["create", "update", "delete"]; +/// Space schema - declares a permissioned-data space type. +/// +/// A space definition must be the `main` definition of its lexicon. The +/// `name` is shown on OAuth consent screens when an application requests +/// access to a space of this type, and `collections` lists the recommended +/// record collections for clients. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct SpaceSchema { + /// Description of the space type. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Recommended space key (`skey`) type for spaces of this type. + /// + /// Required. Mirrors the [record key + /// types](https://atproto.com/specs/record-key#record-key-type-tid) — e.g. + /// `"tid"`, `"literal:self"`, or `"any"`. A declaration missing `key` fails + /// deserialization (spec line 125). + pub key: String, + + /// Human-readable name for the space type (length 1..=64). + pub name: String, + + /// Localization map for name (language code -> translated name). + #[serde( + rename = "name:lang", + default, + skip_serializing_if = "IndexMap::is_empty" + )] + pub name_lang: IndexMap, + + /// Recommended record collection NSIDs for clients of this space type. + /// + /// Required (the field must be present), but may be an empty array. + pub collections: Vec, +} + /// Input schema for procedures #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct InputSchema { @@ -749,4 +813,76 @@ mod tests { let schema: SchemaDef = serde_json::from_str(json).unwrap(); assert!(matches!(schema, SchemaDef::CidLink(_))); } + + #[test] + fn test_space_schema_type_name_and_primary() { + let space = SchemaDef::Space(SpaceSchema { + name: "Example".to_string(), + ..Default::default() + }); + assert_eq!(space.type_name(), "space"); + assert!(space.is_primary()); + } + + #[test] + fn test_deserialize_space_schema() { + let json = r#"{ + "type": "space", + "key": "tid", + "name": "AtmoBoards Forum", + "description": "A discussion forum", + "name:lang": {"es": "Foro AtmoBoards"}, + "collections": ["com.atmoboards.thread", "com.atmoboards.reply"] + }"#; + let schema: SchemaDef = serde_json::from_str(json).unwrap(); + if let SchemaDef::Space(s) = schema { + assert_eq!(s.key, "tid"); + assert_eq!(s.name, "AtmoBoards Forum"); + assert_eq!(s.description, Some("A discussion forum".to_string())); + assert_eq!(s.name_lang.get("es"), Some(&"Foro AtmoBoards".to_string())); + assert_eq!(s.collections.len(), 2); + } else { + panic!("Expected Space schema"); + } + } + + #[test] + fn test_space_schema_round_trip() { + let json = r#"{"type":"space","key":"tid","name":"Example Space","collections":["com.example.thing"]}"#; + let schema: SchemaDef = serde_json::from_str(json).unwrap(); + let serialized = serde_json::to_string(&schema).unwrap(); + let reparsed: SchemaDef = serde_json::from_str(&serialized).unwrap(); + assert_eq!(schema, reparsed); + } + + #[test] + fn test_space_schema_missing_name_fails() { + let json = r#"{"type": "space", "key": "tid", "collections": []}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn test_space_schema_missing_collections_fails() { + let json = r#"{"type": "space", "key": "tid", "name": "Example Space"}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn test_space_schema_missing_key_fails() { + let json = r#"{"type": "space", "name": "Example Space", "collections": []}"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn test_space_schema_key_round_trips() { + let json = r#"{"type":"space","key":"literal:self","name":"Profile","collections":[]}"#; + let schema: SchemaDef = serde_json::from_str(json).unwrap(); + if let SchemaDef::Space(s) = &schema { + assert_eq!(s.key, "literal:self"); + } else { + panic!("Expected Space schema"); + } + let serialized = serde_json::to_string(&schema).unwrap(); + assert!(serialized.contains("\"key\":\"literal:self\"")); + } } diff --git a/crates/atproto-lexicon/src/validation/schema_file.rs b/crates/atproto-lexicon/src/validation/schema_file.rs index 149a609..d278ef6 100644 --- a/crates/atproto-lexicon/src/validation/schema_file.rs +++ b/crates/atproto-lexicon/src/validation/schema_file.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::validation::data_errors::DataValidationError; use crate::validation::schema::{ - PERMISSION_RESOURCES, Permission, PermissionSetSchema, REPO_ACTIONS, SchemaDef, + PERMISSION_RESOURCES, Permission, PermissionSetSchema, REPO_ACTIONS, SchemaDef, SpaceSchema, }; use crate::validation::syntax::validate_nsid; @@ -86,8 +86,10 @@ impl SchemaFile { validate_def_name(name)?; } for def in self.defs.values() { - if let SchemaDef::PermissionSet(ps) = def { - validate_permission_set(ps, &self.id)?; + match def { + SchemaDef::PermissionSet(ps) => validate_permission_set(ps, &self.id)?, + SchemaDef::Space(space) => validate_space(space)?, + _ => {} } } Ok(()) @@ -204,11 +206,65 @@ fn validate_permission( "rpc" => { validate_rpc_permission(permission, namespace)?; } + "space" => { + validate_space_permission(permission, namespace)?; + } _ => {} } Ok(()) } +/// Validate a `space` permission entry. +/// +/// The `collection` list may be wildcard (`*`) or list collections under a +/// different namespace authority than the space and the permission set, so +/// collection NSIDs are not constrained here (spec line 465, final sentence). +/// +/// `spaceType` must be present, must not be the `*` wildcard (a permission set +/// must target a concrete space type), and must obey the permission set's +/// [Namespace Authority](https://atproto.com/specs/permission#namespace-authority) +/// requirement — it must fall under the set's `namespace` (spec line 465), the +/// same constraint enforced for repo `collection` and rpc `lxm`. +fn validate_space_permission( + permission: &Permission, + namespace: &str, +) -> Result<(), DataValidationError> { + let space_type = permission + .space_type + .as_ref() + .ok_or(DataValidationError::SpacePermissionMissingSpaceType)?; + if space_type == "*" { + return Err(DataValidationError::SpacePermissionWildcardSpaceType); + } + if !nsid_in_namespace(space_type, namespace) { + return Err(DataValidationError::PermissionNsidOutsideNamespace { + nsid: space_type.clone(), + namespace: namespace.to_string(), + }); + } + Ok(()) +} + +/// Validate a `space` definition. +/// +/// Enforces that `name` has length 1..=64 and that each `collections` entry +/// is a valid NSID. The `collections` array itself may be empty. +fn validate_space(space: &SpaceSchema) -> Result<(), DataValidationError> { + let len = space.name.len(); + if !(1..=64).contains(&len) { + return Err(DataValidationError::SpaceNameLengthInvalid { length: len }); + } + for nsid in &space.collections { + if let Err(e) = validate_nsid(nsid) { + return Err(DataValidationError::SpaceInvalidCollectionNsid { + nsid: nsid.clone(), + reason: e.to_string(), + }); + } + } + Ok(()) +} + fn validate_repo_permission( permission: &Permission, namespace: &str, @@ -681,4 +737,189 @@ mod tests { fn test_extract_namespace_no_dots() { assert_eq!(extract_namespace("nodots"), "nodots"); } + + #[test] + fn test_parse_space_main() { + 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"]}}}"#; + let schema = SchemaFile::parse(json).unwrap(); + assert_eq!(schema.id, "com.atmoboards.forum"); + if let Some(SchemaDef::Space(space)) = schema.main() { + assert_eq!(space.key, "tid"); + assert_eq!(space.name, "AtmoBoards Forum"); + assert_eq!(space.collections.len(), 2); + assert_eq!( + space.name_lang.get("es"), + Some(&"Foro AtmoBoards".to_string()) + ); + } else { + panic!("Expected Space schema"); + } + } + + #[test] + fn test_space_main_round_trip() { + let json = r#"{"lexicon":1,"id":"com.atmoboards.forum","defs":{"main":{"type":"space","key":"tid","name":"AtmoBoards Forum","collections":["com.atmoboards.thread"]}}}"#; + let schema = SchemaFile::parse(json).unwrap(); + let serialized = serde_json::to_string(&schema).unwrap(); + let reparsed = SchemaFile::parse(&serialized).unwrap(); + assert_eq!(schema, reparsed); + } + + #[test] + fn test_space_empty_collections_allowed() { + let json = r#"{"lexicon": 1, "id": "com.example.empty", "defs": {"main": {"type": "space", "key": "tid", "name": "Empty Space", "collections": []}}}"#; + let schema = SchemaFile::parse(json).unwrap(); + if let Some(SchemaDef::Space(space)) = schema.main() { + assert!(space.collections.is_empty()); + } else { + panic!("Expected Space schema"); + } + } + + #[test] + fn test_space_must_be_main() { + let json = r#"{"lexicon": 1, "id": "com.example.space", "defs": {"demo": {"type": "space", "key": "tid", "name": "Example Space", "collections": []}}}"#; + let result = SchemaFile::parse(json); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("must be named 'main'") + ); + } + + #[test] + fn test_space_name_empty_rejected() { + let json = r#"{"lexicon": 1, "id": "com.example.space", "defs": {"main": {"type": "space", "key": "tid", "name": "", "collections": []}}}"#; + let result = SchemaFile::parse(json); + assert!(matches!( + result, + Err(DataValidationError::SpaceNameLengthInvalid { length: 0 }) + )); + } + + #[test] + fn test_space_name_too_long_rejected() { + let name = "a".repeat(65); + let json = format!( + r#"{{"lexicon": 1, "id": "com.example.space", "defs": {{"main": {{"type": "space", "key": "tid", "name": "{}", "collections": []}}}}}}"#, + name + ); + let result = SchemaFile::parse(&json); + assert!(matches!( + result, + Err(DataValidationError::SpaceNameLengthInvalid { length: 65 }) + )); + } + + #[test] + fn test_space_name_max_length_allowed() { + let name = "a".repeat(64); + let json = format!( + r#"{{"lexicon": 1, "id": "com.example.space", "defs": {{"main": {{"type": "space", "key": "tid", "name": "{}", "collections": []}}}}}}"#, + name + ); + assert!(SchemaFile::parse(&json).is_ok()); + } + + #[test] + fn test_space_invalid_collection_nsid() { + let json = r#"{"lexicon": 1, "id": "com.example.space", "defs": {"main": {"type": "space", "key": "tid", "name": "Example Space", "collections": ["not-a-valid-nsid"]}}}"#; + let result = SchemaFile::parse(json); + assert!(matches!( + result, + Err(DataValidationError::SpaceInvalidCollectionNsid { .. }) + )); + } + + #[test] + fn test_space_missing_key_rejected() { + // `key` is a required field (spec line 125); a declaration omitting it + // must fail to parse. + let json = r#"{"lexicon": 1, "id": "com.example.space", "defs": {"main": {"type": "space", "name": "Example Space", "collections": []}}}"#; + assert!(SchemaFile::parse(json).is_err()); + } + + #[test] + fn test_space_key_round_trips_through_file() { + let json = r#"{"lexicon":1,"id":"com.example.space","defs":{"main":{"type":"space","key":"any","name":"Example Space","collections":[]}}}"#; + let schema = SchemaFile::parse(json).unwrap(); + if let Some(SchemaDef::Space(space)) = schema.main() { + assert_eq!(space.key, "any"); + } else { + panic!("Expected Space schema"); + } + } + + #[test] + fn test_permission_set_space_resource() { + 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"]}]}}}"#; + let schema = SchemaFile::parse(json).unwrap(); + if let Some(SchemaDef::PermissionSet(ps)) = schema.main() { + assert_eq!(ps.permissions.len(), 1); + assert_eq!(ps.permissions[0].resource, "space"); + assert_eq!( + ps.permissions[0].space_type, + Some("com.example.lexicon.group".to_string()) + ); + assert_eq!(ps.permissions[0].did, Some("*".to_string())); + assert_eq!(ps.permissions[0].skey, Some("*".to_string())); + } else { + panic!("Expected PermissionSet schema"); + } + } + + #[test] + fn test_permission_set_space_resource_round_trip() { + 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"]}]}}}"#; + let schema = SchemaFile::parse(json).unwrap(); + let serialized = serde_json::to_string(&schema).unwrap(); + let reparsed = SchemaFile::parse(&serialized).unwrap(); + assert_eq!(schema, reparsed); + } + + #[test] + fn test_space_permission_missing_space_type() { + 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"]}]}}}"#; + let result = SchemaFile::parse(json); + assert!(matches!( + result, + Err(DataValidationError::SpacePermissionMissingSpaceType) + )); + } + + #[test] + fn test_space_permission_wildcard_space_type_rejected() { + 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"]}]}}}"#; + let result = SchemaFile::parse(json); + assert!(matches!( + result, + Err(DataValidationError::SpacePermissionWildcardSpaceType) + )); + } + + #[test] + fn test_space_permission_space_type_outside_namespace_rejected() { + // The permission set id is `com.example.lexicon.perms` (namespace + // `com.example.lexicon`); a `spaceType` under a different namespace + // authority must be rejected (spec line 465, Namespace Authority). + 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"]}]}}}"#; + let result = SchemaFile::parse(json); + assert!( + matches!( + result, + Err(DataValidationError::PermissionNsidOutsideNamespace { .. }) + ), + "got: {result:?}" + ); + } + + #[test] + fn test_space_permission_cross_namespace_collection_allowed() { + // The collection list MAY reference NSIDs under a different namespace + // authority than the space and the permission set (spec line 465). + 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"]}]}}}"#; + assert!(SchemaFile::parse(json).is_ok()); + } } diff --git a/crates/atproto-lexicon/src/validation/validate.rs b/crates/atproto-lexicon/src/validation/validate.rs index 701be9b..fa5956b 100644 --- a/crates/atproto-lexicon/src/validation/validate.rs +++ b/crates/atproto-lexicon/src/validation/validate.rs @@ -624,7 +624,8 @@ fn validate_value( | SchemaDef::Query(_) | SchemaDef::Procedure(_) | SchemaDef::Subscription(_) - | SchemaDef::PermissionSet(_) => Err(DataValidationError::SchemaStructureInvalid { + | SchemaDef::PermissionSet(_) + | SchemaDef::Space(_) => Err(DataValidationError::SchemaStructureInvalid { message: format!( "primary type '{}' cannot be used as a nested schema", schema.type_name() diff --git a/crates/atproto-oauth-aip/Cargo.toml b/crates/atproto-oauth-aip/Cargo.toml index 13a7dfb..ac1f7d3 100644 --- a/crates/atproto-oauth-aip/Cargo.toml +++ b/crates/atproto-oauth-aip/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-oauth-aip" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "ATProtocol AIP OAuth tools" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-oauth-axum/Cargo.toml b/crates/atproto-oauth-axum/Cargo.toml index df3ca96..e271976 100644 --- a/crates/atproto-oauth-axum/Cargo.toml +++ b/crates/atproto-oauth-axum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-oauth-axum" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "Axum web framework integration for AT Protocol OAuth workflows" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-oauth/Cargo.toml b/crates/atproto-oauth/Cargo.toml index 926d24b..cc59ccc 100644 --- a/crates/atproto-oauth/Cargo.toml +++ b/crates/atproto-oauth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-oauth" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "OAuth workflow implementation for AT Protocol - PKCE, DPoP, and secure authentication flows" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" diff --git a/crates/atproto-oauth/src/errors.rs b/crates/atproto-oauth/src/errors.rs index 4ebcb4c..494d0b1 100644 --- a/crates/atproto-oauth/src/errors.rs +++ b/crates/atproto-oauth/src/errors.rs @@ -458,6 +458,27 @@ pub enum DpopError { ResponseBodyObjectParsingFailed, } +/// Error returned when a required OAuth permission scope is missing. +/// +/// Mirrors the reference `ScopeMissingError` from `@atproto/oauth-scopes`. The +/// embedded scope string is the minimal scope that would have satisfied the +/// attempted operation, as produced by the relevant `scope_needed_for` helper. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("error-atproto-oauth-scope-1 Missing required scope: {scope}")] +pub struct ScopeMissingError { + /// The minimal scope string that would satisfy the attempted operation. + pub scope: String, +} + +impl ScopeMissingError { + /// Create a new [`ScopeMissingError`] for the given required scope string. + pub fn new(scope: impl Into) -> Self { + ScopeMissingError { + scope: scope.into(), + } + } +} + /// Error types that can occur when working with OAuth request storage operations #[derive(Debug, Error)] pub enum OAuthStorageError { diff --git a/crates/atproto-oauth/src/scopes.rs b/crates/atproto-oauth/src/scopes.rs index 46df6ae..a3976c9 100644 --- a/crates/atproto-oauth/src/scopes.rs +++ b/crates/atproto-oauth/src/scopes.rs @@ -21,6 +21,14 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::str::FromStr; +/// `space:` permission scope (AT Protocol permissioned-data spaces). +pub mod space_permission; + +pub use space_permission::{ + SpaceAction, SpaceCollection, SpaceCollections, SpaceDid, SpaceManageTarget, SpaceManageVerb, + SpacePermission, SpaceSkey, SpaceTarget, SpaceType, +}; + /// Represents an AT Protocol OAuth scope #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum Scope { @@ -34,6 +42,8 @@ pub enum Scope { Repo(RepoScope), /// RPC scope for method access Rpc(RpcScope), + /// Space scope for permissioned-data space operations + Space(SpacePermission), /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used Atproto, /// Transition scope for migration operations @@ -319,6 +329,7 @@ impl Scope { "blob", "repo", "rpc", + "space", "atproto", "transition", "include", @@ -359,6 +370,7 @@ impl Scope { "blob" => Self::parse_blob(suffix), "repo" => Self::parse_repo(suffix), "rpc" => Self::parse_rpc(suffix), + "space" => Self::parse_space(suffix), "atproto" => Self::parse_atproto(suffix), "transition" => Self::parse_transition(suffix), "include" => Self::parse_include(suffix), @@ -566,6 +578,10 @@ impl Scope { Ok(Scope::Rpc(RpcScope { lxm, aud })) } + fn parse_space(suffix: Option<&str>) -> Result { + Ok(Scope::Space(SpacePermission::parse_suffix(suffix)?)) + } + fn parse_atproto(suffix: Option<&str>) -> Result { if suffix.is_some() { return Err(ParseError::InvalidResource( @@ -765,6 +781,7 @@ impl Scope { } } } + Scope::Space(scope) => scope.to_scope_string(), Scope::Atproto => "atproto".to_string(), Scope::Transition(scope) => match scope { TransitionScope::Generic => "transition:generic".to_string(), @@ -873,6 +890,10 @@ impl Scope { lxm_match && aud_match } + // Space scopes only grant themselves (exact match). Subset-based + // reduction is intentionally not performed for spaces; this keeps + // `parse_multiple_reduced` sound (it never drops a distinct grant). + (Scope::Space(a), Scope::Space(b)) => a == b, _ => false, } } @@ -1015,6 +1036,122 @@ impl fmt::Display for ParseError { impl std::error::Error for ParseError {} +/// A set of granted OAuth scopes that can be queried for permission matches. +/// +/// Mirrors the reference `ScopesSet` / `ScopePermissions`: it stores the raw +/// granted scope strings and, on demand, parses the ones relevant to a given +/// resource to evaluate whether a request is allowed. +/// +/// Scope strings that fail to parse are ignored during matching (they simply +/// cannot grant anything), matching the reference where `fromString` returning +/// `null` means the scope does not contribute to a match. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ScopesSet { + scopes: Vec, +} + +impl ScopesSet { + /// Create an empty scope set. + pub fn new() -> Self { + ScopesSet::default() + } + + /// Build a scope set from a space-separated OAuth scope string. + pub fn from_scope_string(scope: &str) -> Self { + ScopesSet { + scopes: scope.split_whitespace().map(|s| s.to_string()).collect(), + } + } + + /// Build a scope set from an iterator of individual scope strings. + pub fn from_scopes(scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + ScopesSet { + scopes: scopes.into_iter().map(Into::into).collect(), + } + } + + /// Add a scope string to the set. + pub fn insert(&mut self, scope: impl Into) { + self.scopes.push(scope.into()); + } + + /// The raw granted scope strings. + pub fn scopes(&self) -> &[String] { + &self.scopes + } + + /// Returns `true` if any granted `space:` scope satisfies the given record + /// target. An omitted-`collection` grant confers no write targets (the + /// `spaceType=*` / no-declaration case); use + /// [`allows_space_with`](Self::allows_space_with) to resolve the collection + /// default against a space type declaration's collections. + pub fn allows_space(&self, target: &SpaceTarget) -> bool { + self.allows_space_with(target, &[]) + } + + /// Like [`allows_space`](Self::allows_space) but resolves the per-grant + /// `collection` default against `declared` (the space type declaration's + /// `collections`) per spec line 413. + pub fn allows_space_with(&self, target: &SpaceTarget, declared: &[String]) -> bool { + self.scopes.iter().any(|scope| { + matches!(Scope::parse(scope), Ok(Scope::Space(permission)) if permission.matches_with(target, declared)) + }) + } + + /// Returns `true` if any granted `space:` scope satisfies the given + /// space-management target (spec lines 415-419). + pub fn allows_space_manage(&self, target: &SpaceManageTarget) -> bool { + self.scopes.iter().any(|scope| { + matches!(Scope::parse(scope), Ok(Scope::Space(permission)) if permission.matches_manage(target)) + }) + } + + /// Asserts that some granted `space:` scope satisfies the given record + /// target, returning a [`ScopeMissingError`](crate::errors::ScopeMissingError) + /// carrying the minimal scope that would satisfy it otherwise. + pub fn assert_space( + &self, + target: &SpaceTarget, + ) -> Result<(), crate::errors::ScopeMissingError> { + self.assert_space_with(target, &[]) + } + + /// Like [`assert_space`](Self::assert_space) but resolves the collection + /// default against `declared` (spec line 413). + pub fn assert_space_with( + &self, + target: &SpaceTarget, + declared: &[String], + ) -> Result<(), crate::errors::ScopeMissingError> { + if self.allows_space_with(target, declared) { + Ok(()) + } else { + Err(crate::errors::ScopeMissingError::new( + SpacePermission::scope_needed_for(target), + )) + } + } + + /// Asserts that some granted `space:` scope satisfies the given + /// space-management target (spec lines 415-419). + pub fn assert_space_manage( + &self, + target: &SpaceManageTarget, + ) -> Result<(), crate::errors::ScopeMissingError> { + if self.allows_space_manage(target) { + Ok(()) + } else { + Err(crate::errors::ScopeMissingError::new( + SpacePermission::scope_needed_for_manage(target), + )) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -2217,4 +2354,156 @@ mod tests { let reparsed = Scope::parse(&serialized).unwrap(); assert_eq!(scope, reparsed); } + + #[test] + fn test_space_scope_parsing_dispatch() { + // The `space` prefix is recognized and dispatched (no longer an + // UnknownPrefix error). + let scope = Scope::parse("space:com.example.space").unwrap(); + assert!(matches!(scope, Scope::Space(_))); + + // A bare `space` without a type is an error, not UnknownPrefix. + assert!(matches!( + Scope::parse("space"), + Err(ParseError::MissingResource) + )); + } + + #[test] + fn test_space_scope_normalization() { + let tests = vec![ + ("space:com.example.space", "space:com.example.space"), + ("space:*", "space:*"), + // Explicit defaults stripped. + ( + "space:com.example.space?did=*&skey=*", + "space:com.example.space", + ), + ( + "space:com.example.space?action=read&action=create&action=update&action=delete", + "space:com.example.space", + ), + ( + "space:com.example.space?did=did:plc:abc&action=read", + "space:com.example.space?did=did:plc:abc&action=read", + ), + // `manage` is a separate parameter, preserved on normalization. + ( + "space:com.example.space?manage=update&manage=delete", + "space:com.example.space?manage=update&manage=delete", + ), + ]; + + for (input, expected) in tests { + let scope = Scope::parse(input).unwrap(); + assert_eq!(scope.to_string_normalized(), expected, "input: {input}"); + } + } + + #[test] + fn test_space_scope_grants_self_only() { + let a = Scope::parse("space:com.example.space?action=read").unwrap(); + let b = Scope::parse("space:com.example.space?action=read").unwrap(); + let c = Scope::parse("space:com.example.space?manage=update").unwrap(); + let account = Scope::parse("account:email").unwrap(); + + assert!(a.grants(&b)); + assert!(!a.grants(&c)); + assert!(!a.grants(&account)); + assert!(!account.grants(&a)); + } + + #[test] + fn test_space_scope_in_serialize_multiple() { + let scopes = vec![ + Scope::parse("space:com.example.space?action=read").unwrap(), + Scope::Atproto, + Scope::parse("account:email").unwrap(), + ]; + assert_eq!( + Scope::serialize_multiple(&scopes), + "account:email atproto space:com.example.space?action=read" + ); + } + + #[test] + fn test_scopes_set_allows_space() { + let set = ScopesSet::from_scope_string( + "atproto space:com.example.space?did=did:plc:abc&skey=s1&collection=com.example.note&action=read&action=create", + ); + + // read is allowed. + assert!(set.allows_space(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Read, + ))); + + // create on covered collection is allowed. + assert!(set.allows_space(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.note", + ))); + + // delete is not granted. + assert!(!set.allows_space(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Delete, + "com.example.note", + ))); + + // a different space key is not granted. + assert!(!set.allows_space(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "other", + SpaceAction::Read, + ))); + } + + #[test] + fn test_scopes_set_assert_space() { + let set = ScopesSet::from_scope_string("space:com.example.space?action=read"); + + // Satisfied: Ok. + assert!( + set.assert_space(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Read, + )) + .is_ok() + ); + + // Not satisfied: returns the minimal needed scope. + let target = SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.note", + ); + let err = set.assert_space(&target).unwrap_err(); + assert_eq!(err.scope, SpacePermission::scope_needed_for(&target)); + assert!(err.to_string().contains("error-atproto-oauth-scope-1")); + } + + #[test] + fn test_scopes_set_ignores_unparseable_scopes() { + // A non-space and a malformed scope are simply ignored for matching. + let set = ScopesSet::from_scopes(["account:email", "space:com.example.space?action=read"]); + assert!(set.allows_space(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Read, + ))); + } } diff --git a/crates/atproto-oauth/src/scopes/space_permission.rs b/crates/atproto-oauth/src/scopes/space_permission.rs new file mode 100644 index 0000000..0f584fa --- /dev/null +++ b/crates/atproto-oauth/src/scopes/space_permission.rs @@ -0,0 +1,1392 @@ +//! AT Protocol permissioned-data `space:` OAuth permission scope. +//! +//! This module implements the `space:` scope grammar, parser, formatter, and +//! request-time matcher per the published **0016 Permissioned Data** spec +//! (OAuth scopes, lines 369-465), choosing the spec prose over the reference +//! implementation wherever they diverge. +//! +//! # Grammar +//! +//! ```text +//! space:[?did=][&skey=][&collection=...][&action=...][&manage=...] +//! ``` +//! +//! - `spaceType`: positional, **required** — a space-type NSID or `*` (any +//! type). +//! - `did`: a DID or `*` (any authority). Default `*`. +//! - `skey`: a non-empty string up to 512 chars, or `*`. Default `*`. +//! - `collection`: multi-valued NSID or `*`. Default is the space type +//! declaration's `collections` (resolved by the consumer); empty when +//! `spaceType=*`. +//! - `action`: multi-valued. Action values are `read_self, read, create, +//! update, delete`. Default (when omitted) is `{read, create, update, +//! delete}` — note **not** `read_self` alone, since `read` is inclusive of +//! `read_self`. +//! - `manage`: multi-valued, a **separate** parameter governing operations on +//! the spaces themselves. Verbs are `create, update, delete`. Default is +//! none — an ordinary record-access grant confers no administrative +//! capability. +//! +//! `did`, `spaceType`, and `skey` select **which spaces** the grant covers. +//! `action` (and `collection`) govern operations on the **records** in those +//! spaces. `manage` governs operations on the **spaces themselves**. +//! +//! # Matching +//! +//! At request time a [`SpaceTarget`] is checked against a granted +//! [`SpacePermission`] (spec lines 401-419): +//! +//! - The tuple `(spaceType, did, skey)` must overlap: each grant component is +//! `*` or equals the target component. +//! - `read` covers every repo in the space and **ignores** `collection`. It +//! also grants `getDelegationToken`. +//! - `read_self` covers only the holder's **own** repo, is **constrained by** +//! `collection`, and does **not** grant `getDelegationToken`. A `read` grant +//! also satisfies a `read_self` request (`read` implies `read_self`). +//! - `create` / `update` / `delete` act on a specific record and are +//! constrained by `collection` (the action must be granted **and** the target +//! collection covered). +//! - A [`SpaceManageTarget`] is checked against the grant's `manage` set: a +//! management verb is permitted when the grant's `manage` set contains that +//! verb. `manage` ignores `collection`. + +use std::collections::BTreeSet; +use std::fmt; + +use super::ParseError; + +/// Maximum allowed length, in characters, of a `skey` parameter value. +const SKEY_MAX_LENGTH: usize = 512; + +/// A single action that may appear in a `space:` scope's `action` list. +/// +/// Per the 0016 spec param table (line 383) the action values are exactly +/// `[read_self, read, create, update, delete]`. `manage` is **not** an action; +/// it is a separate parameter (see [`SpaceManageVerb`]). +/// +/// The variant order is the canonical action order used for formatting: +/// `read_self`, `read`, `create`, `update`, `delete`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SpaceAction { + /// Read the holder's **own** repo only; collection-constrained; does not + /// grant `getDelegationToken`. A `read` grant also satisfies a `read_self` + /// request. + ReadSelf, + /// Read every repo in the space; ignores `collection`; grants + /// `getDelegationToken`. Implies [`SpaceAction::ReadSelf`]. + Read, + /// Create records in a covered collection. + Create, + /// Update records in a covered collection. + Update, + /// Delete records in a covered collection. + Delete, +} + +impl SpaceAction { + /// The full, canonically ordered action set: `[read_self, read, create, + /// update, delete]`. + pub const ALL: [SpaceAction; 5] = [ + SpaceAction::ReadSelf, + SpaceAction::Read, + SpaceAction::Create, + SpaceAction::Update, + SpaceAction::Delete, + ]; + + /// The default `action` set when the parameter is omitted: `{read, create, + /// update, delete}` (spec line 411). `read` is inclusive of `read_self`, so + /// `read_self` is not listed separately. + pub const DEFAULT: [SpaceAction; 4] = [ + SpaceAction::Read, + SpaceAction::Create, + SpaceAction::Update, + SpaceAction::Delete, + ]; + + /// The lowercase wire form of this action. + pub fn as_str(self) -> &'static str { + match self { + SpaceAction::ReadSelf => "read_self", + SpaceAction::Read => "read", + SpaceAction::Create => "create", + SpaceAction::Update => "update", + SpaceAction::Delete => "delete", + } + } + + /// Parse an action from its wire form, returning [`None`] for unknown + /// values. + pub fn from_wire(value: &str) -> Option { + match value { + "read_self" => Some(SpaceAction::ReadSelf), + "read" => Some(SpaceAction::Read), + "create" => Some(SpaceAction::Create), + "update" => Some(SpaceAction::Update), + "delete" => Some(SpaceAction::Delete), + _ => None, + } + } +} + +/// A single verb that may appear in a `space:` scope's `manage` list. +/// +/// `manage` is a separate parameter from `action` (spec line 384). Its verbs +/// `create`, `update`, `delete` apply to the **space itself** rather than to +/// the records in it, and map onto implementation-defined management +/// operations (spec lines 415-419). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SpaceManageVerb { + /// Create a space of the given `spaceType` under the given authority. + Create, + /// Update the space (e.g. `updateSpace`, `addMember`, `removeMember`). + Update, + /// Delete the space. + Delete, +} + +impl SpaceManageVerb { + /// The full, canonically ordered manage-verb set: `[create, update, + /// delete]`. + pub const ALL: [SpaceManageVerb; 3] = [ + SpaceManageVerb::Create, + SpaceManageVerb::Update, + SpaceManageVerb::Delete, + ]; + + /// The lowercase wire form of this verb. + pub fn as_str(self) -> &'static str { + match self { + SpaceManageVerb::Create => "create", + SpaceManageVerb::Update => "update", + SpaceManageVerb::Delete => "delete", + } + } + + /// Parse a manage verb from its wire form, returning [`None`] for unknown + /// values. + pub fn from_wire(value: &str) -> Option { + match value { + "create" => Some(SpaceManageVerb::Create), + "update" => Some(SpaceManageVerb::Update), + "delete" => Some(SpaceManageVerb::Delete), + _ => None, + } + } +} + +/// The `type` component of a `space:` scope: a space-type NSID or any (`*`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SpaceType { + /// Matches any space type (wildcard `*`). + All, + /// A specific space-type NSID. + Nsid(String), +} + +/// The `did` component of a `space:` scope: a specific DID or any (`*`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SpaceDid { + /// Matches any owner DID (wildcard `*`). + All, + /// A specific owner DID. + Did(String), +} + +/// The `skey` component of a `space:` scope: a specific space key or any (`*`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SpaceSkey { + /// Matches any space key (wildcard `*`). + All, + /// A specific space key (non-empty, up to 512 chars). + Key(String), +} + +/// A single `collection` value in a `space:` scope: an NSID or any (`*`). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum SpaceCollection { + /// Matches any collection (wildcard `*`). + All, + /// A specific collection NSID. + Nsid(String), +} + +/// The resolved or unresolved `collection` set of a `space:` scope. +/// +/// When `collection` is omitted, the spec (line 413) defaults it to the +/// collections declared by the space type's declaration — which the scope +/// parser cannot resolve on its own. [`SpaceCollections::Default`] preserves +/// that "use the declaration's collections" intent; the consumer resolves it +/// against the declared collections at enforcement time via +/// [`SpacePermission::collections_resolved_with`]. When `spaceType` is `*` +/// there is no declaration, so the default resolves to the empty set (no write +/// targets). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SpaceCollections { + /// `collection` was omitted; defaults to the space type declaration's + /// `collections` (empty when `spaceType=*`). + Default, + /// `collection` was supplied explicitly (normalized; a `*` collapses to a + /// single [`SpaceCollection::All`]). + Explicit(BTreeSet), +} + +/// A parsed `space:` permission scope (0016 spec, lines 373-419). +/// +/// - [`collection`](Self::collection) is [`SpaceCollections::Default`] when the +/// parameter is omitted (resolves to the declaration's collections at +/// enforcement time) or [`SpaceCollections::Explicit`] when supplied. An +/// explicit list containing `*` collapses to a single +/// [`SpaceCollection::All`]; otherwise it is a sorted, deduplicated set. +/// - [`action`](Self::action) is normalized to canonical action order and +/// defaults to [`SpaceAction::DEFAULT`] (`{read, create, update, delete}`) +/// when omitted. +/// - [`manage`](Self::manage) is empty by default; an ordinary record-access +/// grant confers no administrative capability. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SpacePermission { + /// The space type (NSID or `*`). + pub space_type: SpaceType, + /// The owner/authority DID (DID or `*`). + pub did: SpaceDid, + /// The space key (string or `*`). + pub skey: SpaceSkey, + /// The covered collections, or [`SpaceCollections::Default`] when omitted. + pub collection: SpaceCollections, + /// The granted record actions, always a subset of [`SpaceAction::ALL`]. + pub action: BTreeSet, + /// The granted space-management verbs. Empty by default (no admin grant). + pub manage: BTreeSet, +} + +/// A request-time **record** permission check against a granted +/// [`SpacePermission`]. +/// +/// The `collection` field is meaningful for the write actions +/// ([`SpaceAction::Create`], [`SpaceAction::Update`], [`SpaceAction::Delete`]) +/// and for [`SpaceAction::ReadSelf`] reads; for whole-space [`SpaceAction::Read`] +/// it is ignored and may be left as [`None`]. +/// +/// For a read of the holder's own repo, request [`SpaceAction::ReadSelf`] +/// (collection-constrained, also satisfied by a `read` grant). For a read of +/// any repo in the space, request [`SpaceAction::Read`]. Management operations +/// use [`SpaceManageTarget`] instead. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SpaceTarget { + /// The space type being accessed (a concrete NSID; `*` is also accepted but + /// only overlaps a grant whose type is also `*`). + pub space_type: String, + /// The authority DID being accessed. + pub did: String, + /// The space key being accessed. + pub skey: String, + /// The action being attempted. + pub action: SpaceAction, + /// The collection being acted on. Required for writes and `read_self`; + /// ignored for whole-space `read`. + pub collection: Option, +} + +impl SpaceTarget { + /// Construct a collection-independent whole-space `read` target. + pub fn new( + space_type: impl Into, + did: impl Into, + skey: impl Into, + action: SpaceAction, + ) -> Self { + SpaceTarget { + space_type: space_type.into(), + did: did.into(), + skey: skey.into(), + action, + collection: None, + } + } + + /// Construct a collection-bound target (`create`, `update`, `delete`, or a + /// `read_self` read). + pub fn with_collection( + space_type: impl Into, + did: impl Into, + skey: impl Into, + action: SpaceAction, + collection: impl Into, + ) -> Self { + SpaceTarget { + space_type: space_type.into(), + did: did.into(), + skey: skey.into(), + action, + collection: Some(collection.into()), + } + } +} + +/// A request-time **space-management** permission check against a granted +/// [`SpacePermission`]. +/// +/// Management operations are governed by the grant's `manage` set, take a verb +/// (`create`/`update`/`delete`) applied to the space itself, and ignore +/// `collection` (spec lines 415-419). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SpaceManageTarget { + /// The space type being managed (a concrete NSID, or `*`). + pub space_type: String, + /// The authority DID being managed. + pub did: String, + /// The space key being managed. + pub skey: String, + /// The management verb being attempted. + pub verb: SpaceManageVerb, +} + +impl SpaceManageTarget { + /// Construct a space-management target. + pub fn new( + space_type: impl Into, + did: impl Into, + skey: impl Into, + verb: SpaceManageVerb, + ) -> Self { + SpaceManageTarget { + space_type: space_type.into(), + did: did.into(), + skey: skey.into(), + verb, + } + } +} + +impl SpacePermission { + /// Parse a `space:` scope from its suffix (everything after `space`). + /// + /// The `suffix` is the remainder of the scope string after the `space` + /// prefix has been stripped, beginning with either `:` (already removed by + /// the caller, so a positional type) or `?` (a query string), matching the + /// suffix handed to the other `Scope::parse_*` helpers. + /// + /// Returns a [`ParseError`] when the grammar or a parameter value is + /// invalid (0016 spec, lines 373-384). + pub(super) fn parse_suffix(suffix: Option<&str>) -> Result { + // Split the suffix into the positional `type` value and the query + // string. + let (positional, query) = match suffix { + // `space?...` — query only, no positional type. + Some(s) if s.starts_with('?') => (None, Some(&s[1..])), + // `space:` possibly followed by `?`. + Some(s) => { + if let Some(pos) = s.find('?') { + let positional = &s[..pos]; + (Some(positional), Some(&s[pos + 1..])) + } else { + (Some(s), None) + } + } + // Bare `space` — no type, which is invalid (type is required). + None => (None, None), + }; + + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(query) = query + && !query.is_empty() + { + for pair in query.split('&') { + if let Some(eq) = pair.find('=') { + params.push((pair[..eq].to_string(), pair[eq + 1..].to_string())); + } else { + // A key with no `=` is not part of the schema. + return Err(ParseError::InvalidResource(format!( + "space scope has malformed query parameter: {pair}" + ))); + } + } + } + + // Reject any query key that is not part of the schema. The five valid + // query parameters are `did`, `skey`, `collection`, `action`, and + // `manage` (spec lines 373-384). + for (key, _) in ¶ms { + match key.as_str() { + "did" | "skey" | "collection" | "action" | "manage" => {} + "type" => { + // `type` is positional-only; it cannot be supplied as a + // named parameter alongside the positional value. + return Err(ParseError::InvalidResource( + "space scope 'type' must be positional, not a query parameter".to_string(), + )); + } + other => { + return Err(ParseError::InvalidResource(format!( + "space scope has unknown query parameter: {other}" + ))); + } + } + } + + // Resolve `type` (required positional). + let space_type = match positional { + Some(value) if !value.is_empty() => Self::parse_type(value)?, + _ => return Err(ParseError::MissingResource), + }; + + // Resolve `did` (single, default `*`). + let did = match Self::single_param(¶ms, "did")? { + Some(value) => Self::parse_did(value)?, + None => SpaceDid::All, + }; + + // Resolve `skey` (single, default `*`). + let skey = match Self::single_param(¶ms, "skey")? { + Some(value) => Self::parse_skey(value)?, + None => SpaceSkey::All, + }; + + // Resolve `collection` (multi). When omitted, the default is the space + // type declaration's `collections`, which the parser cannot resolve; + // represent that intent as `SpaceCollections::Default`. + let mut explicit_collection: BTreeSet = BTreeSet::new(); + let mut saw_collection = false; + for (key, value) in ¶ms { + if key == "collection" { + saw_collection = true; + explicit_collection.insert(Self::parse_collection(value)?); + } + } + let collection = if saw_collection { + normalize_collection(&mut explicit_collection); + SpaceCollections::Explicit(explicit_collection) + } else { + SpaceCollections::Default + }; + + // Resolve `action` (multi, default = {read, create, update, delete}). + let mut action: BTreeSet = BTreeSet::new(); + for (key, value) in ¶ms { + if key == "action" { + let parsed = SpaceAction::from_wire(value) + .ok_or_else(|| ParseError::InvalidAction(value.clone()))?; + action.insert(parsed); + } + } + if action.is_empty() { + action.extend(SpaceAction::DEFAULT); + } + + // Resolve `manage` (multi, default = none). + let mut manage: BTreeSet = BTreeSet::new(); + for (key, value) in ¶ms { + if key == "manage" { + let parsed = SpaceManageVerb::from_wire(value) + .ok_or_else(|| ParseError::InvalidAction(value.clone()))?; + manage.insert(parsed); + } + } + + Ok(SpacePermission { + space_type, + did, + skey, + collection, + action, + manage, + }) + } + + /// Extract a single-valued parameter, returning an error when it appears + /// more than once (`did` and `skey` are single-valued per the spec param + /// table, line 384). + fn single_param<'a>( + params: &'a [(String, String)], + key: &str, + ) -> Result, ParseError> { + let mut found: Option<&'a str> = None; + for (k, v) in params { + if k == key { + if found.is_some() { + return Err(ParseError::InvalidResource(format!( + "space scope '{key}' may only appear once" + ))); + } + found = Some(v.as_str()); + } + } + Ok(found) + } + + fn parse_type(value: &str) -> Result { + if value == "*" { + Ok(SpaceType::All) + } else { + Ok(SpaceType::Nsid(value.to_string())) + } + } + + fn parse_did(value: &str) -> Result { + if value == "*" { + Ok(SpaceDid::All) + } else { + Ok(SpaceDid::Did(value.to_string())) + } + } + + fn parse_skey(value: &str) -> Result { + if value == "*" { + Ok(SpaceSkey::All) + } else if value.is_empty() || value.chars().count() > SKEY_MAX_LENGTH { + Err(ParseError::InvalidResource(format!( + "space scope 'skey' must be non-empty and at most {SKEY_MAX_LENGTH} characters" + ))) + } else { + Ok(SpaceSkey::Key(value.to_string())) + } + } + + fn parse_collection(value: &str) -> Result { + if value == "*" { + Ok(SpaceCollection::All) + } else { + Ok(SpaceCollection::Nsid(value.to_string())) + } + } + + /// Format this permission back into its canonical scope string. + /// + /// Parameters equal to their default are omitted: + /// + /// - `did=*`, `skey=*` are omitted. + /// - `collection` is omitted when [`SpaceCollections::Default`]. + /// - `action` is omitted when it is exactly the default action set + /// (`{read, create, update, delete}`). + /// - `manage` is omitted when empty. + /// + /// The result round-trips through [`SpacePermission::parse_suffix`]. + pub fn to_scope_string(&self) -> String { + let positional = match &self.space_type { + SpaceType::All => "*".to_string(), + SpaceType::Nsid(nsid) => nsid.clone(), + }; + + let mut params: Vec = Vec::new(); + + // did (default `*`). + if let SpaceDid::Did(did) = &self.did { + params.push(format!("did={did}")); + } + + // skey (default `*`). + if let SpaceSkey::Key(skey) = &self.skey { + params.push(format!("skey={skey}")); + } + + // collection (omitted when Default). BTreeSet iteration is sorted. + if let SpaceCollections::Explicit(set) = &self.collection { + for c in set { + match c { + SpaceCollection::All => params.push("collection=*".to_string()), + SpaceCollection::Nsid(nsid) => params.push(format!("collection={nsid}")), + } + } + } + + // action (default = {read, create, update, delete}). BTreeSet iteration + // yields canonical order (read_self, read, create, update, delete). + if !self.is_default_action_set() { + for a in &self.action { + params.push(format!("action={}", a.as_str())); + } + } + + // manage (default = none). + for m in &self.manage { + params.push(format!("manage={}", m.as_str())); + } + + if params.is_empty() { + format!("space:{positional}") + } else { + format!("space:{positional}?{}", params.join("&")) + } + } + + /// Returns `true` when the action set is exactly the default action set + /// (`{read, create, update, delete}`), in which case it is omitted from the + /// formatted scope string. + fn is_default_action_set(&self) -> bool { + self.action.len() == SpaceAction::DEFAULT.len() + && SpaceAction::DEFAULT.iter().all(|a| self.action.contains(a)) + } + + /// Returns `true` when `space_type`/`did`/`skey` of this grant overlap the + /// given target components (each grant component is `*` or equal). + fn tuple_overlaps(&self, space_type: &str, did: &str, skey: &str) -> bool { + if let SpaceType::Nsid(t) = &self.space_type + && t != space_type + { + return false; + } + if let SpaceDid::Did(d) = &self.did + && d != did + { + return false; + } + if let SpaceSkey::Key(s) = &self.skey + && s != skey + { + return false; + } + true + } + + /// Resolve this grant's effective collection set, using `declared` as the + /// default when `collection` was omitted (spec line 413). `declared` should + /// be the space type declaration's `collections`; pass an empty slice when + /// `spaceType=*` (no declaration) — the default then confers no write + /// targets. + pub fn collections_resolved_with(&self, declared: &[String]) -> BTreeSet { + match &self.collection { + SpaceCollections::Explicit(set) => set.clone(), + SpaceCollections::Default => declared + .iter() + .map(|nsid| SpaceCollection::Nsid(nsid.clone())) + .collect(), + } + } + + /// Check whether this granted permission satisfies the given request-time + /// record [`SpaceTarget`], resolving the collection default against the + /// space type's `declared` collections (spec lines 407-413). + /// + /// 1. Tuple gate: `spaceType`, `did`, `skey` must each be `*` or equal the + /// target. + /// 2. `read`: granted if the action set includes `read`; ignores collection. + /// 3. `read_self`: granted if the action set includes `read_self` **or** + /// `read` (read implies read_self) **and** the target collection is + /// covered. + /// 4. `create` / `update` / `delete`: granted if the action set includes + /// the action **and** the target collection is covered. + pub fn matches_with(&self, target: &SpaceTarget, declared: &[String]) -> bool { + if !self.tuple_overlaps(&target.space_type, &target.did, &target.skey) { + return false; + } + + match target.action { + // Whole-space read ignores collection. + SpaceAction::Read => self.action.contains(&SpaceAction::Read), + // Own-repo read: read implies read_self; collection-constrained. + SpaceAction::ReadSelf => { + if !(self.action.contains(&SpaceAction::ReadSelf) + || self.action.contains(&SpaceAction::Read)) + { + return false; + } + // A `read` grant covers every collection for the own repo too. + if self.action.contains(&SpaceAction::Read) { + return true; + } + self.collection_covers(target.collection.as_deref(), declared) + } + // Writes require the action AND collection coverage. + SpaceAction::Create | SpaceAction::Update | SpaceAction::Delete => { + if !self.action.contains(&target.action) { + return false; + } + self.collection_covers(target.collection.as_deref(), declared) + } + } + } + + /// Check whether this granted permission satisfies the given record target, + /// treating an omitted-`collection` grant as conferring **no** write + /// targets (the `spaceType=*` / no-declaration case). Convenience wrapper + /// over [`matches_with`](Self::matches_with) with an empty declared list. + pub fn matches(&self, target: &SpaceTarget) -> bool { + self.matches_with(target, &[]) + } + + /// Whether this grant's collection set (resolved against `declared`) covers + /// the target collection. + fn collection_covers(&self, target: Option<&str>, declared: &[String]) -> bool { + let resolved = self.collections_resolved_with(declared); + if resolved.contains(&SpaceCollection::All) { + return true; + } + match target { + Some(c) => resolved.contains(&SpaceCollection::Nsid(c.to_string())), + None => false, + } + } + + /// Check whether this granted permission satisfies the given space-management + /// [`SpaceManageTarget`] (spec lines 415-419): the tuple must overlap and the + /// grant's `manage` set must contain the requested verb. `manage` ignores + /// `collection`. + pub fn matches_manage(&self, target: &SpaceManageTarget) -> bool { + if !self.tuple_overlaps(&target.space_type, &target.did, &target.skey) { + return false; + } + self.manage.contains(&target.verb) + } + + /// Build the minimal `space:` scope string that would satisfy the given + /// record target. + /// + /// For whole-space `read` the result is collection-independent; for + /// `read_self` and write actions the target's collection is included. + pub fn scope_needed_for(target: &SpaceTarget) -> String { + let space_type = nsid_or_all(&target.space_type); + let did = did_or_all(&target.did); + let skey = skey_or_all(&target.skey); + + let collection_independent = matches!(target.action, SpaceAction::Read); + + let collection = if collection_independent { + SpaceCollections::Explicit(BTreeSet::new()) + } else { + let mut set: BTreeSet = BTreeSet::new(); + if let Some(c) = &target.collection { + set.insert(if c == "*" { + SpaceCollection::All + } else { + SpaceCollection::Nsid(c.clone()) + }); + } + normalize_collection(&mut set); + SpaceCollections::Explicit(set) + }; + + let mut action: BTreeSet = BTreeSet::new(); + action.insert(target.action); + + SpacePermission { + space_type, + did, + skey, + collection, + action, + manage: BTreeSet::new(), + } + .to_scope_string() + } + + /// Build the minimal `space:` scope string that would satisfy the given + /// space-management target (a single `manage` verb, collection-independent). + pub fn scope_needed_for_manage(target: &SpaceManageTarget) -> String { + let mut manage: BTreeSet = BTreeSet::new(); + manage.insert(target.verb); + + SpacePermission { + space_type: nsid_or_all(&target.space_type), + did: did_or_all(&target.did), + skey: skey_or_all(&target.skey), + // No record actions and no collection — manage only. + collection: SpaceCollections::Explicit(BTreeSet::new()), + action: BTreeSet::new(), + manage, + } + .to_scope_string() + } +} + +/// Convert a target type string into a [`SpaceType`] (`*` → `All`). +fn nsid_or_all(value: &str) -> SpaceType { + if value == "*" { + SpaceType::All + } else { + SpaceType::Nsid(value.to_string()) + } +} + +/// Convert a target DID string into a [`SpaceDid`] (`*` → `All`). +fn did_or_all(value: &str) -> SpaceDid { + if value == "*" { + SpaceDid::All + } else { + SpaceDid::Did(value.to_string()) + } +} + +/// Convert a target skey string into a [`SpaceSkey`] (`*` → `All`). +fn skey_or_all(value: &str) -> SpaceSkey { + if value == "*" { + SpaceSkey::All + } else { + SpaceSkey::Key(value.to_string()) + } +} + +/// Normalize a collection set: if it contains `*`, collapse to just `*`. +/// +/// Deduplication and sorting are already handled by the [`BTreeSet`]. +fn normalize_collection(collection: &mut BTreeSet) { + if collection.len() > 1 && collection.contains(&SpaceCollection::All) { + collection.clear(); + collection.insert(SpaceCollection::All); + } +} + +impl fmt::Display for SpacePermission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_scope_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scopes::Scope; + + /// Parse a full `space:` scope string into a [`SpacePermission`]. + fn parse(scope: &str) -> SpacePermission { + match Scope::parse(scope).unwrap() { + Scope::Space(permission) => permission, + other => panic!("expected space scope, got {other:?}"), + } + } + + fn collections(values: &[SpaceCollection]) -> SpaceCollections { + SpaceCollections::Explicit(values.iter().cloned().collect()) + } + + fn actions(values: &[SpaceAction]) -> BTreeSet { + values.iter().copied().collect() + } + + fn manages(values: &[SpaceManageVerb]) -> BTreeSet { + values.iter().copied().collect() + } + + #[test] + fn test_parse_minimal_type_only() { + let permission = parse("space:com.example.space"); + assert_eq!( + permission.space_type, + SpaceType::Nsid("com.example.space".to_string()) + ); + // Defaults: did=*, skey=*, collection=Default, action={read,create, + // update,delete} (no read_self/no manage), manage empty. + assert_eq!(permission.did, SpaceDid::All); + assert_eq!(permission.skey, SpaceSkey::All); + assert_eq!(permission.collection, SpaceCollections::Default); + assert_eq!(permission.action, actions(&SpaceAction::DEFAULT)); + assert!(permission.manage.is_empty()); + } + + #[test] + fn test_parse_type_wildcard() { + let permission = parse("space:*"); + assert_eq!(permission.space_type, SpaceType::All); + } + + #[test] + fn test_parse_all_params() { + let permission = parse( + "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", + ); + assert_eq!( + permission.space_type, + SpaceType::Nsid("com.example.space".to_string()) + ); + assert_eq!(permission.did, SpaceDid::Did("did:plc:abc".to_string())); + assert_eq!(permission.skey, SpaceSkey::Key("my-space".to_string())); + assert_eq!( + permission.collection, + collections(&[ + SpaceCollection::Nsid("com.example.note".to_string()), + SpaceCollection::Nsid("com.example.photo".to_string()), + ]) + ); + assert_eq!( + permission.action, + actions(&[SpaceAction::Read, SpaceAction::Create]) + ); + assert_eq!(permission.manage, manages(&[SpaceManageVerb::Update])); + } + + #[test] + fn test_parse_read_self_action() { + let permission = parse("space:com.example.space?action=read_self"); + assert_eq!(permission.action, actions(&[SpaceAction::ReadSelf])); + } + + #[test] + fn test_parse_manage_is_separate_param() { + // `manage` is its own parameter, distinct from `action`. + let permission = parse("space:com.example.space?manage=create&manage=delete"); + // action keeps its default; manage carries the verbs. + assert_eq!(permission.action, actions(&SpaceAction::DEFAULT)); + assert_eq!( + permission.manage, + manages(&[SpaceManageVerb::Create, SpaceManageVerb::Delete]) + ); + } + + #[test] + fn test_parse_manage_invalid_verb_is_error() { + assert!(Scope::parse("space:com.example.space?manage=read").is_err()); + } + + #[test] + fn test_parse_wildcards_in_params() { + let permission = parse("space:*?did=*&skey=*&collection=*"); + assert_eq!(permission.space_type, SpaceType::All); + assert_eq!(permission.did, SpaceDid::All); + assert_eq!(permission.skey, SpaceSkey::All); + assert_eq!(permission.collection, collections(&[SpaceCollection::All])); + } + + #[test] + fn test_parse_collection_wildcard_collapses() { + // A `*` mixed with NSIDs collapses to just `*`. + let permission = parse("space:com.example.space?collection=com.example.note&collection=*"); + assert_eq!(permission.collection, collections(&[SpaceCollection::All])); + } + + #[test] + fn test_parse_collection_dedup() { + let permission = parse( + "space:com.example.space?collection=com.example.note&collection=com.example.note", + ); + assert_eq!( + permission.collection, + collections(&[SpaceCollection::Nsid("com.example.note".to_string())]) + ); + } + + #[test] + fn test_parse_missing_type_is_error() { + assert!(Scope::parse("space").is_err()); + assert!(Scope::parse("space:").is_err()); + assert!(Scope::parse("space?did=did:plc:abc").is_err()); + } + + #[test] + fn test_parse_unknown_param_is_error() { + assert!(Scope::parse("space:com.example.space?bogus=1").is_err()); + } + + #[test] + fn test_parse_type_as_named_param_is_error() { + assert!(Scope::parse("space:com.example.space?type=other").is_err()); + } + + #[test] + fn test_parse_invalid_action_is_error() { + assert!(Scope::parse("space:com.example.space?action=write").is_err()); + // `manage` is no longer a valid action value (it is its own param). + assert!(Scope::parse("space:com.example.space?action=manage").is_err()); + } + + #[test] + fn test_parse_duplicate_single_param_is_error() { + assert!(Scope::parse("space:com.example.space?did=did:a&did=did:b").is_err()); + assert!(Scope::parse("space:com.example.space?skey=a&skey=b").is_err()); + } + + #[test] + fn test_parse_skey_length_limits() { + // Empty skey is invalid. + assert!(Scope::parse("space:com.example.space?skey=").is_err()); + + // 512 chars is allowed. + let ok = "x".repeat(512); + assert!(Scope::parse(&format!("space:com.example.space?skey={ok}")).is_ok()); + + // 513 chars is rejected. + let too_long = "x".repeat(513); + assert!(Scope::parse(&format!("space:com.example.space?skey={too_long}")).is_err()); + } + + #[test] + fn test_format_omits_defaults() { + // Type-only round-trips to itself; defaults omitted. + let permission = parse("space:com.example.space"); + assert_eq!(permission.to_scope_string(), "space:com.example.space"); + + // Explicit defaults are stripped on format. + let permission = parse("space:com.example.space?did=*&skey=*"); + assert_eq!(permission.to_scope_string(), "space:com.example.space"); + + // The default action set ({read,create,update,delete}) is omitted. + let permission = + parse("space:com.example.space?action=read&action=create&action=update&action=delete"); + assert_eq!(permission.to_scope_string(), "space:com.example.space"); + } + + #[test] + fn test_format_includes_non_defaults() { + let permission = parse( + "space:com.example.space?did=did:plc:abc&skey=my-space&collection=com.example.note&action=read", + ); + assert_eq!( + permission.to_scope_string(), + "space:com.example.space?did=did:plc:abc&skey=my-space&collection=com.example.note&action=read" + ); + } + + #[test] + fn test_format_includes_manage() { + let permission = parse("space:com.example.space?manage=update&manage=delete"); + assert_eq!( + permission.to_scope_string(), + "space:com.example.space?manage=update&manage=delete" + ); + } + + #[test] + fn test_round_trip() { + let cases = [ + "space:com.example.space", + "space:*", + "space:com.example.space?did=did:plc:abc", + "space:com.example.space?skey=my-space", + "space:com.example.space?collection=com.example.note", + "space:com.example.space?collection=*", + "space:com.example.space?action=read", + "space:com.example.space?action=read_self", + "space:com.example.space?action=create&action=update", + "space:com.example.space?manage=update", + "space:com.example.space?action=read_self&manage=update&manage=delete", + "space:com.example.space?did=did:plc:abc&skey=s&collection=a.b.c&action=read&action=create", + ]; + + for case in cases { + let first = parse(case); + let formatted = first.to_scope_string(); + let second = parse(&formatted); + assert_eq!(first, second, "round-trip mismatch for {case}"); + // Formatting is idempotent. + assert_eq!(formatted, second.to_scope_string()); + } + } + + // --- Matching matrix --- + + #[test] + fn test_match_tuple_gate() { + let grant = parse("space:com.example.space?did=did:plc:abc&skey=s1"); + + // Exact tuple matches for read (read is in default action set). + assert!(grant.matches(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Read, + ))); + + // Wrong type, did, or skey each block. + assert!(!grant.matches(&SpaceTarget::new( + "com.other.space", + "did:plc:abc", + "s1", + SpaceAction::Read, + ))); + assert!(!grant.matches(&SpaceTarget::new( + "com.example.space", + "did:plc:xyz", + "s1", + SpaceAction::Read, + ))); + assert!(!grant.matches(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s2", + SpaceAction::Read, + ))); + } + + #[test] + fn test_match_tuple_wildcards() { + let grant = parse("space:*"); + // type/did/skey all `*` overlap any concrete tuple. + assert!(grant.matches(&SpaceTarget::new( + "anything", + "did:plc:zzz", + "whatever", + SpaceAction::Read, + ))); + } + + #[test] + fn test_match_read_does_not_imply_write_or_manage() { + // A read grant does not confer create. + let grant = parse("space:com.example.space?action=read"); + assert!(!grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.note", + ))); + // A grant without read does not confer whole-space read. + let grant = parse("space:com.example.space?action=create&collection=com.example.note"); + assert!(!grant.matches(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Read, + ))); + } + + #[test] + fn test_match_read_implies_read_self() { + // A whole-space `read` grant satisfies a read_self request and is not + // collection-constrained for the own repo. + let grant = parse("space:com.example.space?action=read"); + assert!(grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::ReadSelf, + "any.collection.here", + ))); + } + + #[test] + fn test_match_read_self_is_collection_constrained() { + // A read_self grant covers only the listed collection on the own repo. + let grant = parse("space:com.example.space?action=read_self&collection=com.example.note"); + assert!(grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::ReadSelf, + "com.example.note", + ))); + // A different collection is not covered. + assert!(!grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::ReadSelf, + "com.example.photo", + ))); + // read_self does NOT confer whole-space read. + assert!(!grant.matches(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Read, + ))); + } + + #[test] + fn test_match_manage_verbs() { + let grant = parse("space:com.example.space?manage=update"); + // The granted verb is permitted; collection is ignored. + assert!(grant.matches_manage(&SpaceManageTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceManageVerb::Update, + ))); + // A different verb is not. + assert!(!grant.matches_manage(&SpaceManageTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceManageVerb::Create, + ))); + } + + #[test] + fn test_match_bare_grant_confers_no_manage() { + // An ordinary record-access grant confers no administrative capability. + let grant = parse("space:com.example.space"); + for verb in SpaceManageVerb::ALL { + assert!(!grant.matches_manage(&SpaceManageTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + verb, + ))); + } + } + + #[test] + fn test_match_write_requires_action_and_collection() { + let grant = parse( + "space:com.example.space?collection=com.example.note&action=create&action=update", + ); + + // create on the covered collection is granted. + assert!(grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.note", + ))); + + // delete is not in the action list. + assert!(!grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Delete, + "com.example.note", + ))); + + // create on a different collection is not covered. + assert!(!grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.photo", + ))); + } + + #[test] + fn test_match_write_collection_wildcard() { + let grant = parse("space:com.example.space?collection=*&action=create"); + assert!(grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "any.collection.here", + ))); + } + + #[test] + fn test_match_collection_default_resolves_to_declared() { + // A bare grant defaults `collection` to the declared collections. + let grant = parse("space:com.example.space"); + assert!(grant.action.contains(&SpaceAction::Create)); + assert_eq!(grant.collection, SpaceCollections::Default); + + let declared = vec!["com.example.note".to_string()]; + + // With no declared collections (e.g. spaceType=*), writes are blocked. + assert!(!grant.matches(&SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.note", + ))); + + // With the declared collections resolved in, the write is permitted. + assert!(grant.matches_with( + &SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.note", + ), + &declared, + )); + // A collection outside the declaration is still blocked. + assert!(!grant.matches_with( + &SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.photo", + ), + &declared, + )); + + // Whole-space read is allowed regardless of collection. + assert!(grant.matches(&SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Read, + ))); + } + + #[test] + fn test_collections_resolved_with() { + let bare = parse("space:com.example.space"); + let declared = vec!["a.b.c".to_string(), "d.e.f".to_string()]; + assert_eq!( + bare.collections_resolved_with(&declared), + [ + SpaceCollection::Nsid("a.b.c".to_string()), + SpaceCollection::Nsid("d.e.f".to_string()) + ] + .into_iter() + .collect() + ); + + // An explicit list ignores the declaration. + let explicit = parse("space:com.example.space?collection=x.y.z"); + assert_eq!( + explicit.collections_resolved_with(&declared), + [SpaceCollection::Nsid("x.y.z".to_string())] + .into_iter() + .collect() + ); + } + + #[test] + fn test_match_write_without_target_collection_is_false() { + let grant = parse("space:com.example.space?collection=com.example.note&action=create"); + // A write target with no collection cannot match a non-wildcard grant. + let target = SpaceTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + ); + assert!(!grant.matches(&target)); + } + + // --- scope_needed_for --- + + #[test] + fn test_scope_needed_for_read_is_collection_independent() { + let target = SpaceTarget::new("com.example.space", "did:plc:abc", "s1", SpaceAction::Read); + assert_eq!( + SpacePermission::scope_needed_for(&target), + "space:com.example.space?did=did:plc:abc&skey=s1&action=read" + ); + } + + #[test] + fn test_scope_needed_for_manage_is_collection_independent() { + let target = SpaceManageTarget::new( + "com.example.space", + "did:plc:abc", + "s1", + SpaceManageVerb::Update, + ); + assert_eq!( + SpacePermission::scope_needed_for_manage(&target), + "space:com.example.space?did=did:plc:abc&skey=s1&manage=update" + ); + } + + #[test] + fn test_scope_needed_for_write_includes_collection() { + let target = SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Create, + "com.example.note", + ); + assert_eq!( + SpacePermission::scope_needed_for(&target), + "space:com.example.space?did=did:plc:abc&skey=s1&collection=com.example.note&action=create" + ); + } + + #[test] + fn test_scope_needed_for_round_trips_and_satisfies() { + let target = SpaceTarget::with_collection( + "com.example.space", + "did:plc:abc", + "s1", + SpaceAction::Update, + "com.example.note", + ); + let scope = SpacePermission::scope_needed_for(&target); + let permission = parse(&scope); + assert!(permission.matches(&target)); + } +} diff --git a/crates/atproto-pds/Cargo.toml b/crates/atproto-pds/Cargo.toml index 165ceec..b778235 100644 --- a/crates/atproto-pds/Cargo.toml +++ b/crates/atproto-pds/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atproto-pds" -version = "0.15.0-alpha.1" +version = "0.15.0-alpha.2" description = "AT Protocol Personal Data Server — server library and `pds` binary" readme = "README.md" homepage = "https://tangled.org/ngerakines.me/atproto-crates" @@ -32,6 +32,7 @@ required-features = ["clap"] [dependencies] atproto-dasl.workspace = true atproto-identity.workspace = true +atproto-lexicon.workspace = true atproto-oauth.workspace = true atproto-record.workspace = true atproto-repo.workspace = true @@ -128,10 +129,6 @@ smtp = ["dep:lettre"] # `metrics` — Prometheus exporter at `/metrics` + axum request-counter # middleware. metrics = ["dep:prometheus-client"] -# `ecmh` — replaces the placeholder `XorSha256SetHash` with the production -# `EcmhSetHash` (secp256k1 multiset hash) for all Spaces commitments. The -# digest size changes from 32 to 33 bytes; consumers must mirror. -ecmh = ["atproto-space/ecmh"] clap = ["dep:clap"] hickory-dns = ["atproto-identity/hickory-dns"] diff --git a/crates/atproto-pds/README.md b/crates/atproto-pds/README.md index 8c312b8..8184393 100644 --- a/crates/atproto-pds/README.md +++ b/crates/atproto-pds/README.md @@ -57,11 +57,17 @@ XRPC endpoints (default features): (RFC 7009), `/oauth/jwks`, `/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`. Multi-key JWK rotation is supported via `PDS_OAUTH_KEYS_JWK_SET`. -- **Spaces (`com.atproto.space.*`)** — `createSpace`, `getSpace`, `listSpaces`, - `addMember`, `removeMember`, `getMembers`, `applyWrites` (permissioned), - `getRecord`, `listRecords`, `getRepoState`, `getRepoOplog`, `getMemberState`, - `getMemberOplog`, `getMemberGrant`, `getSpaceCredential` (replay-protected - via the in-memory JTI guard, with optional Valkey/Redis backing). +- **Spaces** — owner-side management under `com.atproto.simplespace.*` + (`createSpace`, `updateSpace`, `deleteSpace`, `addMember`, `removeMember`, + `listMembers`) and the permissioned realm under `com.atproto.space.*` + (`getSpace`, `listSpaces`, `applyWrites`, `createRecord`, `putRecord`, + `deleteRecord`, `getRecord`, `listRecords` (keys-only), `getBlob`, + `listRepos`, `getRepoState`, `listRepoOps`, `getDelegationToken` → + `getSpaceCredential` (the two-step delegation-token/credential exchange, + replay-protected via the in-memory JTI guard with optional Valkey/Redis + backing), `registerNotify`, and the contentless + `notifyWrite`/`notifySpaceDeleted` inbound hooks). Aligned to the published + 0016 Permissioned Data spec. - **Admin** (`com.atproto.admin.*`) — `getAccountInfo`, `getAccountInfos`, `getSubjectStatus`, `updateSubjectStatus`, `deleteAccount`, `searchAccounts`, `getInviteCodes`, `disableInviteCodes`, @@ -81,8 +87,8 @@ metrics at `GET /metrics` when the `metrics` feature is on. Two compile-time-mutually-exclusive backends for the per-actor store: - **SQLite (default)** — per-actor SQLite files. Matches the upstream - Spaces Design Spec exactly. `cargo build` (or `cargo install`) produces - this profile. + 0016 Permissioned Data draft exactly. `cargo build` (or `cargo install`) + produces this profile. - **fjall** — single fjall `Database` per data-dir with one `Keyspace` per logical table. Lower-overhead single-host alternative; build with `--no-default-features --features fjall,smtp,metrics,hickory-dns`. The @@ -110,7 +116,6 @@ Per-actor SQLite vs fjall is independent of the accounts-DB choice. | `http` | yes | axum router + WebSocket subscribeRepos. | | `clap` | | Build the `pds` and `atproto-pds-admin` binaries. | | `hickory-dns` | yes | Hickory resolver via `atproto-identity/hickory-dns`. | -| `ecmh` | | Production `EcmhSetHash` over secp256k1 (replaces the placeholder `XorSha256SetHash`). | | `smtp` | | SMTP integration via `lettre`. When off, email-issuing endpoints fall back to dev-only INFO logging. | | `metrics` | | `prometheus-client` exporter at `GET /metrics` + axum request-counter middleware. | | `valkey` | | Valkey/Redis-backed JTI replay guard + sliding-window rate limiter. Wins over `--durability-profile` when `PDS_VALKEY_URL` is set. | @@ -178,15 +183,6 @@ PDS_POSTGRES_TEST_URL=postgres://pds:pds@127.0.0.1:5432/pds_live \ --test feature_postgres_live ``` -## Benchmarks - -`atproto-space` ships criterion benches comparing `XorSha256SetHash` and -`EcmhSetHash`: - -```bash -cargo bench -p atproto-space --features ecmh -``` - ## License MIT — see [LICENSE](../../LICENSE). diff --git a/crates/atproto-pds/migrations/accounts/20260501000001_init.sql b/crates/atproto-pds/migrations/accounts/20260501000001_init.sql index 02aa245..2d153b1 100644 --- a/crates/atproto-pds/migrations/accounts/20260501000001_init.sql +++ b/crates/atproto-pds/migrations/accounts/20260501000001_init.sql @@ -95,6 +95,12 @@ CREATE TABLE notify_attempt ( target_endpoint TEXT NOT NULL, payload_cbor BLOB NOT NULL, nsid TEXT NOT NULL, + -- Outbound request content-type. notifyWrite is application/json + -- (contentless { space, repo, rev }); notifyMembership is application/cbor. + content_type TEXT NOT NULL DEFAULT 'application/cbor', + -- Pre-minted service-auth bearer (iss = owner DID, aud = target service). + -- NULL for payloads that don't carry service auth. + auth_token TEXT, attempt_count INTEGER NOT NULL DEFAULT 0, last_attempt_at TEXT, next_attempt_at TEXT NOT NULL, diff --git a/crates/atproto-pds/migrations/actor/20260501000001_init.sql b/crates/atproto-pds/migrations/actor/20260501000001_init.sql index bd6204f..52f7faf 100644 --- a/crates/atproto-pds/migrations/actor/20260501000001_init.sql +++ b/crates/atproto-pds/migrations/actor/20260501000001_init.sql @@ -70,7 +70,17 @@ CREATE TABLE space ( uri TEXT PRIMARY KEY, is_owner INTEGER NOT NULL DEFAULT 0, is_member INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + -- simplespace config (com.atproto.simplespace.defs#spaceConfig). + -- mint_policy: 'public' | 'member-list' (default) | 'managing-app'. + mint_policy TEXT NOT NULL DEFAULT 'member-list', + -- app_access: JSON form of the #open / #allowList open union. + app_access TEXT NOT NULL DEFAULT '{"type":"open"}', + -- managing_app: optional service identifier of the managing application. + managing_app TEXT, + -- deleted_at: tombstone timestamp; non-NULL means the space is deleted + -- and all reads/writes must fail with SpaceNotFound. + deleted_at TEXT ); CREATE TABLE space_member_state ( @@ -127,10 +137,27 @@ CREATE TABLE space_member_oplog ( PRIMARY KEY (space, rev, idx) ); +-- Notify subscriptions (recipients of notifyWrite / notifyMembership fan-out). +-- +-- A row may be registered two ways: +-- * `getSpaceCredential` — owner records the credential consumer so future +-- commits fan out to it (whole-space; `repo` IS NULL). +-- * `registerNotify` — a space credential holder subscribes an endpoint, either +-- to the whole space (`repo` IS NULL, space-host case) or to a single repo +-- (`repo` = that account's DID, repo-host case). +-- +-- `expires_at` is the registration lifetime (RFC 3339). NULL means no expiry +-- (the `getSpaceCredential` self-registration path leaves it unset). Uniqueness +-- is keyed on (space, repo, service_did) so per-repo and whole-space +-- subscriptions for the same service coexist; `repo` uses the empty string as +-- the "whole space" sentinel so it participates in the PRIMARY KEY (SQLite +-- treats NULL as distinct in unique constraints, which would defeat upserts). CREATE TABLE space_credential_recipient ( space TEXT NOT NULL REFERENCES space(uri) ON DELETE CASCADE, + repo TEXT NOT NULL DEFAULT '', service_did TEXT NOT NULL, service_endpoint TEXT NOT NULL, last_issued_at TEXT NOT NULL, - PRIMARY KEY (space, service_did) + expires_at TEXT, + PRIMARY KEY (space, repo, service_did) ); diff --git a/crates/atproto-pds/migrations/postgres/20260507000001_init.sql b/crates/atproto-pds/migrations/postgres/20260507000001_init.sql index 937fe42..6fdb498 100644 --- a/crates/atproto-pds/migrations/postgres/20260507000001_init.sql +++ b/crates/atproto-pds/migrations/postgres/20260507000001_init.sql @@ -111,6 +111,8 @@ CREATE TABLE notify_attempt ( target_endpoint TEXT NOT NULL, payload_cbor BYTEA NOT NULL, nsid TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'application/cbor', + auth_token TEXT, attempt_count BIGINT NOT NULL DEFAULT 0, last_attempt_at TEXT, next_attempt_at TEXT NOT NULL, diff --git a/crates/atproto-pds/src/actor_store/fjall/keyspace.rs b/crates/atproto-pds/src/actor_store/fjall/keyspace.rs index ba39116..bbfbcc9 100644 --- a/crates/atproto-pds/src/actor_store/fjall/keyspace.rs +++ b/crates/atproto-pds/src/actor_store/fjall/keyspace.rs @@ -281,19 +281,6 @@ pub fn oplog_prefix_by_space(space_uri: &str) -> Vec { buf } -/// Strict-greater oplog cursor: `\0\0\xFF` so iteration -/// resumes from the next rev forward. -#[must_use] -pub fn oplog_after_rev(space_uri: &str, rev: &str) -> Vec { - let mut buf = Vec::with_capacity(space_uri.len() + rev.len() + 3); - buf.extend_from_slice(space_uri.as_bytes()); - buf.push(0); - buf.extend_from_slice(rev.as_bytes()); - buf.push(0); - buf.push(0xFF); - buf -} - // --------------------------------------------------------------------------- // Public-realm key encodings (§1.2). Each row keys a per-DID prefix so // range scans by DID are cheap and a single fjall keyspace can hold all @@ -481,11 +468,11 @@ mod tests { } #[test] - fn oplog_after_rev_skips_current_rev() { - let mid = oplog_key("ats://x/y/z", "abc", 0); - let cursor = oplog_after_rev("ats://x/y/z", "abc"); - assert!(cursor > mid); - let next_rev_first = oplog_key("ats://x/y/z", "abd", 0); - assert!(cursor < next_rev_first); + fn oplog_key_orders_across_revs() { + // A composite `(rev, idx)` cursor resumes at the exact op key, so the + // last idx of one rev sorts before the first idx of the next rev. + let rev_a_last = oplog_key("ats://x/y/z", "abc", 99); + let rev_b_first = oplog_key("ats://x/y/z", "abd", 0); + assert!(rev_a_last < rev_b_first); } } diff --git a/crates/atproto-pds/src/actor_store/fjall/space_members_storage.rs b/crates/atproto-pds/src/actor_store/fjall/space_members_storage.rs index d2ec076..43655e3 100644 --- a/crates/atproto-pds/src/actor_store/fjall/space_members_storage.rs +++ b/crates/atproto-pds/src/actor_store/fjall/space_members_storage.rs @@ -1,14 +1,11 @@ //! `FjallSpaceMembersStorage` — `SpaceMembersStorage` impl over fjall. -use super::keyspace::{ - FjallActorStore, member_key, member_prefix, oplog_after_rev, oplog_key, oplog_prefix_by_space, -}; +use super::keyspace::{FjallActorStore, member_key, member_prefix, oplog_key}; use async_trait::async_trait; use atproto_space::SpaceError; use atproto_space::errors::SpaceResult; use atproto_space::storage::{ - MemberChange, MemberPage, MemberRow, MemberState, OplogEntry, OplogPage, PreparedCommitMembers, - SpaceMembersStorage, + MemberChange, MemberPage, MemberRow, MemberState, PreparedCommitMembers, SpaceMembersStorage, }; use atproto_space::types::SpaceUri; use serde::{Deserialize, Serialize}; @@ -174,68 +171,12 @@ impl SpaceMembersStorage for FjallSpaceMembersStorage { .map_err(|e| fjall_err(format!("commit batch: {e}")))?; Ok(()) } - - async fn read_oplog( - &self, - space: &SpaceUri, - since: Option<&str>, - limit: u32, - ) -> SpaceResult { - let space_uri = space.to_string(); - let limit = limit.clamp(1, 1000) as usize; - let part = self.store.space_member_oplog(); - - let prefix = oplog_prefix_by_space(&space_uri); - let cursor = since.map(|s| oplog_after_rev(&space_uri, s)); - - let mut ops = Vec::with_capacity(limit); - for kv in part.prefix(&prefix) { - let (k, v) = kv - .into_inner() - .map_err(|e| fjall_err(format!("scan oplog: {e}")))?; - if let Some(c) = cursor.as_ref() - && k.as_ref() <= c.as_slice() - { - continue; - } - let suffix = &k[prefix.len()..]; - let null_pos = suffix - .iter() - .position(|b| *b == 0) - .ok_or_else(|| fjall_err("oplog key missing rev separator".to_string()))?; - let rev = std::str::from_utf8(&suffix[..null_pos]) - .map_err(|e| fjall_err(format!("rev utf-8: {e}")))? - .to_string(); - let idx_str = std::str::from_utf8(&suffix[null_pos + 1..]) - .map_err(|e| fjall_err(format!("idx utf-8: {e}")))?; - let idx: u32 = idx_str - .parse() - .map_err(|e| fjall_err(format!("idx parse: {e}")))?; - - let row: MemberOplogRow = decode(&v)?; - ops.push(OplogEntry { - rev, - idx, - action: row.action, - collection: None, - rkey: None, - cid: None, - prev: None, - did: Some(row.did), - }); - if ops.len() >= limit { - break; - } - } - let state = self.current_state(space).await?; - Ok(OplogPage { ops, state }) - } } #[cfg(test)] mod tests { use super::*; - use atproto_space::set_hash::XorSha256SetHash; + use atproto_space::set_hash::LtHash; use atproto_space::space_members::{MemberOp, MemberOpAction, SpaceMembers}; use atproto_space::types::{SpaceKey, SpaceType}; use tempfile::TempDir; @@ -248,7 +189,7 @@ mod tests { ) } - type TestMembers = SpaceMembers; + type TestMembers = SpaceMembers; fn fresh() -> (FjallSpaceMembersStorage, TempDir) { let tmp = TempDir::new().unwrap(); @@ -287,7 +228,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn list_paginates_and_oplog_round_trips() { + async fn list_paginates() { let (storage, _tmp) = fresh(); let space = test_space(); let m: TestMembers = SpaceMembers::new(space.clone(), storage); @@ -306,9 +247,6 @@ mod tests { let page = m.list_members(None, 2).await.unwrap(); assert_eq!(page.members.len(), 2); assert_eq!(page.members[0].did, "did:plc:a"); - - let oplog = m.read_oplog(None, 100).await.unwrap(); - assert_eq!(oplog.ops.len(), 3); - assert!(oplog.ops.iter().all(|o| o.action == "add")); + assert_eq!(page.cursor.as_deref(), Some("did:plc:b")); } } diff --git a/crates/atproto-pds/src/actor_store/fjall/space_repo_storage.rs b/crates/atproto-pds/src/actor_store/fjall/space_repo_storage.rs index 92c55f5..ca77537 100644 --- a/crates/atproto-pds/src/actor_store/fjall/space_repo_storage.rs +++ b/crates/atproto-pds/src/actor_store/fjall/space_repo_storage.rs @@ -8,15 +8,14 @@ //! Key layouts: see [`super::keyspace`]. use super::keyspace::{ - FjallActorStore, oplog_after_rev, oplog_key, oplog_prefix_by_space, record_key, record_prefix, - repo_state_key, + FjallActorStore, oplog_key, oplog_prefix_by_space, record_key, record_prefix, repo_state_key, }; use async_trait::async_trait; use atproto_space::SpaceError; use atproto_space::errors::SpaceResult; use atproto_space::storage::{ - OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, RepoState, - SpaceRepoStorage, + OplogCursor, OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, + RepoState, SpaceRepoStorage, }; use atproto_space::types::SpaceUri; use serde::{Deserialize, Serialize}; @@ -259,14 +258,17 @@ impl SpaceRepoStorage for FjallSpaceRepoStorage { async fn read_oplog( &self, space: &SpaceUri, - since: Option<&str>, + since: Option<&OplogCursor>, limit: u32, ) -> SpaceResult { let space_uri = space.to_string(); let limit = limit.clamp(1, 1000) as usize; let part = self.store.space_record_oplog(); - let cursor = since.map(|s| oplog_after_rev(&space_uri, s)); + // The oplog key encodes `(rev, idx)` with a zero-padded idx, so the + // exact key for the cursor's `(rev, idx)` is the highest key to skip; + // `k <= cursor` yields `(rev, idx) > (since.rev, since.idx)`. + let cursor = since.map(|c| oplog_key(&space_uri, &c.rev, c.idx)); let prefix = oplog_prefix_by_space(&space_uri); let mut ops = Vec::with_capacity(limit); @@ -317,7 +319,7 @@ impl SpaceRepoStorage for FjallSpaceRepoStorage { #[cfg(test)] mod tests { use super::*; - use atproto_space::set_hash::XorSha256SetHash; + use atproto_space::set_hash::LtHash; use atproto_space::space_repo::{Op, OpAction, SpaceRepo}; use atproto_space::types::{SpaceKey, SpaceType}; use tempfile::TempDir; @@ -330,7 +332,7 @@ mod tests { ) } - type TestRepo = SpaceRepo; + type TestRepo = SpaceRepo; fn fresh_storage() -> (FjallSpaceRepoStorage, TempDir) { let tmp = TempDir::new().unwrap(); @@ -489,7 +491,8 @@ mod tests { ) .await .unwrap(); - let oplog = repo.read_oplog(Some(&first_rev), 100).await.unwrap(); + let cursor = OplogCursor::new(first_rev, 0); + let oplog = repo.read_oplog(Some(&cursor), 100).await.unwrap(); assert_eq!(oplog.ops.len(), 1); assert_eq!(oplog.ops[0].rkey.as_deref(), Some("b")); } diff --git a/crates/atproto-pds/src/actor_store/sql/space_members_storage.rs b/crates/atproto-pds/src/actor_store/sql/space_members_storage.rs index bd0b4e4..34f72c9 100644 --- a/crates/atproto-pds/src/actor_store/sql/space_members_storage.rs +++ b/crates/atproto-pds/src/actor_store/sql/space_members_storage.rs @@ -6,8 +6,7 @@ use async_trait::async_trait; use atproto_space::SpaceError; use atproto_space::errors::SpaceResult; use atproto_space::storage::{ - MemberChange, MemberPage, MemberRow, MemberState, OplogEntry, OplogPage, PreparedCommitMembers, - SpaceMembersStorage, + MemberChange, MemberPage, MemberRow, MemberState, PreparedCommitMembers, SpaceMembersStorage, }; use atproto_space::types::SpaceUri; use sqlx::SqlitePool; @@ -183,61 +182,13 @@ impl SpaceMembersStorage for SqlSpaceMembersStorage { .map_err(|e| sql_err(format!("apply_commit commit: {e}")))?; Ok(()) } - - async fn read_oplog( - &self, - space: &SpaceUri, - since: Option<&str>, - limit: u32, - ) -> SpaceResult { - let limit = limit.clamp(1, 1000); - let rows: Vec<(String, i64, String, String)> = match since { - Some(s) => { - sqlx::query_as( - "SELECT rev, idx, action, did FROM space_member_oplog - WHERE space = ? AND rev > ? ORDER BY rev ASC, idx ASC LIMIT ?", - ) - .bind(space.to_string()) - .bind(s) - .bind(limit as i64) - .fetch_all(&self.pool) - .await - } - None => { - sqlx::query_as( - "SELECT rev, idx, action, did FROM space_member_oplog - WHERE space = ? ORDER BY rev ASC, idx ASC LIMIT ?", - ) - .bind(space.to_string()) - .bind(limit as i64) - .fetch_all(&self.pool) - .await - } - } - .map_err(|e| sql_err(format!("read_oplog: {e}")))?; - let ops = rows - .into_iter() - .map(|(rev, idx, action, did)| OplogEntry { - rev, - idx: idx as u32, - action, - collection: None, - rkey: None, - cid: None, - prev: None, - did: Some(did), - }) - .collect(); - let state = self.current_state(space).await?; - Ok(OplogPage { ops, state }) - } } #[cfg(test)] mod tests { use super::*; use crate::actor_store::sql::SqlActorStore; - use atproto_space::set_hash::XorSha256SetHash; + use atproto_space::set_hash::LtHash; use atproto_space::space_members::{MemberOp, MemberOpAction, SpaceMembers}; use atproto_space::types::{SpaceKey, SpaceType}; @@ -254,7 +205,7 @@ mod tests { SqlSpaceMembersStorage::new(store.pool().clone()) } - type TestMembers = SpaceMembers; + type TestMembers = SpaceMembers; #[tokio::test(flavor = "multi_thread")] async fn add_then_remove() { @@ -286,7 +237,7 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn list_paginates_and_oplog_round_trips() { + async fn list_paginates() { let space = test_space(); let m: TestMembers = SpaceMembers::new(space.clone(), fresh_storage().await); for did in ["did:plc:a", "did:plc:b", "did:plc:c"] { @@ -304,9 +255,6 @@ mod tests { let page = m.list_members(None, 2).await.unwrap(); assert_eq!(page.members.len(), 2); assert_eq!(page.members[0].did, "did:plc:a"); - - let oplog = m.read_oplog(None, 100).await.unwrap(); - assert_eq!(oplog.ops.len(), 3); - assert!(oplog.ops.iter().all(|o| o.action == "add")); + assert_eq!(page.cursor.as_deref(), Some("did:plc:b")); } } diff --git a/crates/atproto-pds/src/actor_store/sql/space_repo_storage.rs b/crates/atproto-pds/src/actor_store/sql/space_repo_storage.rs index 44fd5a1..753ec3b 100644 --- a/crates/atproto-pds/src/actor_store/sql/space_repo_storage.rs +++ b/crates/atproto-pds/src/actor_store/sql/space_repo_storage.rs @@ -8,8 +8,8 @@ use async_trait::async_trait; use atproto_space::SpaceError; use atproto_space::errors::SpaceResult; use atproto_space::storage::{ - OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, RepoState, - SpaceRepoStorage, + OplogCursor, OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, + RepoState, SpaceRepoStorage, }; use atproto_space::types::SpaceUri; use sqlx::SqlitePool; @@ -251,7 +251,7 @@ impl SpaceRepoStorage for SqlSpaceRepoStorage { async fn read_oplog( &self, space: &SpaceUri, - since: Option<&str>, + since: Option<&OplogCursor>, limit: u32, ) -> SpaceResult { let limit = limit.clamp(1, 1000); @@ -264,14 +264,19 @@ impl SpaceRepoStorage for SqlSpaceRepoStorage { Option, Option, )> = match since { - Some(s) => { + Some(cur) => { + // `(rev, idx) > (since.rev, since.idx)` so an atomic batch + // (entries sharing a rev) that exceeds `limit` pages fully. sqlx::query_as( "SELECT rev, idx, action, collection, rkey, cid, prev - FROM space_record_oplog WHERE space = ? AND rev > ? + FROM space_record_oplog + WHERE space = ? AND (rev > ? OR (rev = ? AND idx > ?)) ORDER BY rev ASC, idx ASC LIMIT ?", ) .bind(space.to_string()) - .bind(s) + .bind(&cur.rev) + .bind(&cur.rev) + .bind(cur.idx as i64) .bind(limit as i64) .fetch_all(&self.pool) .await @@ -313,7 +318,7 @@ impl SpaceRepoStorage for SqlSpaceRepoStorage { mod tests { use super::*; use crate::actor_store::sql::SqlActorStore; - use atproto_space::set_hash::{SetHash, XorSha256SetHash}; + use atproto_space::set_hash::{LtHash, SetHash}; use atproto_space::space_repo::{Op, OpAction, SpaceRepo}; use atproto_space::types::{SpaceKey, SpaceType}; @@ -330,7 +335,7 @@ mod tests { SqlSpaceRepoStorage::new(store.pool().clone()) } - type TestRepo = SpaceRepo; + type TestRepo = SpaceRepo; #[tokio::test(flavor = "multi_thread")] async fn create_and_read_record() { @@ -390,6 +395,54 @@ mod tests { assert_eq!(page.ops[1].idx, 1); } + /// Regression: a single atomic batch larger than the page `limit` pages + /// fully via the SQL `(rev > ? OR (rev = ? AND idx > ?))` predicate, with no + /// tail skipped. A bare-rev `rev > ?` cursor would skip ops `limit..N`. + #[tokio::test(flavor = "multi_thread")] + async fn batch_larger_than_limit_pages_fully() { + let space = test_space(); + let repo: TestRepo = SpaceRepo::new(space.clone(), fresh_storage().await); + + const N: usize = 7; + const LIMIT: u32 = 3; + + let ops: Vec = (0..N) + .map(|i| Op { + action: OpAction::Create, + collection: "c".to_string(), + rkey: format!("k{i}"), + cid: Some(format!("cid{i}")), + value: Some(vec![]), + }) + .collect(); + let prepared = repo.format_commit(&ops).await.unwrap(); + repo.apply_commit(prepared).await.unwrap(); + + let mut seen: Vec<(String, u32)> = Vec::new(); + let mut cursor: Option = None; + loop { + let page = repo.read_oplog(cursor.as_ref(), LIMIT).await.unwrap(); + for op in &page.ops { + seen.push((op.rev.clone(), op.idx)); + } + let caught_up = (page.ops.len() as u32) < LIMIT; + cursor = page + .ops + .last() + .map(|o| OplogCursor::new(o.rev.clone(), o.idx)); + if caught_up { + break; + } + } + + assert_eq!(seen.len(), N, "all batch ops delivered, none skipped"); + let rev = &seen[0].0; + for (i, (r, idx)) in seen.iter().enumerate() { + assert_eq!(r, rev, "all ops share the batch rev"); + assert_eq!(*idx as usize, i, "idx is dense and monotonic"); + } + } + #[tokio::test(flavor = "multi_thread")] async fn list_records_paginates() { let space = test_space(); @@ -454,12 +507,15 @@ mod tests { assert!(repo.get_record("c", "k").await.unwrap().is_none()); } + /// The persisted lattice state round-trips through + /// `state_bytes()`/`from_state_bytes()`, preserving both state and digest. #[test] - fn xor_set_hash_matches_in_memory() { - let mut h1 = XorSha256SetHash::empty(); - let mut h2 = XorSha256SetHash::empty(); - h1.add(b"x"); - h2.add(b"x"); - assert_eq!(h1.digest(), h2.digest()); + fn set_hash_state_round_trips() { + let mut h = LtHash::empty(); + h.add(b"x"); + let state = h.state_bytes(); + let rehydrated = LtHash::from_state_bytes(&state).unwrap(); + assert_eq!(rehydrated.state_bytes(), state); + assert_eq!(rehydrated.digest(), h.digest()); } } diff --git a/crates/atproto-pds/src/admin/handlers.rs b/crates/atproto-pds/src/admin/handlers.rs index 71b4d3d..a656d87 100644 --- a/crates/atproto-pds/src/admin/handlers.rs +++ b/crates/atproto-pds/src/admin/handlers.rs @@ -762,7 +762,7 @@ pub async fn takedown_space_record( format!("invalid space URI: {e}"), ) })?; - let store = crate::actor_store::sql::SqlActorStore::open(manager.data_dir(), &space.owner_did) + let store = crate::actor_store::sql::SqlActorStore::open(manager.data_dir(), &space.space_did) .await .map_err(XrpcError::from)?; if input.takedown { diff --git a/crates/atproto-pds/src/bin/pds.rs b/crates/atproto-pds/src/bin/pds.rs index 5053e04..e0e00cf 100644 --- a/crates/atproto-pds/src/bin/pds.rs +++ b/crates/atproto-pds/src/bin/pds.rs @@ -195,7 +195,7 @@ struct Args { /// loop walks each per-actor store on each tick and prunes oplog /// rows whose TID sorts below the cutoff. `0` disables the sweep. /// Receivers that lag behind this window must re-sync via - /// `getRepoState` + `exportSpaces`. + /// `getRepoState`. #[arg( long, env = "PDS_SPACE_OPLOG_RETENTION_DAYS", @@ -225,7 +225,7 @@ struct Args { space_notify_retry_max_attempts: u32, /// SpaceCredential TTL in seconds. Default - /// 10800 (3h, matching `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS`). + /// 7200 (2h, matching `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS`). #[arg( long, env = "PDS_SPACE_CREDENTIAL_TTL_SECONDS", @@ -241,13 +241,6 @@ struct Args { #[arg(long, env = "PDS_SERVICE_HANDLE_DOMAINS", value_delimiter = ',')] service_handle_domains: Vec, - /// When set, `space.addMember` / `space.removeMember` send an - /// notification email to the affected member via `EmailService` - /// The flag is a sentinel — set to any - /// truthy value (`1`, `true`) to enable. - #[arg(long, env = "PDS_NOTIFY_MEMBERSHIP_EMAIL", default_value_t = false)] - notify_membership_email: bool, - /// Comma-separated list of crawler hostnames to notify via /// `com.atproto.sync.requestCrawl`. Each /// entry is a base URL like `https://relay.example`. The handler @@ -457,10 +450,10 @@ async fn main() -> anyhow::Result<()> { args.data_dir.clone(), account_manager.clone(), )); - let space_writer = Arc::new(SpaceWriter::new( - account_manager.clone(), - args.data_dir.clone(), - )); + let space_writer = Arc::new( + SpaceWriter::new(account_manager.clone(), args.data_dir.clone()) + .with_plc_directory(args.plc_directory.clone()), + ); let space_reader = Arc::new(SpaceReader::new( account_manager.clone(), args.data_dir.clone(), @@ -599,7 +592,6 @@ async fn main() -> anyhow::Result<()> { .with_public_realm_backend(public_realm_backend.clone()) .with_space_credential_ttl(args.space_credential_ttl_seconds) .with_service_handle_domains(args.service_handle_domains.clone()) - .with_notify_membership_email(args.notify_membership_email) .with_crawlers(args.crawlers.clone()) // Persist OAuth in-flight state (PAR / auth-codes / refresh handles) // to the accounts DB so the lifecycle survives PDS restart. See @@ -620,6 +612,30 @@ async fn main() -> anyhow::Result<()> { } #[cfg(feature = "hickory-dns")] { + // Space-type declaration resolver (NSID → declared `collections`) for + // the bare `space:` grant default (spec line 413). Reuses the same + // DNS resolver + PLC hostname as handle resolution; results are + // TTL-cached to avoid resolving on every authorization check. + let plc_hostname = args + .plc_directory + .as_deref() + .and_then(|url| url.split("://").nth(1).map(|h| h.to_string())) + .unwrap_or_else(|| "plc.directory".to_string()); + let decl_http = reqwest::Client::builder() + .user_agent(user_agent()) + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_default(); + let network = atproto_pds::space::NetworkSpaceDeclarationResolver::new( + dns_resolver.clone(), + plc_hostname, + decl_http, + ); + let cached = atproto_pds::space::CachingSpaceDeclarationResolver::new( + Arc::new(network), + std::time::Duration::from_secs(300), + ); + state = state.with_space_declaration_resolver(Arc::new(cached)); state = state.with_dns_resolver(dns_resolver); } if let (Some(did), Some(url)) = ( diff --git a/crates/atproto-pds/src/errors.rs b/crates/atproto-pds/src/errors.rs index e8edc03..21e77ed 100644 --- a/crates/atproto-pds/src/errors.rs +++ b/crates/atproto-pds/src/errors.rs @@ -53,6 +53,23 @@ pub enum PdsError { what: String, }, + /// error-atproto-pds-space-2: the referenced space does not exist or has + /// been tombstoned (`deleted_at IS NOT NULL`). Surfaced to clients as the + /// `SpaceNotFound` XRPC error. + #[error("error-atproto-pds-space-2 space not found: {uri}")] + SpaceNotFound { + /// URI of the missing or deleted space. + uri: String, + }, + + /// error-atproto-pds-space-3: the caller is not the owner of the space. + /// Surfaced to clients as the `NotSpaceOwner` XRPC error. + #[error("error-atproto-pds-space-3 not the space owner: {uri}")] + NotSpaceOwner { + /// URI of the space. + uri: String, + }, + /// error-atproto-pds-account-1: account state transition rejected. #[error("error-atproto-pds-account-1 invalid account state transition: {from} -> {to}")] InvalidAccountTransition { diff --git a/crates/atproto-pds/src/gc.rs b/crates/atproto-pds/src/gc.rs index b277a4c..f7c5301 100644 --- a/crates/atproto-pds/src/gc.rs +++ b/crates/atproto-pds/src/gc.rs @@ -32,7 +32,7 @@ pub const DEFAULT_NOTIFY_DELIVERED_RETENTION_DAYS: i64 = 7; pub const DEFAULT_NOTIFY_FAILED_RETENTION_DAYS: i64 = 30; /// Default retention for `space_*_oplog` rows. /// Receivers that lag behind this window need a full re-sync via -/// `getRepoState` + `exportSpaces`. Operators tighten via +/// `getRepoState`. Operators tighten via /// `PDS_SPACE_OPLOG_RETENTION_DAYS`. pub const DEFAULT_SPACE_OPLOG_RETENTION_DAYS: i64 = 30; diff --git a/crates/atproto-pds/src/http/auth.rs b/crates/atproto-pds/src/http/auth.rs index 14c011d..76049aa 100644 --- a/crates/atproto-pds/src/http/auth.rs +++ b/crates/atproto-pds/src/http/auth.rs @@ -60,6 +60,17 @@ impl AuthSubject { } } + /// OAuth `client_id` of the requesting app, when the token is an OAuth + /// access token. `None` for app-password sessions (which have no + /// associated OAuth client). + #[must_use] + pub fn client_id(&self) -> Option<&str> { + match self { + AuthSubject::AppPassword(_) => None, + AuthSubject::OAuth(c) => Some(&c.client_id), + } + } + /// `true` when this is an OAuth token bound to a DPoP key (the /// `cnf.jkt` claim is present). #[must_use] @@ -67,6 +78,28 @@ impl AuthSubject { matches!(self, AuthSubject::OAuth(c) if c.cnf.is_some()) } + /// `true` when this is an OAuth access token (as opposed to an + /// app-password session). Space scope gating only applies to OAuth + /// tokens — app-password sessions carry no `space:` grants and so can + /// never satisfy [`assert_space`](atproto_oauth::scopes::ScopesSet::assert_space). + #[must_use] + pub fn is_oauth(&self) -> bool { + matches!(self, AuthSubject::OAuth(_)) + } + + /// The granted OAuth scope set, parsed from the access token's `scope` + /// claim. Returns an empty set for app-password sessions (which have no + /// OAuth scope string), so callers can uniformly run + /// [`assert_space`](atproto_oauth::scopes::ScopesSet::assert_space) + /// against it — an empty set simply satisfies nothing. + #[must_use] + pub fn scopes(&self) -> atproto_oauth::scopes::ScopesSet { + match self { + AuthSubject::AppPassword(_) => atproto_oauth::scopes::ScopesSet::new(), + AuthSubject::OAuth(c) => atproto_oauth::scopes::ScopesSet::from_scope_string(&c.scope), + } + } + /// `true` if the token is allowed to perform privileged operations /// (those gated to sensitive paths like `importRepo`, /// `getServiceAuth`, account migration). For app-password sessions diff --git a/crates/atproto-pds/src/http/errors.rs b/crates/atproto-pds/src/http/errors.rs index 13fafd3..3c38a84 100644 --- a/crates/atproto-pds/src/http/errors.rs +++ b/crates/atproto-pds/src/http/errors.rs @@ -50,6 +50,16 @@ impl From for XrpcError { PdsError::NotFound { what } => { XrpcError::new(StatusCode::BAD_REQUEST, "NotFound", what) } + PdsError::SpaceNotFound { uri } => XrpcError::new( + StatusCode::BAD_REQUEST, + "SpaceNotFound", + format!("no such space {uri}"), + ), + PdsError::NotSpaceOwner { uri } => XrpcError::new( + StatusCode::FORBIDDEN, + "NotSpaceOwner", + format!("not the owner of {uri}"), + ), PdsError::AuthDenied { reason } => { XrpcError::new(StatusCode::FORBIDDEN, "Forbidden", reason) } diff --git a/crates/atproto-pds/src/http/router.rs b/crates/atproto-pds/src/http/router.rs index 06baa62..23a38f5 100644 --- a/crates/atproto-pds/src/http/router.rs +++ b/crates/atproto-pds/src/http/router.rs @@ -257,35 +257,56 @@ pub fn build_router(state: HttpState) -> Router { "/.well-known/oauth-protected-resource", get(oauth::oauth_protected_resource), ) - // com.atproto.space.* — permissioned realm + // com.atproto.simplespace.* — host-internal space management .route( - "/xrpc/com.atproto.space.createSpace", + "/xrpc/com.atproto.simplespace.createSpace", post(space_handlers::create_space), ) .route( - "/xrpc/com.atproto.space.getSpace", - get(space_handlers::get_space), + "/xrpc/com.atproto.simplespace.updateSpace", + post(space_handlers::update_space), ) .route( - "/xrpc/com.atproto.space.listSpaces", - get(space_handlers::list_spaces), + "/xrpc/com.atproto.simplespace.deleteSpace", + post(space_handlers::delete_space), ) .route( - "/xrpc/com.atproto.space.addMember", + "/xrpc/com.atproto.simplespace.addMember", post(space_handlers::add_member), ) .route( - "/xrpc/com.atproto.space.removeMember", + "/xrpc/com.atproto.simplespace.removeMember", post(space_handlers::remove_member), ) .route( - "/xrpc/com.atproto.space.getMembers", + "/xrpc/com.atproto.simplespace.listMembers", get(space_handlers::get_members), ) + // com.atproto.space.* — permissioned realm + .route( + "/xrpc/com.atproto.space.getSpace", + get(space_handlers::get_space), + ) + .route( + "/xrpc/com.atproto.space.listSpaces", + get(space_handlers::list_spaces), + ) .route( "/xrpc/com.atproto.space.applyWrites", post(space_handlers::apply_writes), ) + .route( + "/xrpc/com.atproto.space.createRecord", + post(space_handlers::create_record_write), + ) + .route( + "/xrpc/com.atproto.space.putRecord", + post(space_handlers::put_record_write), + ) + .route( + "/xrpc/com.atproto.space.deleteRecord", + post(space_handlers::delete_record_write), + ) .route( "/xrpc/com.atproto.space.getRecord", get(space_handlers::get_record), @@ -295,47 +316,43 @@ pub fn build_router(state: HttpState) -> Router { get(space_handlers::list_records), ) .route( - "/xrpc/com.atproto.space.getRepoState", - get(space_handlers::get_repo_state), + "/xrpc/com.atproto.space.getBlob", + get(space_handlers::get_blob), ) .route( - "/xrpc/com.atproto.space.getRepoOplog", - get(space_handlers::get_repo_oplog), + "/xrpc/com.atproto.space.listRepos", + get(space_handlers::list_repos), ) .route( - "/xrpc/com.atproto.space.getMemberState", - get(space_handlers::get_member_state), + "/xrpc/com.atproto.space.getRepoState", + get(space_handlers::get_repo_state), ) .route( - "/xrpc/com.atproto.space.getMemberOplog", - get(space_handlers::get_member_oplog), + "/xrpc/com.atproto.space.listRepoOps", + get(space_handlers::list_repo_ops), ) .route( - "/xrpc/com.atproto.space.getMemberGrant", - post(space_handlers::get_member_grant), + "/xrpc/com.atproto.space.getDelegationToken", + get(space_handlers::get_delegation_token), ) .route( "/xrpc/com.atproto.space.getSpaceCredential", post(space_handlers::get_space_credential), ) - // Inbound notifyWrite + notifyMembership. - // Auth is structural: the embedded signed Commit IS the auth. + // Notify subscription (space-credential auth). .route( - "/xrpc/com.atproto.space.notifyWrite", - post(space_handlers::notify_write), + "/xrpc/com.atproto.space.registerNotify", + post(space_handlers::register_notify), ) + // Inbound notifyWrite (service auth; contentless { space, repo, rev }). .route( - "/xrpc/com.atproto.space.notifyMembership", - post(space_handlers::notify_membership), - ) - // Spaces export/import. - .route( - "/xrpc/com.atproto.space.exportSpaces", - get(space_handlers::export_spaces), + "/xrpc/com.atproto.space.notifyWrite", + post(space_handlers::notify_write), ) + // Space-deletion lifecycle (service auth). .route( - "/xrpc/com.atproto.space.importSpaces", - post(space_handlers::import_spaces), + "/xrpc/com.atproto.space.notifySpaceDeleted", + post(space_handlers::notify_space_deleted), ) // com.atproto.admin.* .route( diff --git a/crates/atproto-pds/src/http/space_auth.rs b/crates/atproto-pds/src/http/space_auth.rs index d2c4765..9ece3f5 100644 --- a/crates/atproto-pds/src/http/space_auth.rs +++ b/crates/atproto-pds/src/http/space_auth.rs @@ -4,35 +4,31 @@ //! 1. **OAuth bearer** — same DPoP-bound HS256 token used for the public //! realm. Verified by [`crate::oauth::token::verify_oauth_jwt`]. Used for //! own-PDS reads and writes. -//! 2. **MemberGrant JWT** (`typ=space_member_grant`) — passed to -//! `getSpaceCredential`. Signed by the member's atproto signing key. -//! 3. **SpaceCredential JWT** (`typ=space_credential`) — passed to +//! 2. **Delegation token** (`typ=atproto-space-delegation+jwt`) — presented in +//! the `Authorization: Bearer` header to `getSpaceCredential`. Signed by the +//! member's atproto signing key (header `kid="#atproto"`). +//! 3. **SpaceCredential JWT** (`typ=atproto-space-credential+jwt`) — passed to //! `getRecord`/`listRecords`/`getRepoState`/etc. by remote consumers. -//! Signed by the space owner's atproto signing key. +//! Signed by the space authority's `#atproto_space` signing key. //! -//! the design (§15.7), MemberGrant verification at the owner's PDS -//! requires resolving the member's DID document to obtain their signing -//! key. The **same-PDS** case (the member is also a locally-managed -//! account on this PDS) looks up the signing key directly via the -//! `AccountManager` + `KeyStore`. Cross-PDS resolution through -//! `atproto-identity` is wired — the resolver picks the path automatically based on -//! whether the DID is locally managed. +//! Delegation-token verification at the authority's PDS requires resolving the +//! member's DID document to obtain their `#atproto` signing key. The +//! **same-PDS** case (the member is also a locally-managed account on this PDS) +//! looks up the signing key directly via the `AccountManager` + `KeyStore`. +//! Cross-PDS resolution through `atproto-identity` is wired — the resolver +//! picks the path automatically based on whether the DID is locally managed. use crate::account::AccountManager; use crate::http::errors::XrpcError; -use crate::security::JtiReplayGuard; use atproto_identity::key::{KeyData, to_public}; use atproto_space::credential::{ - LXM_GET_SPACE_CREDENTIAL, MemberGrant, TYP_MEMBER_GRANT, TYP_SPACE_CREDENTIAL, - verify_member_grant, + DelegationToken, TYP_DELEGATION_TOKEN, TYP_SPACE_CREDENTIAL, verify_delegation_token, }; use atproto_space::types::SpaceUri; use axum::http::StatusCode; use base64::{Engine as _, engine::general_purpose}; use serde::Deserialize; -use sha2::{Digest, Sha256}; use std::sync::Arc; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// JWT typ discriminator inspection. #[derive(Debug, Deserialize)] @@ -55,9 +51,9 @@ pub fn classify_token_typ(token: &str) -> Option { /// Recognised Spaces token shapes. #[derive(Debug, PartialEq, Eq)] pub enum SpaceTokenKind { - /// `space_member_grant`. - MemberGrant, - /// `space_credential`. + /// `atproto-space-delegation+jwt`. + DelegationToken, + /// `atproto-space-credential+jwt`. SpaceCredential, /// Anything else (likely an OAuth access token). Other(String), @@ -68,7 +64,7 @@ pub enum SpaceTokenKind { pub fn classify(token: &str) -> Option { let typ = classify_token_typ(token)?; Some(match typ.as_str() { - TYP_MEMBER_GRANT => SpaceTokenKind::MemberGrant, + TYP_DELEGATION_TOKEN => SpaceTokenKind::DelegationToken, TYP_SPACE_CREDENTIAL => SpaceTokenKind::SpaceCredential, _ => SpaceTokenKind::Other(typ), }) @@ -107,7 +103,7 @@ pub async fn local_signing_key(accounts: &AccountManager, did: &str) -> Result Result { let private = local_signing_key(accounts, did).await?; to_public(&private).map_err(|e| { @@ -119,36 +115,18 @@ pub async fn local_public_key(accounts: &AccountManager, did: &str) -> Result, - jti_guard: &JtiReplayGuard, - grant_jwt: &str, - expected_owner_did: &str, - expected_space: &SpaceUri, - expected_client_id: &str, -) -> Result { - // Peek the issuer claim without verifying signature, so we know which - // member's key to fetch. We re-verify with the proper key below. +/// Returns 400 `InvalidToken` when the token is structurally malformed. +pub fn peek_delegation_token(grant_jwt: &str) -> Result { let payload_b64 = grant_jwt.split('.').nth(1).ok_or_else(|| { XrpcError::new( StatusCode::BAD_REQUEST, "InvalidToken", - "MemberGrant: missing payload", + "delegation token: missing payload", ) })?; let payload_bytes = general_purpose::URL_SAFE_NO_PAD @@ -157,128 +135,85 @@ pub async fn verify_local_member_grant( XrpcError::new( StatusCode::BAD_REQUEST, "InvalidToken", - "MemberGrant: payload not base64url", + "delegation token: payload not base64url", ) })?; - let unverified: MemberGrant = serde_json::from_slice(&payload_bytes).map_err(|_| { + serde_json::from_slice(&payload_bytes).map_err(|_| { XrpcError::new( StatusCode::BAD_REQUEST, "InvalidToken", - "MemberGrant: payload not JSON", + "delegation token: payload not JSON", ) - })?; + }) +} - if unverified.lxm != LXM_GET_SPACE_CREDENTIAL { - return Err(XrpcError::new( - StatusCode::BAD_REQUEST, - "InvalidToken", - format!("MemberGrant lxm mismatch: {}", unverified.lxm), - )); - } +/// Verify a delegation token against a same-PDS member's signing key. +/// +/// Returns the decoded payload on success. Used by `getSpaceCredential`. +/// +/// # Errors +/// +/// - 400 `InvalidToken` if the token is unparseable / expired / claim mismatch. +/// - 404 `AccountNotFound` if the issuer (member) is not known on this PDS. +pub async fn verify_local_delegation_token( + accounts: &Arc, + grant_jwt: &str, + expected_authority_did: &str, + expected_space: &SpaceUri, +) -> Result { + // Peek the issuer claim without verifying signature, so we know which + // member's key to fetch. We re-verify with the proper key below. + let unverified = peek_delegation_token(grant_jwt)?; let member_pub = local_public_key(accounts, &unverified.iss).await?; - let payload = verify_member_grant( + let payload = verify_delegation_token( grant_jwt, - expected_owner_did, + expected_authority_did, expected_space, - expected_client_id, &member_pub, ) .map_err(|e| { XrpcError::new( StatusCode::FORBIDDEN, "InvalidToken", - format!("MemberGrant verification: {e}"), + format!("delegation token verification: {e}"), ) })?; - // Replay protection. Synthesize a JTI from the structural identity of the - // grant; record with TTL = remaining lifetime so the guard self-cleans. - let synthetic_jti = synthesize_member_grant_jti(&payload); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let ttl = Duration::from_secs(payload.exp.saturating_sub(now)); - jti_guard - .check_and_insert(&synthetic_jti, ttl) - .await - .map_err(|e| { - XrpcError::new( - StatusCode::CONFLICT, - "Replay", - format!("MemberGrant replay rejected: {e}"), - ) - })?; - Ok(payload) } -/// Verify a MemberGrant issued by a member on a *remote* PDS by resolving +/// Verify a delegation token issued by a member on a *remote* PDS by resolving /// their DID document for the atproto signing key. /// -/// The flow mirrors [`verify_local_member_grant`] but the public-key +/// The flow mirrors [`verify_local_delegation_token`] but the public-key /// lookup goes through `atproto-identity` instead of the local /// `AccountManager`: /// -/// 1. Peek the JWT payload to learn the issuer DID + claim shape. +/// 1. Peek the JWT payload to learn the issuer DID. /// 2. Resolve the issuer's DID document via the configured PLC directory /// (for `did:plc`) or `.well-known/did.json` (for `did:web`). /// 3. Find the verification method whose id ends in `#atproto`; decode the /// multibase public key into a `KeyData`. -/// 4. Re-verify the JWT against that key, plus the same JTI replay guard -/// used on the local path. +/// 4. Re-verify the JWT against that key. /// /// `plc_directory_hostname` is the configured PLC directory (e.g. /// `plc.directory`); pass `None` to use the upstream default. /// /// # Errors /// -/// - 400 `InvalidToken` — grant unparseable / wrong `lxm` / claim mismatch. +/// - 400 `InvalidToken` — token unparseable / claim mismatch. /// - 401 `AuthenticationRequired` — DID document doesn't resolve, or /// doesn't carry an `#atproto` verification method. /// - 403 `InvalidToken` — signature verification failed. -/// - 409 `Replay` — the same grant has already been exchanged. -pub async fn verify_remote_member_grant( +pub async fn verify_remote_delegation_token( http: &reqwest::Client, - jti_guard: &JtiReplayGuard, grant_jwt: &str, - expected_owner_did: &str, + expected_authority_did: &str, expected_space: &SpaceUri, - expected_client_id: &str, plc_directory_hostname: Option<&str>, -) -> Result { - let payload_b64 = grant_jwt.split('.').nth(1).ok_or_else(|| { - XrpcError::new( - StatusCode::BAD_REQUEST, - "InvalidToken", - "MemberGrant: missing payload", - ) - })?; - let payload_bytes = general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64.as_bytes()) - .map_err(|_| { - XrpcError::new( - StatusCode::BAD_REQUEST, - "InvalidToken", - "MemberGrant: payload not base64url", - ) - })?; - let unverified: MemberGrant = serde_json::from_slice(&payload_bytes).map_err(|_| { - XrpcError::new( - StatusCode::BAD_REQUEST, - "InvalidToken", - "MemberGrant: payload not JSON", - ) - })?; - - if unverified.lxm != LXM_GET_SPACE_CREDENTIAL { - return Err(XrpcError::new( - StatusCode::BAD_REQUEST, - "InvalidToken", - format!("MemberGrant lxm mismatch: {}", unverified.lxm), - )); - } +) -> Result { + let unverified = peek_delegation_token(grant_jwt)?; let member_pub = remote_atproto_signing_key(http, &unverified.iss, plc_directory_hostname) .await @@ -289,107 +224,129 @@ pub async fn verify_remote_member_grant( format!("resolve member DID document for {}: {e}", unverified.iss), ) })?; - let payload = verify_member_grant( + let payload = verify_delegation_token( grant_jwt, - expected_owner_did, + expected_authority_did, expected_space, - expected_client_id, &member_pub, ) .map_err(|e| { XrpcError::new( StatusCode::FORBIDDEN, "InvalidToken", - format!("MemberGrant verification: {e}"), + format!("delegation token verification: {e}"), ) })?; - let synthetic_jti = synthesize_member_grant_jti(&payload); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let ttl = Duration::from_secs(payload.exp.saturating_sub(now)); - jti_guard - .check_and_insert(&synthetic_jti, ttl) - .await - .map_err(|e| { - XrpcError::new( - StatusCode::CONFLICT, - "Replay", - format!("MemberGrant replay rejected: {e}"), - ) - })?; - Ok(payload) } -/// Resolve a remote DID's atproto signing key (the `#atproto` verification -/// method's `publicKeyMultibase`). Returns `KeyData` ready for -/// `verify_member_grant` etc. -async fn remote_atproto_signing_key( +/// Fetch a `did:plc:`/`did:web:` DID document for remote verification. +async fn fetch_remote_document( http: &reqwest::Client, did: &str, plc_directory_hostname: Option<&str>, -) -> anyhow::Result { - use atproto_identity::key::identify_key; - use atproto_identity::model::VerificationMethod; +) -> anyhow::Result { use atproto_identity::plc::query as plc_query; use atproto_identity::web::query as web_query; - let document = if did.starts_with("did:plc:") { + if did.starts_with("did:plc:") { let host = plc_directory_hostname.unwrap_or("plc.directory"); - plc_query(http, host, did).await? + Ok(plc_query(http, host, did).await?) } else if did.starts_with("did:web:") { - web_query(http, did).await? + Ok(web_query(http, did).await?) } else { - anyhow::bail!("unsupported DID method for remote member-grant verification: {did}"); - }; - let mut atproto_pub: Option = None; - for method in &document.verification_method { - if let VerificationMethod::Multikey { - id, - public_key_multibase, - .. - } = method - { - // Match either `#atproto` (relative) or - // `did:plc:xxx#atproto` (absolute). Spec allows either form. - if id.ends_with("#atproto") { - atproto_pub = Some(public_key_multibase.clone()); - break; - } - } + anyhow::bail!("unsupported DID method for remote space verification: {did}") } - let mb = atproto_pub.ok_or_else(|| { - anyhow::anyhow!("DID document has no #atproto Multikey verification method") - })?; +} + +/// Turn a multibase / `did:key:` value into `KeyData`. +fn multibase_to_key(mb: &str) -> anyhow::Result { + use atproto_identity::key::identify_key; // `identify_key` accepts either a bare multibase value or a `did:key:` // wrapper. Normalize so the call site is robust to either form. let did_key = if mb.starts_with("did:key:") { - mb + mb.to_string() } else { - format!("did:key:{}", mb) + format!("did:key:{mb}") }; Ok(identify_key(&did_key)?) } -/// Build a deterministic JTI for a MemberGrant payload. We hash the load-bearing -/// claims so two grants with the same `(iss, iat, lxm, space, clientId)` -/// collide (replay) but distinct issuances do not. -fn synthesize_member_grant_jti(grant: &MemberGrant) -> String { - let mut hasher = Sha256::new(); - hasher.update(b"mg:"); - hasher.update(grant.iss.as_bytes()); - hasher.update(b"|"); - hasher.update(grant.iat.to_be_bytes()); - hasher.update(b"|"); - hasher.update(grant.lxm.as_bytes()); - hasher.update(b"|"); - hasher.update(grant.space.as_bytes()); - hasher.update(b"|"); - hasher.update(grant.client_id.as_bytes()); - let digest = hasher.finalize(); - hex::encode(digest) +/// Resolve a remote DID's atproto signing key (the `#atproto` verification +/// method's `publicKeyMultibase`). Returns `KeyData` ready for +/// `verify_delegation_token` etc. The delegation token's `kid` is `#atproto` +/// per 0016 line 162, so this resolves the member's public-data signing key. +async fn remote_atproto_signing_key( + http: &reqwest::Client, + did: &str, + plc_directory_hostname: Option<&str>, +) -> anyhow::Result { + let document = fetch_remote_document(http, did, plc_directory_hostname).await?; + let mb = document + .verification_method_multibase("atproto") + .ok_or_else(|| { + anyhow::anyhow!("DID document has no #atproto Multikey verification method") + })?; + multibase_to_key(mb) +} + +/// Resolve a remote space authority's credential-verification key. +/// +/// Per 0016 §"Space authority" (lines 87-92) the authority DID exposes the +/// credential-signing public key as the `#atproto_space` verification method, +/// which MAY coincide in value with `#atproto` (line 92). This prefers the +/// dedicated `#atproto_space` entry and falls back to `#atproto` for authority +/// DID documents that exercise the coincidence allowance without publishing a +/// distinct entry. +pub async fn remote_space_credential_key( + http: &reqwest::Client, + authority_did: &str, + plc_directory_hostname: Option<&str>, +) -> anyhow::Result { + let document = fetch_remote_document(http, authority_did, plc_directory_hostname).await?; + let mb = space_credential_multibase(&document).ok_or_else(|| { + anyhow::anyhow!( + "authority DID document has no #atproto_space or #atproto Multikey verification method" + ) + })?; + multibase_to_key(mb) +} + +/// Select the authority's credential-verification public key from a DID +/// document: prefer `#atproto_space`, fall back to `#atproto` (0016 line 92). +fn space_credential_multibase(document: &atproto_identity::model::Document) -> Option<&str> { + document + .verification_method_multibase("atproto_space") + .or_else(|| document.verification_method_multibase("atproto")) +} + +/// Resolve a remote space authority's host endpoint. +/// +/// Per 0016 §"Space authority" (lines 87-92) the authority DID exposes its host +/// as the `#atproto_space_host` service, which MAY coincide with `#atproto_pds` +/// (line 92). This prefers the dedicated `#atproto_space_host` entry and falls +/// back to `#atproto_pds`. +pub async fn remote_space_host_endpoint( + http: &reqwest::Client, + authority_did: &str, + plc_directory_hostname: Option<&str>, +) -> anyhow::Result { + let document = fetch_remote_document(http, authority_did, plc_directory_hostname).await?; + space_host_endpoint(&document) + .map(|s| s.to_string()) + .ok_or_else(|| { + anyhow::anyhow!( + "authority DID document has no #atproto_space_host or #atproto_pds service" + ) + }) +} + +/// Select the authority's host endpoint from a DID document: prefer +/// `#atproto_space_host`, fall back to `#atproto_pds` (0016 line 92). +fn space_host_endpoint(document: &atproto_identity::model::Document) -> Option<&str> { + document + .service_endpoint("atproto_space_host") + .or_else(|| document.service_endpoint("atproto_pds")) } #[cfg(test)] @@ -398,11 +355,80 @@ mod tests { use crate::account::{AccountDirectory, AccountManager, CreateAccountParams}; use crate::keys::{KeyStore, MemoryKeyStore}; use atproto_identity::key::KeyType; - use atproto_space::credential::{MEMBER_GRANT_TTL_SECS, create_member_grant}; + use atproto_identity::model::Document; + use atproto_space::credential::{DELEGATION_TOKEN_TTL_SECS, create_delegation_token}; use atproto_space::types::{SpaceKey, SpaceType}; use std::sync::Arc; use tempfile::TempDir; + fn doc_from(json: &str) -> Document { + serde_json::from_str(json).expect("DID document parses") + } + + #[test] + fn space_credential_key_prefers_atproto_space_vm() { + // 0016 lines 87-92: prefer the dedicated #atproto_space VM when present. + let doc = doc_from( + r##"{"id":"did:plc:authority", + "verificationMethod":[ + {"id":"#atproto","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zATPROTO"}, + {"id":"#atproto_space","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zSPACE"} + ]}"##, + ); + assert_eq!(space_credential_multibase(&doc), Some("zSPACE")); + } + + #[test] + fn space_credential_key_falls_back_to_atproto_vm() { + // Line 92 MAY-coincide: authorities that don't publish a distinct + // #atproto_space entry fall back to the #atproto signing key. + let doc = doc_from( + r##"{"id":"did:plc:authority", + "verificationMethod":[ + {"id":"#atproto","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zATPROTO"} + ]}"##, + ); + assert_eq!(space_credential_multibase(&doc), Some("zATPROTO")); + } + + #[test] + fn space_credential_key_absent_when_neither_present() { + let doc = doc_from(r##"{"id":"did:plc:authority","verificationMethod":[]}"##); + assert_eq!(space_credential_multibase(&doc), None); + } + + #[test] + fn space_host_prefers_atproto_space_host_service() { + // 0016 lines 87-92: prefer the dedicated #atproto_space_host service. + let doc = doc_from( + r##"{"id":"did:plc:authority", + "service":[ + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}, + {"id":"#atproto_space_host","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://host.example.com"} + ]}"##, + ); + assert_eq!(space_host_endpoint(&doc), Some("https://host.example.com")); + } + + #[test] + fn space_host_falls_back_to_atproto_pds_service() { + // Line 92 MAY-coincide: fall back to #atproto_pds when no dedicated + // host service is published. + let doc = doc_from( + r##"{"id":"did:plc:authority", + "service":[ + {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"} + ]}"##, + ); + assert_eq!(space_host_endpoint(&doc), Some("https://pds.example.com")); + } + + #[test] + fn space_host_absent_when_neither_present() { + let doc = doc_from(r##"{"id":"did:plc:authority","service":[]}"##); + assert_eq!(space_host_endpoint(&doc), None); + } + async fn fresh_manager(dir: &std::path::Path) -> Arc { let accounts_db = AccountDirectory::open(&dir.join("accounts.sqlite")) .await @@ -441,101 +467,57 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn classify_token_kinds() { // synthetic header-only jwt-shaped strings - let mg_header = general_purpose::URL_SAFE_NO_PAD - .encode(br#"{"alg":"ES256","typ":"space_member_grant"}"#); - let sc_header = - general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"space_credential"}"#); - let jwt_a = format!("{}.payload.sig", mg_header); + let dt_header = general_purpose::URL_SAFE_NO_PAD + .encode(br##"{"alg":"ES256","typ":"atproto-space-delegation+jwt","kid":"#atproto"}"##); + let sc_header = general_purpose::URL_SAFE_NO_PAD.encode( + br##"{"alg":"ES256","typ":"atproto-space-credential+jwt","kid":"#atproto_space"}"##, + ); + let jwt_a = format!("{}.payload.sig", dt_header); let jwt_b = format!("{}.payload.sig", sc_header); - assert_eq!(classify(&jwt_a), Some(SpaceTokenKind::MemberGrant)); + assert_eq!(classify(&jwt_a), Some(SpaceTokenKind::DelegationToken)); assert_eq!(classify(&jwt_b), Some(SpaceTokenKind::SpaceCredential)); } - fn fresh_jti_guard() -> JtiReplayGuard { - JtiReplayGuard::new(1024) - } - #[tokio::test(flavor = "multi_thread")] - async fn local_member_grant_round_trip() { + async fn local_delegation_token_round_trip() { let tmp = TempDir::new().unwrap(); let manager = fresh_manager(tmp.path()).await; let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap(); let space = test_space(); - let grant = create_member_grant( + let grant = create_delegation_token( "did:plc:alice", - "did:plc:owner", &space, - "https://app.example/client-metadata.json", &alice_priv, - MEMBER_GRANT_TTL_SECS, + DELEGATION_TOKEN_TTL_SECS, ) .unwrap(); - let guard = fresh_jti_guard(); - let payload = verify_local_member_grant( - &manager, - &guard, - &grant, - "did:plc:owner", - &space, - "https://app.example/client-metadata.json", - ) - .await - .unwrap(); - assert_eq!(payload.iss, "did:plc:alice"); - } - - #[tokio::test(flavor = "multi_thread")] - async fn member_grant_replay_rejected() { - let tmp = TempDir::new().unwrap(); - let manager = fresh_manager(tmp.path()).await; - let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap(); - - let space = test_space(); - let grant = create_member_grant( - "did:plc:alice", - "did:plc:owner", - &space, - "client", - &alice_priv, - MEMBER_GRANT_TTL_SECS, - ) - .unwrap(); - - let guard = fresh_jti_guard(); - - // First exchange: succeeds. - verify_local_member_grant(&manager, &guard, &grant, "did:plc:owner", &space, "client") + let payload = verify_local_delegation_token(&manager, &grant, "did:plc:owner", &space) .await .unwrap(); - - // Second exchange of the same grant: rejected as a replay. - let result = - verify_local_member_grant(&manager, &guard, &grant, "did:plc:owner", &space, "client") - .await; - assert!(result.is_err(), "second exchange should be rejected"); + assert_eq!(payload.iss, "did:plc:alice"); } #[tokio::test(flavor = "multi_thread")] - async fn member_grant_unknown_issuer_rejected() { + async fn delegation_token_unknown_issuer_rejected() { let tmp = TempDir::new().unwrap(); let manager = fresh_manager(tmp.path()).await; let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap(); let space = test_space(); // Issue with a *different* iss claim — verify lookup of the unknown - // issuer fails. We hand-craft the payload to bypass create_member_grant. + // issuer fails. We hand-craft the payload to bypass + // create_delegation_token. let header = general_purpose::URL_SAFE_NO_PAD - .encode(br#"{"alg":"ES256K","typ":"space_member_grant"}"#); + .encode(br##"{"alg":"ES256K","typ":"atproto-space-delegation+jwt","kid":"#atproto"}"##); let bad_payload = serde_json::to_vec(&serde_json::json!({ "iss": "did:plc:nobody", - "aud": "did:plc:owner", - "space": space.to_string(), - "clientId": "client", - "lxm": "com.atproto.space.getSpaceCredential", + "aud": "did:plc:owner#atproto_space_host", + "sub": space.to_string(), "iat": 1_700_000_000, "exp": 9_999_999_999u64, + "jti": "nonce", })) .unwrap(); let payload_b64 = general_purpose::URL_SAFE_NO_PAD.encode(&bad_payload); @@ -547,10 +529,7 @@ mod tests { general_purpose::URL_SAFE_NO_PAD.encode(&sig) ); - let guard = fresh_jti_guard(); - let result = - verify_local_member_grant(&manager, &guard, &token, "did:plc:owner", &space, "client") - .await; + let result = verify_local_delegation_token(&manager, &token, "did:plc:owner", &space).await; assert!(result.is_err()); } } diff --git a/crates/atproto-pds/src/http/space_handlers.rs b/crates/atproto-pds/src/http/space_handlers.rs index 91179c5..e2c7e0e 100644 --- a/crates/atproto-pds/src/http/space_handlers.rs +++ b/crates/atproto-pds/src/http/space_handlers.rs @@ -1,37 +1,40 @@ -//! XRPC HTTP handlers for `com.atproto.space.*`. +//! XRPC HTTP handlers for `com.atproto.space.*` and `com.atproto.simplespace.*`. //! -//! Surface (): +//! Surface: //! -//! Management (owner-only, OAuth): -//! - `POST /xrpc/com.atproto.space.createSpace` +//! Simplespace management (owner-only, OAuth): +//! - `POST /xrpc/com.atproto.simplespace.createSpace` +//! - `POST /xrpc/com.atproto.simplespace.addMember` +//! - `POST /xrpc/com.atproto.simplespace.removeMember` +//! - `GET /xrpc/com.atproto.simplespace.listMembers` +//! +//! Space reads (OAuth): //! - `GET /xrpc/com.atproto.space.getSpace` //! - `GET /xrpc/com.atproto.space.listSpaces` -//! - `POST /xrpc/com.atproto.space.addMember` -//! - `POST /xrpc/com.atproto.space.removeMember` -//! - `GET /xrpc/com.atproto.space.getMembers` //! //! Records (member-OAuth or remote SpaceCredential): //! - `POST /xrpc/com.atproto.space.applyWrites` +//! - `POST /xrpc/com.atproto.space.createRecord` +//! - `POST /xrpc/com.atproto.space.putRecord` +//! - `POST /xrpc/com.atproto.space.deleteRecord` //! - `GET /xrpc/com.atproto.space.getRecord` //! - `GET /xrpc/com.atproto.space.listRecords` //! //! Sync (read-only state + oplog): //! - `GET /xrpc/com.atproto.space.getRepoState` -//! - `GET /xrpc/com.atproto.space.getRepoOplog` -//! - `GET /xrpc/com.atproto.space.getMemberState` -//! - `GET /xrpc/com.atproto.space.getMemberOplog` +//! - `GET /xrpc/com.atproto.space.listRepoOps` //! //! Credentials (member + owner two-step flow): -//! - `POST /xrpc/com.atproto.space.getMemberGrant` (member-OAuth) +//! - `GET /xrpc/com.atproto.space.getDelegationToken` (member-OAuth) //! - `POST /xrpc/com.atproto.space.getSpaceCredential` (no auth — grant *is* the auth) use crate::account::AccountManager; use crate::actor_store::sql::SqlActorStore; -use crate::errors::PdsError; -use crate::http::auth::{bearer_token, request_htm_htu, require_authn_sub}; +use crate::http::auth::{bearer_token, request_htm_htu, require_authn, require_authn_sub}; use crate::http::errors::XrpcError; use crate::http::space_auth::{ - SpaceTokenKind, classify, local_signing_key, verify_local_member_grant, + SpaceTokenKind, classify, local_signing_key, peek_delegation_token, + verify_local_delegation_token, }; use crate::http::state::HttpState; use crate::space::notify::upsert_recipient; @@ -39,8 +42,9 @@ use crate::space::reader::SpaceReadAuth; use crate::space::writer::{SpaceCommitResult, SpaceWriteAction, SpaceWriteOp}; use crate::space::{SpaceReader, SpaceService, SpaceSync, SpaceWriter}; use atproto_space::credential::{ - MEMBER_GRANT_TTL_SECS, create_member_grant, create_space_credential, + DELEGATION_TOKEN_TTL_SECS, create_delegation_token, create_space_credential, }; +use atproto_space::storage::OplogCursor; use atproto_space::types::SpaceUri; use axum::Json; use axum::extract::{Query, State}; @@ -122,66 +126,312 @@ async fn require_session_subject(parts: &Parts, state: &HttpState) -> Result Result { + let (htm, htu) = request_htm_htu(parts); + require_authn(parts, state, &htm, &htu).await +} + // --------------------------------------------------------------------------- // Management endpoints. // --------------------------------------------------------------------------- -/// Inputs for `createSpace`. +/// Inputs for `com.atproto.simplespace.createSpace`. +/// +/// Matches the authoritative lexicon: `{did, type, skey?, config?}`. `did` +/// is the DID of the space authority — it defaults to the authenticated +/// caller and, if supplied, must equal the caller. `skey` auto-generates a +/// TID when absent. `config` carries the initial `#spaceConfig`. #[derive(Debug, Deserialize)] pub struct CreateSpaceInput { + /// DID of the space (the authority). Defaults to the caller. + pub did: Option, /// NSID space type (e.g., `app.bsky.group`). - #[serde(rename = "spaceType")] + #[serde(rename = "type")] pub space_type: String, - /// Caller-chosen key. - #[serde(rename = "spaceKey")] - pub space_key: String, + /// Space key. Auto-generated as a TID when omitted. + pub skey: Option, + /// Initial space configuration (`com.atproto.simplespace.defs#spaceConfig`). + pub config: Option, +} + +/// Output of `createSpace`: `{uri}`. +#[derive(Debug, Serialize)] +pub struct CreateSpaceResponse { + /// URI of the created space. + pub uri: String, } -/// Output of `createSpace` / `getSpace`. +/// `getSpace` output (`{uri, config}`). +pub use crate::space::GetSpaceOutput; +/// Internal view of a space (re-exported for `listSpaces`). pub use crate::space::SpaceInfo; -/// `POST /xrpc/com.atproto.space.createSpace`. +/// `POST /xrpc/com.atproto.simplespace.createSpace`. pub async fn create_space( State(state): State, parts: Parts, Json(input): Json, -) -> Result, XrpcError> { - let owner_did = require_session_subject(&parts, &state).await?; +) -> Result, XrpcError> { + let subject = require_session_auth(&parts, &state).await?; + let caller = subject.sub().to_string(); + // The space authority defaults to the caller; an explicit `did` must + // match (callers may only create spaces under their own authority). + let authority_did = match input.did { + Some(ref d) if d != &caller => { + return Err(XrpcError::new( + StatusCode::FORBIDDEN, + "NotSpaceOwner", + "space did must equal the authenticated caller", + )); + } + Some(d) => d, + None => caller, + }; + let skey = input + .skey + .unwrap_or_else(|| atproto_record::tid::Tid::new().to_string()); + // OAuth `space:` scope gate (manage). Build the target URI from the + // resolved authority/type/skey; no-op for app-password sessions. + let scope_uri = parse_space_uri(&format!( + "{}{}/{}/{}", + atproto_space::types::ATS_SCHEME, + authority_did, + input.space_type, + skey + ))?; + assert_space_manage( + &subject, + &scope_uri, + atproto_oauth::scopes::SpaceManageVerb::Create, + )?; + let config = match input.config { + Some(ref v) => crate::space::SpaceConfig::from_create_input(v).map_err(XrpcError::from)?, + None => crate::space::SpaceConfig::default(), + }; let svc = space_service(&state)?; let info = svc - .create_space(&owner_did, &input.space_type, &input.space_key) + .create_space(&authority_did, &input.space_type, &skey, config) + .await + .map_err(XrpcError::from)?; + Ok(Json(CreateSpaceResponse { uri: info.uri })) +} + +/// Inputs for `com.atproto.simplespace.updateSpace`. +#[derive(Debug, Deserialize)] +pub struct UpdateSpaceInput { + /// Space URI to update. + pub space: String, + /// New mint policy, if provided. + #[serde(rename = "mintPolicy")] + pub mint_policy: Option, + /// New managing-app identifier. Empty string clears to NULL. + #[serde(rename = "managingApp")] + pub managing_app: Option, + /// New app-access union, if provided (replaces wholesale). + #[serde(rename = "appAccess")] + pub app_access: Option, +} + +/// `POST /xrpc/com.atproto.simplespace.updateSpace`. +pub async fn update_space( + State(state): State, + parts: Parts, + Json(input): Json, +) -> Result { + let subject = require_session_auth(&parts, &state).await?; + let owner = subject.sub().to_string(); + let uri = parse_space_uri(&input.space)?; + assert_space_manage( + &subject, + &uri, + atproto_oauth::scopes::SpaceManageVerb::Update, + )?; + // Reassemble the config-field object the patch parser expects. + let mut obj = serde_json::Map::new(); + if let Some(p) = input.mint_policy { + obj.insert("mintPolicy".to_string(), serde_json::Value::String(p)); + } + if let Some(a) = input.managing_app { + obj.insert("managingApp".to_string(), serde_json::Value::String(a)); + } + if let Some(v) = input.app_access { + obj.insert("appAccess".to_string(), v); + } + let patch = crate::space::SpaceConfigPatch::from_update_input(&serde_json::Value::Object(obj)) + .map_err(XrpcError::from)?; + space_service(&state)? + .update_space(&owner, &uri, patch) + .await + .map_err(XrpcError::from)?; + Ok(StatusCode::OK) +} + +/// Inputs for `com.atproto.simplespace.deleteSpace`. +#[derive(Debug, Deserialize)] +pub struct DeleteSpaceInput { + /// Space URI to tombstone. + pub space: String, +} + +/// `POST /xrpc/com.atproto.simplespace.deleteSpace`. +pub async fn delete_space( + State(state): State, + parts: Parts, + Json(input): Json, +) -> Result { + let subject = require_session_auth(&parts, &state).await?; + let owner = subject.sub().to_string(); + let uri = parse_space_uri(&input.space)?; + assert_space_manage( + &subject, + &uri, + atproto_oauth::scopes::SpaceManageVerb::Delete, + )?; + space_service(&state)? + .delete_space(&owner, &uri) .await .map_err(XrpcError::from)?; - Ok(Json(info)) + + // Best-effort: notify registered recipients + members that the space was + // deleted (com.atproto.space.notifySpaceDeleted). Failures are swallowed — + // the tombstone is already durable. + fire_notify_space_deleted(&state, &uri, &owner).await; + + Ok(StatusCode::OK) +} + +/// Best-effort fan-out of `notifySpaceDeleted` to every registered recipient +/// and member of `uri` after the authority deletes the space. Resolves each +/// target's PDS endpoint, mints a service-auth token (iss = authority, aud = +/// target), and POSTs. All errors are logged and swallowed. +async fn fire_notify_space_deleted(state: &HttpState, uri: &SpaceUri, authority_did: &str) { + let Ok(manager) = account_manager(state) else { + return; + }; + let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(crate::user_agent()) + .build() + .unwrap_or_default(); + + // Owner signing key to mint the outbound service-auth tokens. + let signing_key = match crate::http::space_auth::local_signing_key(manager, authority_did).await + { + Ok(k) => k, + Err(e) => { + tracing::warn!(error = ?e, space = %uri, "notifySpaceDeleted: owner signing key unavailable; skipping fan-out"); + return; + } + }; + + // Open the owner's per-actor store to read recipients + members. + let owner_store = match SqlActorStore::open(manager.data_dir(), authority_did).await { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = ?e, space = %uri, "notifySpaceDeleted: owner store unavailable; skipping fan-out"); + return; + } + }; + + // Collect distinct target DIDs: recipient services + members. + let mut targets: std::collections::BTreeSet = std::collections::BTreeSet::new(); + if let Ok(rows) = sqlx::query_as::<_, (String,)>( + "SELECT DISTINCT service_did FROM space_credential_recipient WHERE space = ?", + ) + .bind(uri.to_string()) + .fetch_all(owner_store.pool()) + .await + { + for (did,) in rows { + targets.insert(did); + } + } + if let Ok(rows) = sqlx::query_as::<_, (String,)>("SELECT did FROM space_member WHERE space = ?") + .bind(uri.to_string()) + .fetch_all(owner_store.pool()) + .await + { + for (did,) in rows { + targets.insert(did); + } + } + targets.remove(authority_did); + + for target in targets { + if !target.starts_with("did:") { + continue; + } + let endpoint = match crate::space::recipient::resolve_service_endpoint( + &http, + &format!("{target}#atproto_pds"), + plc_dir, + ) + .await + { + Ok(Some(ep)) => ep, + _ => continue, + }; + let token = match crate::space::service_auth::mint_service_auth( + &signing_key, + authority_did, + &target, + "com.atproto.space.notifySpaceDeleted", + crate::space::service_auth::NOTIFY_SERVICE_AUTH_TTL_SECS, + ) { + Ok(t) => t, + Err(_) => continue, + }; + let url = format!( + "{}/xrpc/com.atproto.space.notifySpaceDeleted", + endpoint.trim_end_matches('/') + ); + let body = serde_json::json!({ "space": uri.to_string() }); + let _ = http.post(&url).bearer_auth(&token).json(&body).send().await; + } } /// Query params for `getSpace`. #[derive(Debug, Deserialize)] pub struct GetSpaceQuery { - /// Full `ats://...` URI of the space. + /// Full space URI. pub space: String, } /// `GET /xrpc/com.atproto.space.getSpace`. +/// +/// A **host** query authorized by a **space credential** (spec XRPC table line +/// 481). A space credential confers whole-space read access, so this accepts +/// either a space credential or a covering OAuth `read` scope, mirroring the +/// other host/repo read methods. The `read` scope is whole-space and so is not +/// collection-constrained. pub async fn get_space( State(state): State, parts: Parts, Query(q): Query, -) -> Result, XrpcError> { - let viewer = require_session_subject(&parts, &state).await?; +) -> Result, XrpcError> { let uri = parse_space_uri(&q.space)?; + let subject = require_any_authn(&parts, &state, &uri).await?; + assert_space_read_opt(&state, &subject, &uri).await?; + // The space authority hosts the space config; describe from the authority's + // store regardless of which member's credential authorized the read. + let viewer = match &subject { + Some(s) => s.sub().to_string(), + None => uri.space_did.clone(), + }; let svc = space_service(&state)?; - svc.get_space(&viewer, &uri) + let out = svc + .get_space(&viewer, &uri) .await - .map_err(XrpcError::from)? - .map(Json) - .ok_or_else(|| { - XrpcError::new( - StatusCode::NOT_FOUND, - "SpaceNotFound", - format!("no such space {uri}"), - ) - }) + .map_err(XrpcError::from)?; + Ok(Json(out)) } /// Query params for `listSpaces`. @@ -236,80 +486,49 @@ pub struct MemberInput { pub did: String, } -/// `POST /xrpc/com.atproto.space.addMember`. +/// `POST /xrpc/com.atproto.simplespace.addMember`. pub async fn add_member( State(state): State, parts: Parts, Json(input): Json, ) -> Result { - let owner = require_session_subject(&parts, &state).await?; + let subject = require_session_auth(&parts, &state).await?; + let owner = subject.sub().to_string(); let uri = parse_space_uri(&input.space)?; + assert_space_manage( + &subject, + &uri, + atproto_oauth::scopes::SpaceManageVerb::Update, + )?; space_service(&state)? .add_member(&owner, &uri, &input.did) .await .map_err(XrpcError::from)?; - if state.notify_membership_email { - notify_membership_change(&state, &input.did, &uri, "added").await; - } Ok(StatusCode::OK) } -/// `POST /xrpc/com.atproto.space.removeMember`. +/// `POST /xrpc/com.atproto.simplespace.removeMember`. pub async fn remove_member( State(state): State, parts: Parts, Json(input): Json, ) -> Result { - let owner = require_session_subject(&parts, &state).await?; + let subject = require_session_auth(&parts, &state).await?; + let owner = subject.sub().to_string(); let uri = parse_space_uri(&input.space)?; + assert_space_manage( + &subject, + &uri, + atproto_oauth::scopes::SpaceManageVerb::Update, + )?; space_service(&state)? .remove_member(&owner, &uri, &input.did) .await .map_err(XrpcError::from)?; - if state.notify_membership_email { - notify_membership_change(&state, &input.did, &uri, "removed").await; - } Ok(StatusCode::OK) } -/// Best-effort: send a notification email to the affected member when -/// `PDS_NOTIFY_MEMBERSHIP_EMAIL` is enabled. Logs -/// continues on any failure (no email address, send error). -async fn notify_membership_change(state: &HttpState, member_did: &str, uri: &SpaceUri, verb: &str) { - // Lookup member's email — only send when the affected member is on - // this PDS and has a confirmed email. Cross-PDS members are skipped - // (we'd need a service-auth proxy to reach their PDS, out of scope). - let directory = state.reader.accounts(); - let row = match directory.lookup_did(member_did).await { - Ok(Some(r)) => r, - _ => { - tracing::debug!(member_did, "membership-email: member not local; skipping"); - return; - } - }; - let Some(email) = row.email else { - tracing::debug!( - member_did, - "membership-email: no email on account; skipping" - ); - return; - }; - let subject = match verb { - "added" => "You've been added to a Spaces group", - "removed" => "You've been removed from a Spaces group", - _ => "Spaces membership change", - }; - let body = format!( - "Your account ({member_did}) has been {verb} {} the Spaces group {uri}.\n\n\ - If you didn't expect this, contact the space owner via your client.", - if verb == "added" { "to" } else { "from" } - ); - if let Err(e) = state.email.send(&email, subject, &body).await { - tracing::warn!(error = ?e, member_did, "membership-email send failed"); - } -} - -/// Query params for `getMembers`. +/// Query params for `listMembers`. #[derive(Debug, Deserialize)] pub struct GetMembersQuery { /// Space URI. @@ -320,7 +539,7 @@ pub struct GetMembersQuery { pub limit: Option, } -/// Output of `getMembers`. +/// Output of `listMembers`. #[derive(Debug, Serialize)] pub struct GetMembersResponse { /// Member DIDs on this page. @@ -343,14 +562,23 @@ pub struct MemberRowDto { pub added_at: String, } -/// `GET /xrpc/com.atproto.space.getMembers`. +/// `GET /xrpc/com.atproto.simplespace.listMembers`. pub async fn get_members( State(state): State, parts: Parts, Query(q): Query, ) -> Result, XrpcError> { - let owner = require_session_subject(&parts, &state).await?; + let subject = require_session_auth(&parts, &state).await?; + let owner = subject.sub().to_string(); let uri = parse_space_uri(&q.space)?; + assert_space_scope( + &state, + &subject, + &uri, + atproto_oauth::scopes::SpaceAction::Read, + None, + ) + .await?; let page = space_service(&state)? .list_members(&owner, &uri, q.cursor.as_deref(), q.limit.unwrap_or(50)) .await @@ -405,7 +633,8 @@ pub async fn apply_writes( parts: Parts, Json(input): Json, ) -> Result, XrpcError> { - let member_did = require_session_subject(&parts, &state).await?; + let auth = require_session_auth(&parts, &state).await?; + let member_did = auth.sub().to_string(); let uri = parse_space_uri(&input.space)?; let writer = space_writer(&state)?; @@ -419,10 +648,19 @@ pub async fn apply_writes( let mut ops = Vec::with_capacity(input.writes.len()); for w in input.writes { - let action = match w.action.as_str() { - "create" => SpaceWriteAction::Create, - "update" => SpaceWriteAction::Update, - "delete" => SpaceWriteAction::Delete, + let (action, scope_action) = match w.action.as_str() { + "create" => ( + SpaceWriteAction::Create, + atproto_oauth::scopes::SpaceAction::Create, + ), + "update" => ( + SpaceWriteAction::Update, + atproto_oauth::scopes::SpaceAction::Update, + ), + "delete" => ( + SpaceWriteAction::Delete, + atproto_oauth::scopes::SpaceAction::Delete, + ), other => { return Err(XrpcError::new( StatusCode::BAD_REQUEST, @@ -431,6 +669,9 @@ pub async fn apply_writes( )); } }; + // OAuth `space:` scope gate — each op's action must be covered for + // its collection (no-op for app-password sessions). + assert_space_scope(&state, &auth, &uri, scope_action, Some(&w.collection)).await?; ops.push(SpaceWriteOp { action, collection: w.collection, @@ -446,6 +687,209 @@ pub async fn apply_writes( .map_err(XrpcError::from) } +// --------------------------------------------------------------------------- +// Single-op record writes: createRecord / putRecord / deleteRecord. +// +// Each is a thin wrapper over the SpaceWriter single-op path. The `repo` +// field names the DID being written to and MUST equal the authenticated +// subject — members write only to their own per-actor store. +// --------------------------------------------------------------------------- + +/// Output of `createRecord` / `putRecord`. +#[derive(Debug, Serialize)] +pub struct WriteRecordResponse { + /// Six-segment space-URI of the written record. + pub uri: String, + /// CID of the record value (DAG-CBOR). + pub cid: String, + /// Validation status when known. + #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] + pub validation_status: Option, +} + +/// Inputs for `createRecord`. +#[derive(Debug, Deserialize)] +pub struct CreateRecordInput { + /// Space URI. + pub space: String, + /// DID of the repo to write to (the authenticated member). + pub repo: String, + /// NSID collection. + pub collection: String, + /// Record key (optional — auto-TID when omitted). + pub rkey: Option, + /// Lexicon validation toggle (reserved; not yet enforced). + #[allow(dead_code)] + pub validate: Option, + /// Record value (must contain a `$type` field). + pub record: serde_json::Value, +} + +/// `POST /xrpc/com.atproto.space.createRecord`. +pub async fn create_record_write( + State(state): State, + parts: Parts, + Json(input): Json, +) -> Result, XrpcError> { + let auth = require_session_auth(&parts, &state).await?; + let subject = auth.sub().to_string(); + require_repo_matches_subject(&input.repo, &subject)?; + let uri = parse_space_uri(&input.space)?; + assert_space_scope( + &state, + &auth, + &uri, + atproto_oauth::scopes::SpaceAction::Create, + Some(&input.collection), + ) + .await?; + let writer = space_writer(&state)?; + let result = writer + .create_record( + &subject, + &uri, + input.collection, + input.rkey.unwrap_or_default(), + input.record, + ) + .await + .map_err(XrpcError::from)?; + Ok(Json(single_write_response(result)?)) +} + +/// Inputs for `putRecord`. +#[derive(Debug, Deserialize)] +pub struct PutRecordInput { + /// Space URI. + pub space: String, + /// DID of the repo to write to (the authenticated member). + pub repo: String, + /// NSID collection. + pub collection: String, + /// Record key. + pub rkey: String, + /// Lexicon validation toggle (reserved; not yet enforced). + #[allow(dead_code)] + pub validate: Option, + /// Record value. + pub record: serde_json::Value, +} + +/// `POST /xrpc/com.atproto.space.putRecord`. +pub async fn put_record_write( + State(state): State, + parts: Parts, + Json(input): Json, +) -> Result, XrpcError> { + let auth = require_session_auth(&parts, &state).await?; + let subject = auth.sub().to_string(); + require_repo_matches_subject(&input.repo, &subject)?; + let uri = parse_space_uri(&input.space)?; + // putRecord may either create or update the record, so it requires both + // the `create` and `update` actions per the 0016 OAuth-scope rules (spec + // lines 405-411), asserting both before the upsert. + assert_space_scope( + &state, + &auth, + &uri, + atproto_oauth::scopes::SpaceAction::Create, + Some(&input.collection), + ) + .await?; + assert_space_scope( + &state, + &auth, + &uri, + atproto_oauth::scopes::SpaceAction::Update, + Some(&input.collection), + ) + .await?; + let writer = space_writer(&state)?; + let result = writer + .put_record(&subject, &uri, input.collection, input.rkey, input.record) + .await + .map_err(XrpcError::from)?; + Ok(Json(single_write_response(result)?)) +} + +/// Inputs for `deleteRecord`. +#[derive(Debug, Deserialize)] +pub struct DeleteRecordInput { + /// Space URI. + pub space: String, + /// DID of the repo to delete from (the authenticated member). + pub repo: String, + /// NSID collection. + pub collection: String, + /// Record key. + pub rkey: String, +} + +/// `POST /xrpc/com.atproto.space.deleteRecord`. +pub async fn delete_record_write( + State(state): State, + parts: Parts, + Json(input): Json, +) -> Result, XrpcError> { + let auth = require_session_auth(&parts, &state).await?; + let subject = auth.sub().to_string(); + require_repo_matches_subject(&input.repo, &subject)?; + let uri = parse_space_uri(&input.space)?; + assert_space_scope( + &state, + &auth, + &uri, + atproto_oauth::scopes::SpaceAction::Delete, + Some(&input.collection), + ) + .await?; + let writer = space_writer(&state)?; + writer + .delete_record(&subject, &uri, input.collection, input.rkey) + .await + .map_err(XrpcError::from)?; + Ok(Json(serde_json::json!({}))) +} + +/// Enforce that the `repo` field of a record-write request names the +/// authenticated subject. Members may only write to their own per-actor +/// store. +fn require_repo_matches_subject(repo: &str, subject: &str) -> Result<(), XrpcError> { + if repo == subject { + Ok(()) + } else { + Err(XrpcError::new( + StatusCode::FORBIDDEN, + "InvalidRequest", + "repo must equal the authenticated subject", + )) + } +} + +/// Project a single-op [`SpaceCommitResult`] into a `createRecord` / +/// `putRecord` output `{uri, cid}`. +fn single_write_response(result: SpaceCommitResult) -> Result { + let uri = result.uris.into_iter().next().ok_or_else(|| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + "write produced no record URI", + ) + })?; + let cid = result.cids.into_iter().next().flatten().ok_or_else(|| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + "write produced no record CID", + ) + })?; + Ok(WriteRecordResponse { + uri, + cid, + validation_status: None, + }) +} + /// Query params for `getRecord`. #[derive(Debug, Deserialize)] pub struct GetSpaceRecordQuery { @@ -455,6 +899,10 @@ pub struct GetSpaceRecordQuery { pub collection: String, /// Record key. pub rkey: String, + /// DID of the member whose repo to read from. If omitted, defaults to + /// the authenticated subject (OAuth auth). Required when using + /// space-credential auth. + pub repo: Option, } /// Output of `getRecord`. @@ -475,9 +923,25 @@ pub async fn get_record( Query(q): Query, ) -> Result, XrpcError> { let uri = parse_space_uri(&q.space)?; - let auth = resolve_record_auth(&parts, &state).await?; + let resolved = resolve_record_auth(&parts, &state, q.repo.as_deref()).await?; + if let Some(subject) = &resolved.subject { + assert_space_record_read( + &state, + subject, + &uri, + &resolved.target_repo, + Some(&q.collection), + ) + .await?; + } let row = space_reader(&state)? - .get_record(&uri, auth, &q.collection, &q.rkey) + .get_record( + &uri, + resolved.auth, + &resolved.target_repo, + &q.collection, + &q.rkey, + ) .await .map_err(XrpcError::from)? .ok_or_else(|| { @@ -506,23 +970,31 @@ pub async fn get_record( pub struct ListSpaceRecordsQuery { /// Space URI. pub space: String, - /// NSID collection. - pub collection: String, - /// Cursor (last `rkey`). + /// NSID collection. When omitted, records are listed across every + /// collection in the space (one page per collection, no cross-collection + /// cursor). + pub collection: Option, + /// Cursor (last `rkey`). Ignored when `collection` is omitted. pub cursor: Option, /// Page size. pub limit: Option, + /// DID of the member whose repo to read from. If omitted, defaults to + /// the authenticated subject (OAuth auth). Required when using + /// space-credential auth. + pub repo: Option, } -/// One record in `listRecords`. +/// One record in `listRecords` — keys-only per +/// `com.atproto.space.listRecords#record` (`{collection, rkey, cid}`). Fetch +/// the value separately via `getRecord`. #[derive(Debug, Serialize)] pub struct SpaceRecordItem { - /// AT-URI. - pub uri: String, - /// CID. + /// NSID collection. + pub collection: String, + /// Record key. + pub rkey: String, + /// CID of the record value. pub cid: String, - /// Decoded value. - pub value: serde_json::Value, } /// Output of `listRecords`. @@ -542,93 +1014,115 @@ pub async fn list_records( Query(q): Query, ) -> Result, XrpcError> { let uri = parse_space_uri(&q.space)?; - let auth = resolve_record_auth(&parts, &state).await?; + let resolved = resolve_record_auth(&parts, &state, q.repo.as_deref()).await?; + if let Some(subject) = &resolved.subject { + // listRecords may span every collection in the repo (collection + // omitted). A `read_self` grant is collection-constrained, so a + // cross-collection list of the own repo requires a whole-space `read` + // grant; pass `None` to force the `read` path in that case. + assert_space_record_read( + &state, + subject, + &uri, + &resolved.target_repo, + q.collection.as_deref(), + ) + .await?; + } let page = space_reader(&state)? .list_records( &uri, - auth, - &q.collection, + resolved.auth, + &resolved.target_repo, + q.collection.as_deref(), q.cursor.as_deref(), q.limit.unwrap_or(50), ) .await .map_err(XrpcError::from)?; - let mut records = Vec::with_capacity(page.records.len()); - for r in page.records { - let value: serde_json::Value = atproto_dasl::from_slice(&r.value).map_err(|e| { - XrpcError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "InternalError", - format!("decode record value: {e}"), - ) - })?; - records.push(SpaceRecordItem { - uri: format!("{}/{}/{}", uri, r.collection, r.rkey), + let records: Vec = page + .records + .into_iter() + .map(|r| SpaceRecordItem { + collection: r.collection, + rkey: r.rkey, cid: r.cid, - value, - }); - } + }) + .collect(); Ok(Json(ListSpaceRecordsResponse { records, cursor: page.cursor, })) } +/// Resolved auth + read-target DID for a Spaces record read. +struct ResolvedRecordAuth<'a> { + auth: SpaceReadAuth<'a>, + target_repo: String, + /// The bearer subject when the request authenticated via a session/OAuth + /// access token. `None` for SpaceCredential auth, which pre-authorizes + /// whole-space read at the auth layer and skips the `space:` scope gate. + subject: Option, +} + /// Decide which auth flavor a record-read uses based on the bearer token's -/// `typ` header. Owns the borrow of `parts` so callers don't need separate -/// branches. +/// `typ` header, validate the `repo` parameter against the auth mode, and +/// return the resolved target DID. +/// +/// - **OAuth / session bearer** — `repo` may be omitted (defaults to the +/// authenticated subject) or supplied to read another member's per-actor +/// store on this PDS. +/// - **SpaceCredential** — `repo` is **required**; returns 400 +/// `InvalidRequest` when missing because a SpaceCredential is not bound +/// to any one member's repo. +/// - **Delegation token** — rejected; delegation tokens must be exchanged at +/// `getSpaceCredential` before being used to read records. async fn resolve_record_auth<'a>( parts: &'a Parts, state: &HttpState, -) -> Result, XrpcError> { + repo: Option<&str>, +) -> Result, XrpcError> { let raw = bearer_token(parts)?; match classify(raw) { - Some(SpaceTokenKind::SpaceCredential) => Ok(SpaceReadAuth::SpaceCredential { - token: raw, - // Issuer-binding simplification: bind to the issuer's - // `client_id` claim rather than enforcing an HTTP-layer - // expected client. The SpaceReader will re-verify the JWT - // including its `client_id` claim; we pass the same value - // here so that check is a no-op. The follow-up tracked in - // pulls the expected `client_id` from a - // DPoP/cnf binding on the peer access token wrapping this - // credential. - expected_client_id: extract_credential_client_id(raw) - .map(|s| Box::leak(s.into_boxed_str()) as &str) - .unwrap_or(""), - }), - Some(SpaceTokenKind::MemberGrant) => Err(XrpcError::new( + Some(SpaceTokenKind::SpaceCredential) => { + let repo = repo.ok_or_else(|| { + XrpcError::new( + StatusCode::BAD_REQUEST, + "InvalidRequest", + "repo is required for space credential auth", + ) + })?; + Ok(ResolvedRecordAuth { + auth: SpaceReadAuth::SpaceCredential { token: raw }, + target_repo: repo.to_string(), + subject: None, + }) + } + Some(SpaceTokenKind::DelegationToken) => Err(XrpcError::new( StatusCode::BAD_REQUEST, "InvalidToken", - "MemberGrant cannot be used to read records; exchange it at getSpaceCredential first", + "delegation token cannot be used to read records; exchange it at getSpaceCredential first", )), _ => { // Treat as a session-style or OAuth access token. The unified // helper transparently accepts both shapes and enforces DPoP // when an OAuth token carries a `cnf.jkt` thumbprint. let (htm, htu) = request_htm_htu(parts); - let sub = require_authn_sub(parts, state, &htm, &htu).await?; - let did_static: &'a str = Box::leak(sub.into_boxed_str()); - Ok(SpaceReadAuth::OwnPds { - account_did: did_static, + let subject = require_authn(parts, state, &htm, &htu).await?; + let sub = subject.sub().to_string(); + let did_static: &'a str = Box::leak(sub.clone().into_boxed_str()); + let target_repo = repo.map(|r| r.to_string()).unwrap_or(sub); + Ok(ResolvedRecordAuth { + auth: SpaceReadAuth::OwnPds { + account_did: did_static, + }, + target_repo, + subject: Some(subject), }) } } } -/// Best-effort extraction of `clientId` from a SpaceCredential JWT *without* -/// signature verification — used solely to forward the value into -/// `SpaceReadAuth::SpaceCredential::expected_client_id` so the reader's -/// re-verification accepts the same bound `client_id`. -fn extract_credential_client_id(token: &str) -> Option { - let payload_b64 = token.split('.').nth(1)?; - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64.as_bytes()) - .ok()?; - let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - Some(value.get("clientId")?.as_str()?.to_string()) -} - // --------------------------------------------------------------------------- // Sync endpoints. // --------------------------------------------------------------------------- @@ -638,319 +1132,520 @@ fn extract_credential_client_id(token: &str) -> Option { pub struct RepoStateQuery { /// Space URI. pub space: String, - /// Member DID whose record-state to fetch. - pub member: String, + /// DID of the account whose repo state to retrieve. + pub repo: String, } -/// Output of `getRepoState` / `getMemberState`. +/// JSON wire form of a signed commit (`com.atproto.space.defs#signedCommit`). +/// +/// The four byte fields are emitted in atproto's lex-data `bytes` form +/// (`{"$bytes": ""}`, standard alphabet, unpadded) rather than the +/// JSON array that [`atproto_space::Commit`]'s `serde_bytes` derive would +/// produce, so the wire shape matches the lexicon and the 0016 spec +/// `#signedCommit` field table (lines 307-316). #[derive(Debug, Serialize)] -pub struct StateResponse { - /// Hex-encoded SetHash digest. `null` if empty. - #[serde(rename = "setHash")] - pub set_hash: Option, - /// Latest rev (TID). `null` if empty. - pub rev: Option, +pub struct SignedCommitDto { + /// `sha256` of the LtHash state (32 bytes), as `{"$bytes": ...}`. + pub hash: BytesValue, + /// `HMAC-SHA256` over `hash`, as `{"$bytes": ...}`. + pub mac: BytesValue, + /// Per-commit fresh IKM (32 bytes), as `{"$bytes": ...}`. + pub ikm: BytesValue, + /// `sign(ctx)` over the commit context, as `{"$bytes": ...}`. + pub sig: BytesValue, + /// Commit revision (TID). + pub rev: String, } -/// `GET /xrpc/com.atproto.space.getRepoState`. +/// atproto lex-data `bytes` value — serializes as `{"$bytes": ""}` +/// (standard alphabet, unpadded). +#[derive(Debug)] +pub struct BytesValue(Vec); + +impl Serialize for BytesValue { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + let b64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&self.0); + let mut map = serializer.serialize_map(Some(1))?; + map.serialize_entry("$bytes", &b64)?; + map.end() + } +} + +impl SignedCommitDto { + /// Convert an [`atproto_space::Commit`] into its `$bytes`-encoded wire DTO. + fn from_commit(c: atproto_space::Commit) -> Self { + Self { + hash: BytesValue(c.hash), + mac: BytesValue(c.mac), + ikm: BytesValue(c.ikm), + sig: BytesValue(c.sig), + rev: c.rev, + } + } +} + +/// Output of `getRepoState`. +/// +/// `commit` is absent when the repo has never been written to, per +/// `com.atproto.space.getRepoState`. +#[derive(Debug, Serialize)] +pub struct StateResponse { + /// The current signed commit, or absent when empty. + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, +} + +/// Build a signed commit from a persisted SetHash state + rev. +/// +/// Rehydrates the [`PdsSetHash`](crate::realm::PdsSetHash) lattice from the +/// 2048-byte state persisted in [`RepoState`](atproto_space::RepoState), +/// derives the 32-byte commitment, and signs a [`SpaceContext`] (the full +/// `ats://` space URI + rev) with `signing_key`, per the 0016 Permissioned Data +/// draft (§ Commit signature). Returns `None` when the state is empty (no +/// commits yet). +fn signed_commit_from_state( + space: &SpaceUri, + state: &atproto_space::RepoState, + signing_key: &atproto_identity::key::KeyData, +) -> Result, XrpcError> { + use atproto_space::set_hash::SetHash; + let (Some(state_bytes), Some(rev)) = (state.set_hash.as_deref(), state.rev.as_deref()) else { + return Ok(None); + }; + let set_hash = crate::realm::PdsSetHash::from_state_bytes(state_bytes).map_err(|e| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + format!("rehydrate set hash: {e}"), + ) + })?; + let ctx = atproto_space::SpaceContext { + space: space.to_string(), + rev: rev.to_string(), + }; + let commit = atproto_space::create_commit(&set_hash, &ctx, signing_key).map_err(|e| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + format!("sign commit: {e}"), + ) + })?; + Ok(Some(SignedCommitDto::from_commit(commit))) +} + +/// `GET /xrpc/com.atproto.space.getRepoState`. +/// +/// Returns the repo account's current signed commit (`records` scope, signed +/// by the repo account's atproto signing key). `commit` is absent when the +/// repo is empty. pub async fn get_repo_state( State(state): State, parts: Parts, Query(q): Query, ) -> Result, XrpcError> { - require_any_authn(&parts, &state).await?; let uri = parse_space_uri(&q.space)?; + let subject = require_any_authn(&parts, &state, &uri).await?; + assert_space_read_opt(&state, &subject, &uri).await?; let st = space_sync(&state)? - .get_repo_state(&uri, &q.member) + .get_repo_state(&uri, &q.repo) .await .map_err(XrpcError::from)?; - Ok(Json(StateResponse { - set_hash: st.set_hash.as_deref().map(hex::encode), - rev: st.rev, - })) + let manager = account_manager(&state)?; + let signing_key = local_signing_key(manager, &q.repo).await?; + let commit = signed_commit_from_state(&uri, &st, &signing_key)?; + Ok(Json(StateResponse { commit })) } -/// Query params for `getRepoOplog`. +/// Query params for `listRepoOps`. #[derive(Debug, Deserialize)] pub struct RepoOplogQuery { /// Space URI. pub space: String, - /// Member DID. - pub member: String, - /// Rev to start *after* (exclusive). + /// DID of the account whose oplog to retrieve. + pub repo: String, + /// Opaque `(rev, idx)` cursor (`"__"`) to start *after* + /// (exclusive). Carries the last op delivered on the prior page so that an + /// atomic batch larger than `limit` is not skipped across paging. pub since: Option, /// Page size. pub limit: Option, } -/// One oplog entry in the wire form. +/// One records-oplog entry, wire shape per `com.atproto.space.listRepoOps#opEntry`. +/// +/// Exactly `{ rev, collection, rkey, cid, prev }`. `cid` is `null` for deletes; +/// `prev` is `null` for creates (both keys are always present, per the +/// lexicon's `nullable` set). #[derive(Debug, Serialize)] -pub struct OplogEntryDto { - /// Rev (TID). +pub struct RecordOpEntry { + /// Rev (TID). Ops sharing a rev belong to the same batch. pub rev: String, - /// Index within the batch. - pub idx: u32, - /// Action. - pub action: String, - /// NSID collection (records only). - #[serde(skip_serializing_if = "Option::is_none")] - pub collection: Option, - /// Record key (records only). - #[serde(skip_serializing_if = "Option::is_none")] - pub rkey: Option, - /// New CID (records only). - #[serde(skip_serializing_if = "Option::is_none")] + /// NSID collection. + pub collection: String, + /// Record key. + pub rkey: String, + /// New record CID; `null` for deletes. pub cid: Option, - /// Prior CID (records only). - #[serde(skip_serializing_if = "Option::is_none")] + /// Prior record CID; `null` for creates. pub prev: Option, - /// DID (members only). - #[serde(skip_serializing_if = "Option::is_none")] - pub did: Option, } -/// Output of `getRepoOplog` / `getMemberOplog`. +/// Output of `listRepoOps`. +/// +/// `commit` is included only when the page reaches the head of the oplog +/// (`ops.len() < limit`), so a caught-up consumer can verify the resulting +/// state; it is omitted on backfill responses. #[derive(Debug, Serialize)] -pub struct OplogResponse { +pub struct RepoOpsResponse { /// Oplog ops on this page (rev,idx ascending). - pub ops: Vec, - /// Current state at read time. - pub state: StateResponse, + pub ops: Vec, + /// The repo's current signed commit, when caught up. Absent on backfill + /// or when the repo is empty. + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, + /// Opaque `(rev, idx)` cursor for the next page (the last op on this page), + /// when more may remain. Encoded as `"__"` so a batch larger than + /// `limit` resumes within the batch rather than skipping its tail. + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, } -/// `GET /xrpc/com.atproto.space.getRepoOplog`. -pub async fn get_repo_oplog( +/// `GET /xrpc/com.atproto.space.listRepoOps`. +/// +/// Incremental sync for a per-account repo within a space. On a caught-up +/// page (fewer ops than `limit`), attaches the repo's current signed commit +/// (`records` scope, signed by the repo account's key). +pub async fn list_repo_ops( State(state): State, parts: Parts, Query(q): Query, -) -> Result, XrpcError> { - require_any_authn(&parts, &state).await?; +) -> Result, XrpcError> { let uri = parse_space_uri(&q.space)?; + let subject = require_any_authn(&parts, &state, &uri).await?; + assert_space_read_opt(&state, &subject, &uri).await?; + let limit = q.limit.unwrap_or(100); + let since = match q.since.as_deref() { + Some(token) => Some(OplogCursor::from_token(token).map_err(|_| { + XrpcError::new( + StatusCode::BAD_REQUEST, + "InvalidRequest", + "since cursor is malformed", + ) + })?), + None => None, + }; let page = space_sync(&state)? - .get_repo_oplog(&uri, &q.member, q.since.as_deref(), q.limit.unwrap_or(100)) + .list_repo_ops(&uri, &q.repo, since.as_ref(), limit) .await .map_err(XrpcError::from)?; - Ok(Json(oplog_to_dto(page))) -} -/// Query params for `getMemberState`. -#[derive(Debug, Deserialize)] -pub struct MemberStateQuery { - /// Space URI. - pub space: String, -} + let caught_up = (page.ops.len() as u32) < limit; + // Next-page cursor is the `(rev, idx)` of the last op on this page, so a + // batch larger than `limit` resumes within the batch on the next call. + let cursor = if caught_up { + None + } else { + page.ops + .last() + .map(|o| OplogCursor::new(o.rev.clone(), o.idx).to_token()) + }; + let ops: Vec = page + .ops + .into_iter() + .map(|o| RecordOpEntry { + rev: o.rev, + collection: o.collection.unwrap_or_default(), + rkey: o.rkey.unwrap_or_default(), + cid: o.cid, + prev: o.prev, + }) + .collect(); + + let commit = if caught_up { + let manager = account_manager(&state)?; + let signing_key = local_signing_key(manager, &q.repo).await?; + signed_commit_from_state(&uri, &page.state, &signing_key)? + } else { + None + }; -/// `GET /xrpc/com.atproto.space.getMemberState`. -pub async fn get_member_state( - State(state): State, - parts: Parts, - Query(q): Query, -) -> Result, XrpcError> { - require_any_authn(&parts, &state).await?; - let uri = parse_space_uri(&q.space)?; - let st = space_sync(&state)? - .get_member_state(&uri) - .await - .map_err(XrpcError::from)?; - Ok(Json(StateResponse { - set_hash: st.set_hash.as_deref().map(hex::encode), - rev: st.rev, + Ok(Json(RepoOpsResponse { + ops, + commit, + cursor, })) } -/// Query params for `getMemberOplog`. -#[derive(Debug, Deserialize)] -pub struct MemberOplogQuery { - /// Space URI. - pub space: String, - /// Rev to start after. - pub since: Option, - /// Page size. - pub limit: Option, -} - -/// `GET /xrpc/com.atproto.space.getMemberOplog`. -pub async fn get_member_oplog( - State(state): State, - parts: Parts, - Query(q): Query, -) -> Result, XrpcError> { - require_any_authn(&parts, &state).await?; - let uri = parse_space_uri(&q.space)?; - let page = space_sync(&state)? - .get_member_oplog(&uri, q.since.as_deref(), q.limit.unwrap_or(100)) - .await - .map_err(XrpcError::from)?; - Ok(Json(oplog_to_dto(page))) -} - -fn oplog_to_dto(page: atproto_space::storage::OplogPage) -> OplogResponse { - OplogResponse { - ops: page - .ops - .into_iter() - .map(|o| OplogEntryDto { - rev: o.rev, - idx: o.idx, - action: o.action, - collection: o.collection, - rkey: o.rkey, - cid: o.cid, - prev: o.prev, - did: o.did, - }) - .collect(), - state: StateResponse { - set_hash: page.state.set_hash.as_deref().map(hex::encode), - rev: page.state.rev, - }, - } -} - // --------------------------------------------------------------------------- // Credential mint endpoints. // --------------------------------------------------------------------------- -/// Inputs for `getMemberGrant`. +/// Query params for `getDelegationToken`. #[derive(Debug, Deserialize)] -pub struct GetMemberGrantInput { +pub struct GetDelegationTokenQuery { /// Space URI. pub space: String, - /// OAuth `client_id` of the requesting app. - #[serde(rename = "clientId")] - pub client_id: String, } -/// Output of `getMemberGrant` and `getSpaceCredential`. +/// Output of `getDelegationToken` — `{ token }` only, per the +/// `com.atproto.space.getDelegationToken` lexicon. #[derive(Debug, Serialize)] -pub struct TokenWrapper { - /// The compact-form JWT. +pub struct DelegationTokenResponse { + /// The compact-form delegation JWT. pub token: String, - /// Expiry in seconds since epoch. - #[serde(rename = "expiresAt")] - pub expires_at: u64, } -/// `POST /xrpc/com.atproto.space.getMemberGrant` — member-OAuth gated. -/// Mints a [`MemberGrant`](atproto_space::credential::MemberGrant) signed by -/// the member's atproto signing key, scoped to the given `clientId`. -pub async fn get_member_grant( +/// Output of `getSpaceCredential` — `{ credential }`, the bare JWT, per the +/// `com.atproto.space.getSpaceCredential` lexicon (spec lines 246). +#[derive(Debug, Serialize)] +pub struct SpaceCredentialResponse { + /// The compact-form space-credential JWT. + pub credential: String, +} + +/// `GET /xrpc/com.atproto.space.getDelegationToken` — member-OAuth gated. +/// Mints a [`DelegationToken`](atproto_space::credential::DelegationToken) +/// signed by the member's atproto signing key (header `kid="#atproto"`). +/// +/// The delegation token asserts only the user-to-app delegation; it carries no +/// app identity. It is `aud`-addressed to the space host +/// (`#atproto_space_host`) and `sub`-bound to the space URI. The +/// output body is exactly `{ "token": }` — the token is later exchanged +/// with the space authority at `getSpaceCredential`. +pub async fn get_delegation_token( State(state): State, parts: Parts, - Json(input): Json, -) -> Result, XrpcError> { - let member_did = require_session_subject(&parts, &state).await?; - let uri = parse_space_uri(&input.space)?; - let manager = account_manager(&state)?; - let signing_key = local_signing_key(manager, &member_did).await?; - let token = create_member_grant( - &member_did, - &uri.owner_did, + Query(q): Query, +) -> Result, XrpcError> { + let (htm, htu) = request_htm_htu(&parts); + let subject = require_authn(&parts, &state, &htm, &htu).await?; + let member_did = subject.sub().to_string(); + // The delegation token proves an app is acting on the user's behalf, so + // the request must come from an OAuth session (which carries a client + // identity). The token itself records nothing about the app — app + // identity is the client attestation's job — but we still reject + // app-password sessions here, matching the OAuth-gated flow. + if subject.client_id().is_none() { + return Err(XrpcError::new( + StatusCode::FORBIDDEN, + "InvalidRequest", + "getDelegationToken requires OAuth auth with a client_id", + )); + } + let uri = parse_space_uri(&q.space)?; + // OAuth `space:` read-scope gate before minting. No-op for app-password + // sessions, which are rejected above for lacking a client_id anyway. + assert_space_scope( + &state, + &subject, &uri, - &input.client_id, - &signing_key, - MEMBER_GRANT_TTL_SECS, + atproto_oauth::scopes::SpaceAction::Read, + None, ) - .map_err(|e| { - XrpcError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "InternalError", - format!("mint MemberGrant: {e}"), - ) - })?; - Ok(Json(TokenWrapper { - token, - expires_at: now_secs() + MEMBER_GRANT_TTL_SECS, - })) + .await?; + let manager = account_manager(&state)?; + let signing_key = local_signing_key(manager, &member_did).await?; + let token = create_delegation_token(&member_did, &uri, &signing_key, DELEGATION_TOKEN_TTL_SECS) + .map_err(|e| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + format!("mint delegation token: {e}"), + ) + })?; + Ok(Json(DelegationTokenResponse { token })) } -/// Inputs for `getSpaceCredential` — the grant *is* the auth. +/// Inputs for `getSpaceCredential`. The delegation token is presented in the +/// `Authorization: Bearer` header (not the body); the body carries the target +/// space and an optional client attestation. #[derive(Debug, Deserialize)] pub struct GetSpaceCredentialInput { - /// MemberGrant compact-form JWT. - pub grant: String, + /// The space being requested, an `ats://` URI. + pub space: String, + /// Optional client attestation (compact JWT) establishing the app's + /// identity. Required only when the space gates on app identity + /// (`appAccess` is `#allowList`). Matches the lexicon + /// `clientAttestation` field. + #[serde(rename = "clientAttestation", default)] + pub client_attestation: Option, } -/// `POST /xrpc/com.atproto.space.getSpaceCredential` — grant-gated. -/// Verifies the [`MemberGrant`](atproto_space::credential::MemberGrant) -/// against the member's signing key, then mints a +/// `POST /xrpc/com.atproto.space.getSpaceCredential` — delegation-token gated. +/// Reads the [`DelegationToken`](atproto_space::credential::DelegationToken) +/// from the `Authorization: Bearer` header, verifies it against the member's +/// `#atproto` signing key, enforces single-use via its `jti`, then mints a /// [`SpaceCredential`](atproto_space::credential::SpaceCredential) signed by -/// the owner's signing key. +/// the authority's `#atproto_space` signing key. pub async fn get_space_credential( State(state): State, + parts: Parts, Json(input): Json, -) -> Result, XrpcError> { +) -> Result, XrpcError> { let manager = account_manager(&state)?; - // First, peek the grant payload to learn space + clientId so we can - // verify against the right key. - let payload_b64 = input.grant.split('.').nth(1).ok_or_else(|| { - XrpcError::new(StatusCode::BAD_REQUEST, "InvalidToken", "grant: malformed") - })?; - let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload_b64.as_bytes()) + // The delegation token is the bearer credential. + let grant_jwt = bearer_token(&parts)?; + + let space = parse_space_uri(&input.space)?; + + // Peek the delegation token to learn its issuer (the member) so we know + // which key to resolve, and confirm it targets this space. + let unverified = peek_delegation_token(grant_jwt)?; + + // Try the local path first (fast); on `AccountNotFound` (the + // member is not on this PDS), fall through to the remote path that + // resolves the member's DID document via atproto-identity. + let payload = + match verify_local_delegation_token(manager, grant_jwt, &space.space_did, &space).await { + Ok(p) => p, + Err(e) if e.status == StatusCode::NOT_FOUND && e.name == "AccountNotFound" => { + tracing::debug!( + member = %unverified.iss, + "member not local; attempting cross-PDS DID-document resolution" + ); + let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(crate::user_agent()) + .build() + .unwrap_or_default(); + crate::http::space_auth::verify_remote_delegation_token( + &http, + grant_jwt, + &space.space_did, + &space, + plc_dir, + ) + .await? + } + Err(e) => return Err(e), + }; + + // Enforce single-use of the delegation token via its `jti` (spec line + // 149). Consume it before minting so a replayed token is refused. + let dt_ttl = std::time::Duration::from_secs(payload.exp.saturating_sub(now_secs())); + state + .jti_guard + .check_and_insert(&payload.jti, dt_ttl) + .await .map_err(|_| { XrpcError::new( - StatusCode::BAD_REQUEST, + StatusCode::FORBIDDEN, "InvalidToken", - "grant: payload not base64url", + "delegation token already used (single-use replay)", ) })?; - let unverified: atproto_space::credential::MemberGrant = serde_json::from_slice(&bytes) - .map_err(|_| { - XrpcError::new( - StatusCode::BAD_REQUEST, - "InvalidToken", - "grant: payload not JSON", + + let owner_signing = local_signing_key(manager, &space.space_did).await?; + + // ── Mint-time authorization (defs.json: a credential is minted only when + // the user is authorized by `mintPolicy` AND their app by `appAccess`). + // + // The requesting member is the delegation token's issuer. App identity is + // established solely by the optional client attestation: when one is + // presented we verify it (which yields the attested client_id) and use + // that for the APP axis and the credential's `client_id`. When none is + // presented the credential's `client_id` is omitted (spec lines 221, 228). + let mint_http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(crate::user_agent()) + .build() + .unwrap_or_default(); + + let attested_client_id: Option = match input.client_attestation.as_deref() { + Some(att) => Some( + crate::space::mint_authz::verify_client_attestation( + &mint_http, + &state.jti_guard, + att, + &space, ) - })?; + .await + .map_err(mint_denial_to_xrpc)?, + ), + None => None, + }; - let space = parse_space_uri(&unverified.space)?; - // §3.3: try the local path first (fast); on `AccountNotFound` (the - // member is not on this PDS), fall through to the remote path that - // resolves the member's DID document via atproto-identity. - let payload = match verify_local_member_grant( - manager, - &state.jti_guard, - &input.grant, - &space.owner_did, - &space, - &unverified.client_id, - ) - .await + let svc = space_service(&state)?; + let inputs = svc + .load_mint_authz_inputs(&space, &payload.iss) + .await + .map_err(XrpcError::from)?; + if !inputs.found { + return Err(XrpcError::new( + StatusCode::NOT_FOUND, + "SpaceNotFound", + format!("space not found: {space}"), + )); + } + if inputs.deleted { + return Err(XrpcError::new( + StatusCode::NOT_FOUND, + "SpaceDeleted", + format!("space deleted: {space}"), + )); + } + + // USER axis (mintPolicy). + match crate::space::mint_authz::user_axis_local(inputs.config.mint_policy, inputs.is_member) + .map_err(mint_denial_to_xrpc)? { - Ok(p) => p, - Err(e) if e.status == StatusCode::NOT_FOUND && e.name == "AccountNotFound" => { - tracing::debug!( - member = %unverified.iss, - "member not local; attempting cross-PDS DID-document resolution" - ); + Some(()) => {} + None => { + // managing-app: ask the managingApp via checkUserAccess. + let managing_app = inputs.config.managing_app.as_deref().ok_or_else(|| { + XrpcError::new( + StatusCode::FORBIDDEN, + "NotAuthorized", + "mintPolicy is managing-app but no managingApp is configured", + ) + })?; let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); - let http = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .user_agent(crate::user_agent()) - .build() - .unwrap_or_default(); - crate::http::space_auth::verify_remote_member_grant( - &http, - &state.jti_guard, - &input.grant, - &space.owner_did, - &space, - &unverified.client_id, + let endpoint = crate::space::recipient::resolve_service_endpoint( + &mint_http, + managing_app, plc_dir, ) - .await? + .await + .map_err(XrpcError::from)? + .ok_or_else(|| { + XrpcError::new( + StatusCode::FORBIDDEN, + "NotAuthorized", + format!("could not resolve managingApp service endpoint: {managing_app}"), + ) + })?; + crate::space::mint_authz::check_user_access( + &mint_http, + &endpoint, + managing_app, + &owner_signing, + &space.space_did, + &space, + &payload.iss, + attested_client_id.as_deref(), + ) + .await + .map_err(mint_denial_to_xrpc)?; } - Err(e) => return Err(e), - }; + } - let owner_signing = local_signing_key(manager, &space.owner_did).await?; + // APP axis (appAccess). + crate::space::mint_authz::app_axis(&inputs.config.app_access, attested_client_id.as_deref()) + .map_err(mint_denial_to_xrpc)?; + + // The credential's `client_id` is the attested application identity, or + // omitted entirely when the request carried no attestation. let credential_ttl = state.space_credential_ttl_secs; let token = create_space_credential( - &space.owner_did, + &space.space_did, &space, - &payload.client_id, + attested_client_id.as_deref(), &owner_signing, credential_ttl, ) @@ -967,46 +1662,50 @@ pub async fn get_space_credential( // `(space, service_did)` — re-issuing to the same client just bumps // `last_issued_at`. // - // §3.2: discover the consumer's actual `(service_did, service_endpoint)` - // by resolving `/.well-known/atproto-did` and the - // resulting DID document's `AtprotoPersonalDataServer` service. Falls - // back to a documented stub `(grant.iss, client_id-origin)` when any - // step fails — that's the same behavior the §3.2 audit identified, but - // now flagged as `fully_resolved=false` so operators can audit. + // Recipient discovery is keyed off the *attested* client_id (the + // consumer's client-metadata URL): we resolve + // `/.well-known/atproto-did` and the resulting DID + // document's `AtprotoPersonalDataServer` service, falling back to a + // documented stub when any step fails. When the request carried no + // attestation there is no consumer URL to resolve, so we register the + // member's own DID as the recipient via the stub. let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); let recipient_http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .user_agent(crate::user_agent()) .build() .unwrap_or_default(); - let resolved = match crate::space::recipient::resolve_recipient( - &recipient_http, - &payload.iss, - &payload.client_id, - plc_dir, - ) - .await - { - Ok(r) => r, - Err(e) => { - tracing::warn!( - error = ?e, - client_id = %payload.client_id, - "recipient resolution failed; falling back to stub" - ); - crate::space::recipient::stub_recipient(&payload.iss, &payload.client_id) - } + let resolved = match attested_client_id.as_deref() { + Some(client_id) => match crate::space::recipient::resolve_recipient( + &recipient_http, + &payload.iss, + client_id, + plc_dir, + ) + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!( + error = ?e, + client_id = %client_id, + "recipient resolution failed; falling back to stub" + ); + crate::space::recipient::stub_recipient(&payload.iss, client_id) + } + }, + None => crate::space::recipient::stub_recipient(&payload.iss, &payload.iss), }; if !resolved.fully_resolved { tracing::warn!( - client_id = %payload.client_id, + member = %payload.iss, stub_did = %resolved.service_did, stub_endpoint = %resolved.service_endpoint, "recipient resolved via stub; consumer DID document was unreachable or missing a PDS service entry" ); } - match SqlActorStore::open(manager.data_dir(), &space.owner_did).await { + match SqlActorStore::open(manager.data_dir(), &space.space_did).await { Ok(owner_store) => { if let Err(e) = upsert_recipient( owner_store.pool(), @@ -1019,8 +1718,8 @@ pub async fn get_space_credential( tracing::warn!( error = ?e, space = %space, - client_id = %payload.client_id, - "register space_credential_recipient failed; this consumer will not receive notifyWrite/notifyMembership" + member = %payload.iss, + "register space_credential_recipient failed; this consumer will not receive notifyWrite" ); } } @@ -1033,10 +1732,7 @@ pub async fn get_space_credential( } } - Ok(Json(TokenWrapper { - token, - expires_at: now_secs() + credential_ttl, - })) + Ok(Json(SpaceCredentialResponse { credential: token })) } // --------------------------------------------------------------------------- @@ -1053,91 +1749,427 @@ fn parse_space_uri(s: &str) -> Result { }) } -/// Sync endpoints accept either a session/OAuth access token or a -/// SpaceCredential. / G35 the PDS does not enforce -/// membership at sync time — we just require *some* valid token shape. -/// OAuth tokens with a DPoP `cnf.jkt` binding still trigger the proof -/// check via the unified helper. -async fn require_any_authn(parts: &Parts, state: &HttpState) -> Result<(), XrpcError> { +/// Host/sync read endpoints accept either a session/OAuth access token or a +/// `SpaceCredential` bound to `space`. The PDS does not enforce membership at +/// sync time, but a presented credential MUST verify: when the bearer's `typ` +/// classifies as a `SpaceCredential`, its signature is checked against the +/// space authority's `#atproto_space` key and its `iss`/`sub`/`exp` are bound +/// to `space`. A forged, unsigned, expired, or wrong-space credential is +/// rejected with 401 rather than admitted on its `typ` string alone. +/// OAuth tokens with a DPoP `cnf.jkt` binding still trigger the proof check via +/// the unified helper. +/// +/// Returns the bearer [`AuthSubject`](crate::http::auth::AuthSubject) for a +/// session/OAuth access token, or `None` for a verified `SpaceCredential` +/// (which pre-authorizes whole-space read at the auth layer). Callers gate the +/// `space:` scope only on the returned subject. +async fn require_any_authn( + parts: &Parts, + state: &HttpState, + space: &SpaceUri, +) -> Result, XrpcError> { let raw = bearer_token(parts)?; if let Some(SpaceTokenKind::SpaceCredential) = classify(raw) { - // We don't have the space URI here for full verification, but the - // structural typ check is enough — the reader handlers re-verify - // signatures against owner keys on every record read. Sync endpoints - // are intentionally permissive; consumers do inductive verification. - return Ok(()); + space_reader(state)? + .verify_space_credential_for(space, raw) + .await + .map_err(|e| { + XrpcError::new( + StatusCode::UNAUTHORIZED, + "Unauthorized", + format!("invalid space credential: {e}"), + ) + })?; + return Ok(None); } let (htm, htu) = request_htm_htu(parts); - require_authn_sub(parts, state, &htm, &htu) - .await - .map(|_| ()) + require_authn(parts, state, &htm, &htu).await.map(Some) } -fn now_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) +/// Host/sync read auth restricted to a verified `SpaceCredential` (spec XRPC +/// table: `getSpace` and `listRepos` are "space credential" only). Rejects an +/// OAuth/session bearer with 401 — only a credential minted by the space +/// authority is acceptable — and verifies the credential against `space`. +async fn require_space_credential( + parts: &Parts, + state: &HttpState, + space: &SpaceUri, +) -> Result<(), XrpcError> { + let raw = bearer_token(parts)?; + if classify(raw) != Some(SpaceTokenKind::SpaceCredential) { + return Err(XrpcError::new( + StatusCode::UNAUTHORIZED, + "Unauthorized", + "this method requires a space credential", + )); + } + space_reader(state)? + .verify_space_credential_for(space, raw) + .await + .map_err(|e| { + XrpcError::new( + StatusCode::UNAUTHORIZED, + "Unauthorized", + format!("invalid space credential: {e}"), + ) + }) } // --------------------------------------------------------------------------- -// Inbound notify endpoints (§3.1). +// OAuth `space:` scope enforcement. +// +// Enforces the 0016 `space:` OAuth scope rules (spec lines 369-419): only +// OAuth credentials carry granular `space:` permissions. App-password +// sessions (`access`) and SpaceCredential auth pre-authorize at the auth +// layer and skip the scope check entirely. A missing scope maps to 403. // --------------------------------------------------------------------------- -/// `POST /xrpc/com.atproto.space.notifyWrite` — receive a notify-write -/// payload from a remote owner PDS. -/// -/// Authentication is structural: the embedded signed `Commit` must -/// verify against the owner's atproto signing key (resolved from the -/// owner's DID document). No bearer token is required — the signature -/// IS the auth, and the `(space, rev)` dedup keeps replays harmless. +/// Assert that the OAuth scope set granted to `subject` permits `action` on +/// the space `uri`. Collection-scoped (`create`/`update`/`delete`) targets +/// must pass `collection`; `read`/`manage` leave it `None`. /// -/// The `?recipient=` query parameter selects which local account's -/// per-actor store records the receipt. Typically this is the local -/// account holding the `SpaceCredential` for the named space. -pub async fn notify_write( - State(state): State, - Query(q): Query, - body: axum::body::Bytes, -) -> Result { - let manager = account_manager(&state)?; - let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); - let http = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .user_agent(crate::user_agent()) - .build() - .unwrap_or_default(); - crate::space::inbound::receive_write( - &http, - plc_dir, - manager.data_dir(), - &q.recipient, - body.as_ref(), - ) - .await - .map_err(XrpcError::from)?; - Ok(StatusCode::OK) +/// No-op for non-OAuth subjects (app-password sessions): they carry no +/// `space:` grants and are authorized at the session layer, matching the +/// reference `auth.credentials.type !== 'oauth'` early-return. A scope +/// shortfall on an OAuth subject becomes a 403 `InvalidToken` carrying the +/// minimal scope that would have satisfied the request. +/// Gate the `read` action for a sync/read endpoint whose auth was resolved +/// via [`require_any_authn`]: `Some(subject)` runs the OAuth scope check, +/// `None` (SpaceCredential auth) skips it. +async fn assert_space_read_opt( + state: &HttpState, + subject: &Option, + uri: &SpaceUri, +) -> Result<(), XrpcError> { + match subject { + Some(s) => { + assert_space_scope( + state, + s, + uri, + atproto_oauth::scopes::SpaceAction::Read, + None, + ) + .await + } + None => Ok(()), + } } -/// `POST /xrpc/com.atproto.space.notifyMembership` — receive a -/// notify-membership payload from a remote owner PDS. -pub async fn notify_membership( - State(state): State, - Query(q): Query, - body: axum::body::Bytes, -) -> Result { - let manager = account_manager(&state)?; - let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); +async fn assert_space_scope( + state: &HttpState, + subject: &crate::http::auth::AuthSubject, + uri: &SpaceUri, + action: atproto_oauth::scopes::SpaceAction, + collection: Option<&str>, +) -> Result<(), XrpcError> { + if !subject.is_oauth() { + return Ok(()); + } + let target = match collection { + Some(c) => atproto_oauth::scopes::SpaceTarget::with_collection( + uri.space_type.as_str(), + &uri.space_did, + uri.space_key.as_str(), + action, + c, + ), + None => atproto_oauth::scopes::SpaceTarget::new( + uri.space_type.as_str(), + &uri.space_did, + uri.space_key.as_str(), + action, + ), + }; + // Resolve the space type declaration's collections so a bare grant's + // omitted-`collection` default matches the declared collections (spec line + // 413). Whole-space `read` ignores collection, so the lookup is skipped. + let declared = match action { + atproto_oauth::scopes::SpaceAction::Read => Vec::new(), + _ => declared_collections(state, uri.space_type.as_str()).await, + }; + subject + .scopes() + .assert_space_with(&target, &declared) + .map_err(|e| { + tracing::debug!( + space = %uri, + action = action.as_str(), + needed = %e.scope, + "space scope assertion failed" + ); + XrpcError::new( + StatusCode::FORBIDDEN, + "InvalidToken", + format!( + "insufficient OAuth scope for this space operation; need `{}`", + e.scope + ), + ) + }) +} + +/// Assert that the OAuth scope set granted to `subject` permits the +/// space-management `verb` on the space `uri` (spec lines 415-419). The verb +/// maps onto the management surface at the call site (e.g. `update` authorizes +/// `updateSpace`/`addMember`/`removeMember`). No-op for non-OAuth subjects. +fn assert_space_manage( + subject: &crate::http::auth::AuthSubject, + uri: &SpaceUri, + verb: atproto_oauth::scopes::SpaceManageVerb, +) -> Result<(), XrpcError> { + if !subject.is_oauth() { + return Ok(()); + } + let target = atproto_oauth::scopes::SpaceManageTarget::new( + uri.space_type.as_str(), + &uri.space_did, + uri.space_key.as_str(), + verb, + ); + subject.scopes().assert_space_manage(&target).map_err(|e| { + tracing::debug!( + space = %uri, + verb = verb.as_str(), + needed = %e.scope, + "space manage scope assertion failed" + ); + XrpcError::new( + StatusCode::FORBIDDEN, + "InvalidToken", + format!( + "insufficient OAuth scope for this space-management operation; need `{}`", + e.scope + ), + ) + }) +} + +/// Assert that the OAuth scope set granted to `subject` permits reading the +/// record(s) at `uri` from `target_repo` (spec lines 392-413). +/// +/// - Reading the holder's **own** repo (`target_repo == subject.sub()`) is +/// satisfied by either a whole-space `read` grant or a collection-covering +/// `read_self` grant. A `read_self` grant is collection-constrained, so a +/// cross-collection listing (`collection == None`) of the own repo falls +/// back to requiring whole-space `read`. +/// - Reading **another** member's repo requires whole-space `read` +/// (collection-independent). +/// +/// No-op for non-OAuth subjects (app-password sessions). +async fn assert_space_record_read( + state: &HttpState, + subject: &crate::http::auth::AuthSubject, + uri: &SpaceUri, + target_repo: &str, + collection: Option<&str>, +) -> Result<(), XrpcError> { + let own_repo = subject.sub() == target_repo; + match (own_repo, collection) { + // Own repo, single collection: read_self (also satisfied by read). + (true, Some(c)) => { + assert_space_scope( + state, + subject, + uri, + atproto_oauth::scopes::SpaceAction::ReadSelf, + Some(c), + ) + .await + } + // Own repo across all collections, or any other member's repo: the + // collection-independent whole-space `read` grant is required. + _ => { + assert_space_scope( + state, + subject, + uri, + atproto_oauth::scopes::SpaceAction::Read, + None, + ) + .await + } + } +} + +/// Resolve the `collections` declared by the space type's declaration for the +/// `space_type` NSID, used to expand a bare `space:` grant's +/// omitted-`collection` default (spec line 413). +/// +/// Resolution is delegated to the configured +/// [`SpaceDeclarationResolver`](crate::space::SpaceDeclarationResolver). It is +/// **fail-closed**: when no resolver is configured, the spaceType is the `*` +/// wildcard (no declaration to draw from), or resolution fails, this returns an +/// empty list — a bare grant then confers no write targets. Explicit +/// `collection=` grants are unaffected (they never consult the default). +async fn declared_collections(state: &HttpState, space_type: &str) -> Vec { + // `spaceType=*` has no declaration (spec line 413); skip resolution. + if space_type == "*" { + return Vec::new(); + } + let Some(resolver) = state.space_declaration_resolver.as_ref() else { + return Vec::new(); + }; + match resolver.resolve(space_type).await { + Some(decl) => decl.collections, + None => { + tracing::warn!( + space_type, + "space-type declaration resolution failed; bare `space:` grant defaults to no write collections (fail-closed)" + ); + Vec::new() + } + } +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Map a mint-authorization denial to its documented `getSpaceCredential` +/// XRPC error. User/app/not-authorized refusals are `403`; an invalid client +/// attestation is `400`. +fn mint_denial_to_xrpc(denial: crate::space::mint_authz::MintDenial) -> XrpcError { + use crate::space::mint_authz::MintDenial; + let status = match denial { + MintDenial::InvalidClientAttestation { .. } => StatusCode::BAD_REQUEST, + _ => StatusCode::FORBIDDEN, + }; + tracing::debug!( + error_name = denial.error_name(), + reason = denial.reason(), + "getSpaceCredential mint authorization denied" + ); + XrpcError::new(status, denial.error_name(), denial.reason().to_string()) +} + +// --------------------------------------------------------------------------- +// Inbound notify endpoints. +// --------------------------------------------------------------------------- + +/// `POST /xrpc/com.atproto.space.notifyWrite` — receive a contentless +/// notify-write `{ space, repo, rev }` announcing that `repo` advanced to +/// `rev` within `space`. +/// +/// Authentication is **service auth**: a bearer JWT signed by the writer's +/// `#atproto` key, with `iss == repo` and `aud == `, +/// scoped to `lxm == com.atproto.space.notifyWrite`. +/// +/// Behavior implements the spec's two-hop fan-out (lines 343-351): members +/// notify the authority, which forwards to the endpoints registered for the +/// space. When this PDS hosts the space owner and the writer is a member, the +/// notification is forwarded to every registered recipient (registerNotify +/// subscribers + credential consumers). A lightweight receipt is recorded for +/// dedup + audit. For a non-owner host (e.g. a syncing service that also holds +/// a replica) the handler simply records the receipt — there is no fan-out +/// state. +pub async fn notify_write( + State(state): State, + parts: Parts, + body: axum::body::Bytes, +) -> Result { + let manager = account_manager(&state)?; + let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .user_agent(crate::user_agent()) .build() .unwrap_or_default(); - crate::space::inbound::receive_membership( + + // Decode first so we know the space/owner the service-auth `aud` must bind. + let payload: crate::space::notify::NotifyWritePayload = serde_json::from_slice(body.as_ref()) + .map_err(|e| { + XrpcError::new( + StatusCode::BAD_REQUEST, + "InvalidRequest", + format!("decode notifyWrite payload: {e}"), + ) + })?; + let space = parse_space_uri(&payload.space)?; + + // Service-auth: signature over the writer's key, aud = owner DID, + // lxm scoped to notifyWrite. + let token = bearer_token(&parts)?; + let claims = crate::space::service_auth::verify_service_auth( + &http, + token, + plc_dir, + &space.space_did, + crate::space::notify::NOTIFY_WRITE_NSID, + ) + .await + .map_err(XrpcError::from)?; + // The JWT issuer must match the claimed writer so a PDS can't deliver a + // notification on someone else's behalf (reference `notifyWrite.ts`). + if claims.iss != payload.repo { + return Err(XrpcError::new( + StatusCode::FORBIDDEN, + "Forbidden", + "notifyWrite iss does not match claimed writer", + )); + } + + // Owner-side fan-out (HOP 2): only the owner's PDS holds the member list + + // recipient subscriptions. For a non-owner host this is a best-effort + // no-op (the receipt below is still recorded). + let owner_is_local = manager + .lookup_handle(&space.space_did) + .await + .map_err(XrpcError::from)? + .is_some(); + if owner_is_local { + let is_member = space_service(&state)? + .is_member(&space, &payload.repo) + .await + .map_err(XrpcError::from)?; + if !is_member { + return Err(XrpcError::new( + StatusCode::FORBIDDEN, + "Forbidden", + "notifyWrite writer is not a member of the space", + )); + } + // Owner signing key to mint per-recipient service-auth tokens for the + // outbound fan-out. If it's unavailable we log and skip fan-out (the + // receipt below is still recorded). + match local_signing_key(manager, &space.space_did).await { + Ok(owner_key) => { + if let Err(e) = crate::space::notify::enqueue_writes( + manager.pool(), + manager.data_dir(), + &space, + &payload, + &owner_key, + ) + .await + { + tracing::warn!( + error = ?e, + space = %space, + repo = %payload.repo, + "notifyWrite fan-out enqueue failed; recipients may miss this revision" + ); + } + } + Err(e) => { + tracing::warn!( + error = ?e, + space = %space, + "notifyWrite fan-out skipped: owner signing key unavailable" + ); + } + } + } + + // Record a lightweight receipt (dedup + audit) pinned to the owner DID. + crate::space::inbound::receive_write( &http, plc_dir, manager.data_dir(), - &q.recipient, + &space.space_did, body.as_ref(), ) .await @@ -1145,183 +2177,808 @@ pub async fn notify_membership( Ok(StatusCode::OK) } -/// Query params for the inbound notify-* endpoints. -#[derive(Debug, Deserialize)] -pub struct NotifyRecipientQuery { - /// DID of the local account this PDS hosts that should record the - /// receipt. The notifying peer learned this DID from the - /// `getSpaceCredential` flow. - pub recipient: String, -} - // --------------------------------------------------------------------------- -// Export / import endpoints (§3.4). +// getBlob — permissioned blob fetch (com.atproto.space.getBlob). // --------------------------------------------------------------------------- -/// Query params for `exportSpaces`. +/// Query params for `com.atproto.space.getBlob`. #[derive(Debug, Deserialize)] -pub struct ExportSpacesQuery { - /// Space URI (`ats://owner/type/key`). +pub struct GetSpaceBlobQuery { + /// Space URI. pub space: String, - /// DID whose record-set should be exported. When omitted the export - /// emits a member-list-only manifest (owner-side migration shape). - pub member: Option, + /// DID of the account whose repo holds the blob. + pub repo: String, + /// CID of the blob to fetch. + pub cid: String, } -/// `GET /xrpc/com.atproto.space.exportSpaces` — stream a CARv1 of the -/// requested `(member, space)` records + member list. +/// `GET /xrpc/com.atproto.space.getBlob`. /// -/// Auth model: the caller must be the space owner or the named member. -/// This bounds the export to actors who are authorized to read that -/// data via the existing `getRecord` / `getMembers` paths. -pub async fn export_spaces( +/// Serves the full blob as originally uploaded from `repo`'s regular +/// blobstore, gated by the same auth as `getRecord` / `listRecords` +/// (space-credential-space-match OR OAuth/session). Distinct from the public +/// `com.atproto.sync.getBlob`, which has no permissioned gate. Response carries +/// the standard atproto blob security headers (`x-content-type-options: +/// nosniff`, `content-disposition: attachment`, restrictive +/// `content-security-policy`). +pub async fn get_blob( State(state): State, parts: Parts, - Query(q): Query, + Query(q): Query, ) -> Result { + use axum::body::Body; + use axum::http::HeaderValue; use axum::http::header; - use axum::response::IntoResponse; - let caller = require_session_subject(&parts, &state).await?; - let uri = parse_space_uri(&q.space)?; + let space = parse_space_uri(&q.space)?; + // `repo` is required by the lexicon; ignore the auth-resolver default by + // always passing the explicit repo param. + let resolved = resolve_record_auth(&parts, &state, Some(q.repo.as_str())).await?; + if let Some(subject) = &resolved.subject { + assert_space_scope( + &state, + subject, + &space, + atproto_oauth::scopes::SpaceAction::Read, + None, + ) + .await?; + } + space_reader(&state)? + .verify_read_auth(&space, &resolved.auth) + .await + .map_err(XrpcError::from)?; + + let manager = account_manager(&state)?; + let pair = if let Some(backend) = state.public_realm_backend.as_ref() { + backend + .blob + .get(&q.repo, &q.cid) + .await + .map_err(XrpcError::from)? + } else { + let store = SqlActorStore::open(manager.data_dir(), &q.repo) + .await + .map_err(XrpcError::from)?; + crate::blob::get_blob(&store, &q.cid) + .await + .map_err(XrpcError::from)? + }; + let (data, mime) = pair.ok_or_else(|| { + XrpcError::new( + StatusCode::NOT_FOUND, + "BlobNotFound", + format!("no blob {} for {}", q.cid, q.repo), + ) + })?; + + let mut resp = axum::response::Response::new(Body::from(data)); + let headers = resp.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_str(&mime) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), + ); + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&format!("attachment; filename=\"{}\"", q.cid)) + .unwrap_or_else(|_| HeaderValue::from_static("attachment")), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'none'; sandbox"), + ); + Ok(resp) +} + +// --------------------------------------------------------------------------- +// listRepos — the writer set (com.atproto.space.listRepos). +// --------------------------------------------------------------------------- + +/// Query params for `com.atproto.space.listRepos`. +#[derive(Debug, Deserialize)] +pub struct ListReposQuery { + /// Space URI. + pub space: String, + /// Maximum number of repos to return (1..1000, default 100). + pub limit: Option, + /// Cursor (last `did` from the prior page). + pub cursor: Option, +} + +/// One repo in `listRepos` — `{ did, rev }` per `com.atproto.space.listRepos#repo`. +/// +/// Per the 0016 Permissioned Data draft (line 357), the writer set conveys each +/// repo together with its current `rev` so a syncer can resume per repo without +/// a separate probe. +#[derive(Debug, Serialize)] +pub struct RepoRef { + /// DID of a repo that holds data in the space. + pub did: String, + /// The repo's current `rev` (the latest observed in this space's + /// write-receipt log for that issuer). + pub rev: String, +} + +/// Output of `listRepos`. +#[derive(Debug, Serialize)] +pub struct ListReposResponse { + /// Cursor for the next page, when more may remain. + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// Page of writer repos. + pub repos: Vec, +} + +/// `GET /xrpc/com.atproto.space.listRepos`. +/// +/// The writer set: distinct issuer DIDs observed in the owner's inbound +/// write-receipt log (`space_received_op`), each paired with its current `rev` +/// (`MAX(rev)` over that issuer's receipts), ordered by DID, paginated by +/// `did > cursor`. Output is `{ did, rev }` per writer (spec line 357). +/// `SpaceNotFound` when the space row is absent. +/// +/// Auth is **space credential only** (spec XRPC table: `listRepos` is "space +/// credential"). An OAuth/session bearer is rejected with 401; the presented +/// credential is verified against the space authority's `#atproto_space` key. +pub async fn list_repos( + State(state): State, + parts: Parts, + Query(q): Query, +) -> Result, XrpcError> { + let space = parse_space_uri(&q.space)?; + // listRepos is space-credential-only (spec XRPC table line 483 + 394): an + // OAuth/session token is rejected; only a verified credential is accepted. + require_space_credential(&parts, &state, &space).await?; let manager = account_manager(&state)?; let _ = space_service(&state)?; // gate on Spaces being enabled + let limit = q.limit.unwrap_or(100).clamp(1, 1000); + + let store = SqlActorStore::open(manager.data_dir(), &space.space_did) + .await + .map_err(XrpcError::from)?; - // Authorize: caller must be owner OR the named member. - let caller_is_owner = caller == uri.owner_did; - let caller_is_named_member = q.member.as_deref() == Some(caller.as_str()); - if !caller_is_owner && !caller_is_named_member { + // SpaceNotFound when the owner's space row is absent. + let space_exists: Option = sqlx::query_scalar("SELECT 1 FROM space WHERE uri = ? LIMIT 1") + .bind(space.to_string()) + .fetch_optional(store.pool()) + .await + .map_err(|e| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + format!("listRepos space lookup: {e}"), + ) + })?; + if space_exists.is_none() { return Err(XrpcError::new( - StatusCode::FORBIDDEN, - "Forbidden", - "exportSpaces requires owner or named-member auth", + StatusCode::NOT_FOUND, + "SpaceNotFound", + format!("space not found: {space}"), )); } - // Buffer the CAR in memory. CARv1 export size for a space scales - // with record count — the simple buffered shape ships today; a - // future streaming bridge would use `axum::body::Body::from_stream`. - let mut car_bytes: Vec = Vec::new(); - crate::space::export::export_to_writer( - manager.data_dir(), - &uri, - q.member.as_deref(), - &mut car_bytes, + let cursor = q.cursor.clone().unwrap_or_default(); + let rows: Vec<(String, String)> = sqlx::query_as( + "SELECT issuer_did, MAX(rev) FROM space_received_op + WHERE space = ? AND issuer_did > ? + GROUP BY issuer_did + ORDER BY issuer_did ASC + LIMIT ?", ) + .bind(space.to_string()) + .bind(&cursor) + .bind(limit as i64) + .fetch_all(store.pool()) .await - .map_err(XrpcError::from)?; + .map_err(|e| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + format!("listRepos query: {e}"), + ) + })?; - let mut response = (StatusCode::OK, car_bytes).into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - "application/vnd.ipld.car".parse().unwrap(), - ); - Ok(response) + let repos: Vec = rows + .into_iter() + .map(|(did, rev)| RepoRef { did, rev }) + .collect(); + let next_cursor = if repos.len() as u32 == limit { + repos.last().map(|r| r.did.clone()) + } else { + None + }; + Ok(Json(ListReposResponse { + cursor: next_cursor, + repos, + })) } -/// Query params for `importSpaces`. +// --------------------------------------------------------------------------- +// registerNotify — subscribe an endpoint to write notifications. +// --------------------------------------------------------------------------- + +/// Inputs for `com.atproto.space.registerNotify`. #[derive(Debug, Deserialize)] -pub struct ImportSpacesQuery { - /// Optional override for the manifest-declared space URI. - pub space: Option, - /// Optional override for the manifest-declared member DID. - pub member: Option, +pub struct RegisterNotifyInput { + /// Space URI. + pub space: String, + /// DID of a specific repo to subscribe to (repo host). Omit for whole-space. + #[serde(default)] + pub repo: Option, + /// Endpoint to which `notifyWrite` events should be delivered. + pub endpoint: String, } -/// Output of `importSpaces`. +/// Output of `registerNotify`. #[derive(Debug, Serialize)] -pub struct ImportSpacesResponse { - /// Space URI restored. - pub space: String, - /// Imported member DID, when the export carried records for one. - #[serde(rename = "memberDid", skip_serializing_if = "Option::is_none")] - pub member_did: Option, - /// Number of records inserted. - #[serde(rename = "recordsInserted")] - pub records_inserted: usize, - /// Number of members inserted. - #[serde(rename = "membersInserted")] - pub members_inserted: usize, -} - -/// `POST /xrpc/com.atproto.space.importSpaces` — restore a previously -/// exported CARv1 onto this PDS. +pub struct RegisterNotifyResponse { + /// When the registration expires (RFC 3339). + #[serde(rename = "expiresAt")] + pub expires_at: String, +} + +/// Registration window for `registerNotify` (24h). +const REGISTER_NOTIFY_TTL_SECS: i64 = 24 * 60 * 60; + +/// `POST /xrpc/com.atproto.space.registerNotify`. /// -/// Auth model: the caller must be the destination space owner OR the -/// destination member DID. Override query params let callers retarget -/// the import (useful after handle/DID migration); the override DID -/// must match the auth subject. -pub async fn import_spaces( +/// Authenticated with a **space credential** (`typ = space_credential`): the +/// presented JWT is verified against the space owner's `#atproto` signing key +/// and must bind to `space`. Persists a subscription keyed +/// `(space, repo-or-null, service)` with a 24h expiry and returns `expiresAt`. +pub async fn register_notify( State(state): State, parts: Parts, - Query(q): Query, - body: axum::body::Bytes, -) -> Result, XrpcError> { - let caller = require_session_subject(&parts, &state).await?; + Json(input): Json, +) -> Result, XrpcError> { + let space = parse_space_uri(&input.space)?; let manager = account_manager(&state)?; let _ = space_service(&state)?; // gate on Spaces being enabled - // Resolve target overrides up-front so we can authorize. - let target_space = match q.space.as_deref() { - Some(s) => Some(parse_space_uri(s)?), - None => None, - }; - let target_member = q.member.as_deref(); - - // Authorize: when targeting a specific member DID, caller must equal - // that member. When targeting only a space, caller must be its - // owner. When neither override is provided, defer the auth check - // until we've decoded the manifest below (we pull the manifest's - // claimed (space, member) and re-check). - if let Some(m) = target_member - && m != caller - { + // Require a space-credential bearer. + let token = bearer_token(&parts)?; + if classify(token) != Some(SpaceTokenKind::SpaceCredential) { return Err(XrpcError::new( - StatusCode::FORBIDDEN, - "Forbidden", - "importSpaces member override must match the authenticated subject", + StatusCode::UNAUTHORIZED, + "AuthenticationRequired", + "registerNotify requires a space credential", )); } - if let Some(ref s) = target_space - && target_member.is_none() - && s.owner_did != caller - { + + // SpaceNotFound when the owner's space row is absent. + let owner_store = SqlActorStore::open(manager.data_dir(), &space.space_did) + .await + .map_err(XrpcError::from)?; + let space_exists: Option = sqlx::query_scalar("SELECT 1 FROM space WHERE uri = ? LIMIT 1") + .bind(space.to_string()) + .fetch_optional(owner_store.pool()) + .await + .map_err(|e| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + format!("registerNotify space lookup: {e}"), + ) + })?; + if space_exists.is_none() { return Err(XrpcError::new( - StatusCode::FORBIDDEN, - "Forbidden", - "importSpaces requires owner auth when no member override is set", + StatusCode::NOT_FOUND, + "SpaceNotFound", + format!("space not found: {space}"), )); } - let outcome = crate::space::export::import_from_reader( - manager.data_dir(), - std::io::Cursor::new(body.to_vec()), - target_space.as_ref(), - target_member, + // Verify the space credential: signature over the authority's + // #atproto_space key, bound to this space, not expired. The authority is + // local to this host PDS, and per 0016 line 92 #atproto_space coincides + // with the account's #atproto signing key (resolved via local_public_key). + let owner_pub = crate::http::space_auth::local_public_key(manager, &space.space_did).await?; + let credential = atproto_space::credential::verify_space_credential( + token, + &space.space_did, + &space, + &owner_pub, + ) + .map_err(|e| { + XrpcError::new( + StatusCode::FORBIDDEN, + "InvalidToken", + format!("SpaceCredential verification: {e}"), + ) + })?; + + // The credential's advisory `client_id` (the attested application) + // identifies the subscribing service. When the credential carried no + // attestation we key the subscription on the credential issuer (the space + // authority) instead, so registration still succeeds. + let service_did = credential + .client_id + .clone() + .unwrap_or_else(|| credential.iss.clone()); + let expires_at = + (chrono::Utc::now() + chrono::Duration::seconds(REGISTER_NOTIFY_TTL_SECS)).to_rfc3339(); + crate::space::notify::upsert_subscription( + owner_store.pool(), + &space, + input.repo.as_deref(), + &service_did, + &input.endpoint, + Some(&expires_at), ) .await .map_err(XrpcError::from)?; - // Post-decode authorization: if no overrides were supplied, the - // manifest's claimed (space, member) must square with the caller. - if target_space.is_none() && target_member.is_none() { - let restored_space = SpaceUri::parse(&outcome.space).map_err(PdsError::Space)?; - let manifest_member = outcome.member_did.as_deref(); - let caller_is_owner = caller == restored_space.owner_did; - let caller_is_named_member = manifest_member == Some(caller.as_str()); - if !caller_is_owner && !caller_is_named_member { - return Err(XrpcError::new( - StatusCode::FORBIDDEN, - "Forbidden", - "importSpaces caller is neither owner nor the manifest's member", - )); + Ok(Json(RegisterNotifyResponse { expires_at })) +} + +// --------------------------------------------------------------------------- +// notifySpaceDeleted — space-deletion lifecycle notification. +// --------------------------------------------------------------------------- + +/// Inputs for `com.atproto.space.notifySpaceDeleted`. +#[derive(Debug, Deserialize)] +pub struct NotifySpaceDeletedInput { + /// Space URI of the deleted space. + pub space: String, +} + +/// `POST /xrpc/com.atproto.space.notifySpaceDeleted`. +/// +/// Service-auth: the JWT `iss` must equal the space's `spaceDid` (the +/// authority) and `aud` the recipient (a repo host or syncing service hosted +/// here). Marks the recipient-side space row as deleted (`deleted_at`). +/// Best-effort: a no-op when the recipient is not local or the space row is +/// unknown. +/// +/// This PDS acts as a **repo host** here, so it implements the repo-host +/// behavior of the 0016 draft (line 365): flag the member's repo as belonging +/// to a deleted space rather than erase it (the data is the user's own). The +/// **syncer** behavior of line 367 — "delete every copy of the space's data it +/// holds, both the repos it pulled and any derived state" — is a syncer-role +/// responsibility and does not apply to the PDS-as-repo-host; this handler +/// therefore tombstones rather than purges. +pub async fn notify_space_deleted( + State(state): State, + parts: Parts, + Json(input): Json, +) -> Result { + let space = parse_space_uri(&input.space)?; + let manager = account_manager(&state)?; + let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname()); + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(crate::user_agent()) + .build() + .unwrap_or_default(); + + // Service-auth verification. `iss` must be the space authority; `aud` is + // the recipient hosted here. We don't know the recipient ahead of time, so + // we peek the unverified `aud`, then verify with that expected audience. + let token = bearer_token(&parts)?; + let recipient_did = peek_jwt_aud(token).ok_or_else(|| { + XrpcError::new( + StatusCode::BAD_REQUEST, + "InvalidToken", + "notifySpaceDeleted: missing aud claim", + ) + })?; + let claims = crate::space::service_auth::verify_service_auth( + &http, + token, + plc_dir, + &recipient_did, + "com.atproto.space.notifySpaceDeleted", + ) + .await + .map_err(XrpcError::from)?; + if claims.iss != space.space_did { + return Err(XrpcError::new( + StatusCode::UNAUTHORIZED, + "UntrustedIss", + "notifySpaceDeleted JWT issuer must be the space DID", + )); + } + + // aud must be a DID/handle; best-effort no-op otherwise. + if !recipient_did.starts_with("did:") { + return Ok(StatusCode::OK); + } + // Recipient must be hosted here; otherwise best-effort no-op. + if manager + .lookup_handle(&recipient_did) + .await + .map_err(XrpcError::from)? + .is_none() + { + return Ok(StatusCode::OK); + } + + // Mark the recipient-side space row deleted; no-op if unknown. + let store = SqlActorStore::open(manager.data_dir(), &recipient_did) + .await + .map_err(XrpcError::from)?; + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query("UPDATE space SET deleted_at = ? WHERE uri = ? AND deleted_at IS NULL") + .bind(&now) + .bind(space.to_string()) + .execute(store.pool()) + .await + .map_err(|e| { + XrpcError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalError", + format!("notifySpaceDeleted mark deleted: {e}"), + ) + })?; + Ok(StatusCode::OK) +} + +/// Best-effort extraction of the `aud` claim from a JWT *without* signature +/// verification — used only to learn the expected audience before the full +/// service-auth verification. +fn peek_jwt_aud(token: &str) -> Option { + let payload_b64 = token.split('.').nth(1)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64.as_bytes()) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + Some(value.get("aud")?.as_str()?.to_string()) +} + +#[cfg(test)] +mod scope_gate_tests { + use super::*; + use crate::account::session::SessionClaims; + use crate::http::auth::AuthSubject; + use crate::oauth::token::OAuthClaims; + use crate::space::declaration::{SpaceDeclaration, StubSpaceDeclarationResolver}; + use atproto_oauth::scopes::{SpaceAction, SpaceManageVerb}; + use std::collections::HashMap; + use std::sync::Arc; + + fn space_uri() -> SpaceUri { + parse_space_uri("ats://did:plc:owner/app.bsky.group/default").unwrap() + } + + /// Minimal `HttpState` with no declaration resolver configured (the + /// fail-closed default: bare grants confer no write targets). + async fn test_state() -> HttpState { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().to_path_buf(); + let accounts = crate::account::AccountDirectory::open_memory() + .await + .unwrap(); + let reader = Arc::new(crate::repo::RepoReader::new(accounts, dir)); + HttpState::new(reader) + } + + /// `HttpState` whose declaration resolver maps `app.bsky.group` to a + /// declaration listing `app.bsky.feed.post` as its sole collection. + async fn test_state_with_declaration() -> HttpState { + let mut map = HashMap::new(); + map.insert( + "app.bsky.group".to_string(), + SpaceDeclaration { + name: "Group".to_string(), + key: "tid".to_string(), + collections: vec!["app.bsky.feed.post".to_string()], + }, + ); + let resolver = Arc::new(StubSpaceDeclarationResolver::new(map)); + test_state().await.with_space_declaration_resolver(resolver) + } + + fn oauth_subject(scope: &str) -> AuthSubject { + AuthSubject::OAuth(OAuthClaims { + sub: "did:plc:member".to_string(), + iss: "did:web:pds".to_string(), + aud: "did:web:pds".to_string(), + client_id: "https://app.example/cm".to_string(), + scope: scope.to_string(), + cnf: None, + iat: 0, + exp: u64::MAX, + jti: "jti".to_string(), + }) + } + + fn session_subject() -> AuthSubject { + AuthSubject::AppPassword(SessionClaims { + sub: "did:plc:member".to_string(), + iss: "did:web:pds".to_string(), + apw: "apw".to_string(), + privileged: true, + iat: 0, + exp: u64::MAX, + jti: "jti".to_string(), + }) + } + + #[tokio::test] + async fn oauth_read_scope_matching_space_allows_read() { + let state = test_state().await; + let subject = + oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read"); + assert!( + assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn oauth_without_space_scope_denied_403() { + let state = test_state().await; + let subject = oauth_subject("atproto"); + let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + assert_eq!(err.name, "InvalidToken"); + } + + #[tokio::test] + async fn oauth_manage_scope_does_not_imply_read() { + // A grant with only `manage` and no record `action` confers no record + // read (manage and action are orthogonal axes per the 0016 spec). + let state = test_state().await; + let subject = oauth_subject( + "space:app.bsky.group?did=did:plc:owner&skey=default&action=create&manage=update", + ); + let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + } + + #[test] + fn oauth_manage_verb_gated_per_verb() { + // `manage=update` authorizes the update verb but not create/delete. + let subject = + oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&manage=update"); + assert!(assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Update).is_ok()); + let err = assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Create).unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + assert_eq!(err.name, "InvalidToken"); + } + + #[test] + fn oauth_bare_grant_confers_no_manage() { + // A bare record-access grant must not authorize any management op. + let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default"); + for verb in SpaceManageVerb::ALL { + let err = assert_space_manage(&subject, &space_uri(), verb).unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); } } - Ok(Json(ImportSpacesResponse { - space: outcome.space, - member_did: outcome.member_did, - records_inserted: outcome.records_inserted, - members_inserted: outcome.members_inserted, - })) + #[tokio::test] + async fn oauth_read_self_own_repo_collection_constrained() { + // read_self on the holder's own repo is collection-constrained. + let state = test_state().await; + let subject = oauth_subject( + "space:app.bsky.group?did=did:plc:owner&skey=default&action=read_self&collection=app.bsky.feed.post", + ); + // sub() == "did:plc:member"; reading own repo + covered collection. + assert!( + assert_space_record_read( + &state, + &subject, + &space_uri(), + "did:plc:member", + Some("app.bsky.feed.post"), + ) + .await + .is_ok() + ); + // Uncovered collection denied. + let err = assert_space_record_read( + &state, + &subject, + &space_uri(), + "did:plc:member", + Some("app.bsky.feed.like"), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + // Reading ANOTHER member's repo requires whole-space read → denied. + let err = assert_space_record_read( + &state, + &subject, + &space_uri(), + "did:plc:other", + Some("app.bsky.feed.post"), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn oauth_read_grant_reads_any_repo() { + // A whole-space `read` grant reads any member's repo, any collection. + let state = test_state().await; + let subject = + oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read"); + assert!( + assert_space_record_read( + &state, + &subject, + &space_uri(), + "did:plc:other", + Some("any.collection"), + ) + .await + .is_ok() + ); + // And cross-collection (collection=None) own-repo listing. + assert!( + assert_space_record_read(&state, &subject, &space_uri(), "did:plc:member", None) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn oauth_wildcard_type_and_did_allows_any_space() { + let state = test_state().await; + let subject = oauth_subject("space:*?action=read"); + assert!( + assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn oauth_tuple_mismatch_denied() { + // Scope is for a different owner DID — tuple gate fails. + let state = test_state().await; + let subject = + oauth_subject("space:app.bsky.group?did=did:plc:other&skey=default&action=read"); + let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn oauth_create_requires_covered_collection() { + // `create` action but collection list does not cover the target. + let state = test_state().await; + let subject = oauth_subject( + "space:app.bsky.group?did=did:plc:owner&skey=default&collection=app.bsky.feed.post&action=create", + ); + // Covered collection → allowed. + assert!( + assert_space_scope( + &state, + &subject, + &space_uri(), + SpaceAction::Create, + Some("app.bsky.feed.post"), + ) + .await + .is_ok() + ); + // Uncovered collection → denied. + let err = assert_space_scope( + &state, + &subject, + &space_uri(), + SpaceAction::Create, + Some("app.bsky.feed.like"), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn read_scope_does_not_grant_write() { + let state = test_state().await; + let subject = + oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read"); + let err = assert_space_scope( + &state, + &subject, + &space_uri(), + SpaceAction::Create, + Some("app.bsky.feed.post"), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + } + + #[test] + fn app_password_session_skips_scope_gate() { + // Non-OAuth subjects are authorized at the session layer and skip the + // `space:` scope check entirely. `assert_space_manage` is sync; the + // record-scope gate is covered by the async tests above. + let subject = session_subject(); + assert!(assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Update).is_ok()); + } + + #[tokio::test] + async fn app_password_session_skips_record_scope_gate() { + // Non-OAuth subjects skip the `space:` record-scope check entirely + // (only OAuth grants carry `space:` scopes per the 0016 spec, lines + // 369-419). + let state = test_state().await; + let subject = session_subject(); + assert!( + assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn read_opt_none_skips_gate() { + // SpaceCredential auth (None subject) always passes the read gate. + let state = test_state().await; + assert!( + assert_space_read_opt(&state, &None, &space_uri()) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn bare_grant_defaults_to_declared_collections() { + // A bare `space:app.bsky.group` grant (omits `collection` and `action`) + // must default its write targets to the declaration's `collections` + // (spec line 413). With a resolver mapping the type to a declaration + // listing `app.bsky.feed.post`, a create on that collection is allowed. + let state = test_state_with_declaration().await; + let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default"); + assert!( + assert_space_scope( + &state, + &subject, + &space_uri(), + SpaceAction::Create, + Some("app.bsky.feed.post"), + ) + .await + .is_ok() + ); + // A collection NOT in the declaration is not conferred by the default. + let err = assert_space_scope( + &state, + &subject, + &space_uri(), + SpaceAction::Create, + Some("app.bsky.feed.like"), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn bare_grant_without_resolver_confers_no_write_targets() { + // Fail-closed: with no declaration resolver configured, a bare grant's + // omitted-`collection` default resolves to empty, so no write target is + // conferred (the pre-F4 behavior, now explicit and documented). + let state = test_state().await; + let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default"); + let err = assert_space_scope( + &state, + &subject, + &space_uri(), + SpaceAction::Create, + Some("app.bsky.feed.post"), + ) + .await + .unwrap_err(); + assert_eq!(err.status, StatusCode::FORBIDDEN); + } } diff --git a/crates/atproto-pds/src/http/state.rs b/crates/atproto-pds/src/http/state.rs index cba978a..00eb897 100644 --- a/crates/atproto-pds/src/http/state.rs +++ b/crates/atproto-pds/src/http/state.rs @@ -8,7 +8,7 @@ use crate::plc::PlcService; use crate::repo::{RepoReader, RepoWriter}; use crate::security::{JtiReplayGuard, SlidingWindowLimiter}; use crate::sequencer::EventBus; -use crate::space::{SpaceReader, SpaceService, SpaceSync, SpaceWriter}; +use crate::space::{SpaceDeclarationResolver, SpaceReader, SpaceService, SpaceSync, SpaceWriter}; use atproto_identity::key::KeyData; use atproto_identity::traits::DnsResolver; use std::sync::Arc; @@ -43,6 +43,11 @@ pub struct HttpState { pub space_reader: Option>, /// Spaces sync (state + oplog) reader. pub space_sync: Option>, + /// Resolver for space-type declarations (NSID → declared `collections`), + /// used to expand a bare `space:` grant's omitted-`collection` default + /// (spec line 413). `None` disables the default (bare grants confer no + /// write targets); typically a TTL-cached network resolver. + pub space_declaration_resolver: Option>, /// PLC genesis service (None disables PLC-managed DID creation). pub plc_service: Option>, /// JWT-jti replay guard (always populated; in-memory by default). @@ -93,16 +98,13 @@ pub struct HttpState { /// `/oauth/jwks` so consumers verifying older tokens see them. pub pds_extra_signing_keys: Vec>, /// SpaceCredential TTL in seconds. Default - /// `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS` (3h); + /// `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS` (7200 / 2h); /// operators tighten/loosen via `PDS_SPACE_CREDENTIAL_TTL_SECONDS`. pub space_credential_ttl_secs: u64, /// Allowed handle suffix domains. Empty means /// any handle is accepted (back-compat). Set via /// `PDS_SERVICE_HANDLE_DOMAINS`. pub service_handle_domains: Vec, - /// Whether to send a notification email on space membership changes - /// Set via `PDS_NOTIFY_MEMBERSHIP_EMAIL`. - pub notify_membership_email: bool, /// Crawler hostnames notified by `requestCrawl`. /// Comma-separated `PDS_CRAWLERS`. pub crawlers: Vec, @@ -131,6 +133,7 @@ impl HttpState { space_writer: None, space_reader: None, space_sync: None, + space_declaration_resolver: None, plc_service: None, jti_guard: JtiReplayGuard::new(100_000), rate_limiter: SlidingWindowLimiter::new(300, Duration::from_secs(60), 100_000), @@ -146,7 +149,6 @@ impl HttpState { pds_extra_signing_keys: Vec::new(), space_credential_ttl_secs: atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS, service_handle_domains: Vec::new(), - notify_membership_email: false, crawlers: Vec::new(), bsky_app_view_did: None, bsky_app_view_url: None, @@ -174,6 +176,7 @@ impl HttpState { space_writer: None, space_reader: None, space_sync: None, + space_declaration_resolver: None, plc_service: None, jti_guard: JtiReplayGuard::new(100_000), rate_limiter: SlidingWindowLimiter::new(300, Duration::from_secs(60), 100_000), @@ -189,7 +192,6 @@ impl HttpState { pds_extra_signing_keys: Vec::new(), space_credential_ttl_secs: atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS, service_handle_domains: Vec::new(), - notify_membership_email: false, crawlers: Vec::new(), bsky_app_view_did: None, bsky_app_view_url: None, @@ -268,6 +270,21 @@ impl HttpState { self } + /// Attach a space-type declaration resolver. When set, a bare `space:` + /// grant's omitted-`collection` default expands to the declaration's + /// `collections` (spec line 413). Typically a + /// [`CachingSpaceDeclarationResolver`](crate::space::CachingSpaceDeclarationResolver) + /// wrapping a + /// [`NetworkSpaceDeclarationResolver`](crate::space::NetworkSpaceDeclarationResolver). + #[must_use] + pub fn with_space_declaration_resolver( + mut self, + resolver: Arc, + ) -> Self { + self.space_declaration_resolver = Some(resolver); + self + } + /// Attach moderation-service forwarding configuration. When both `did` and `url` are set, `createReport` mints a /// service-auth token (`aud=did`, `lxm=com.atproto.moderation.createReport`) /// and POSTs the report payload to `/xrpc/...`. Without these @@ -333,13 +350,6 @@ impl HttpState { self } - /// Enable membership-change email notifications. - #[must_use] - pub fn with_notify_membership_email(mut self, enabled: bool) -> Self { - self.notify_membership_email = enabled; - self - } - /// Set the crawler hostnames notified by `requestCrawl` (§11b). #[must_use] pub fn with_crawlers(mut self, crawlers: Vec) -> Self { diff --git a/crates/atproto-pds/src/notifier.rs b/crates/atproto-pds/src/notifier.rs index 9744a27..9904e75 100644 --- a/crates/atproto-pds/src/notifier.rs +++ b/crates/atproto-pds/src/notifier.rs @@ -1,12 +1,10 @@ -//! Spaces notifier — `notifyWrite` / `notifyMembership` outbound delivery. +//! Spaces notifier — `notifyWrite` outbound delivery. //! -//! Spaces -//! writes don't fan out via the public firehose; instead the owner's PDS -//! POSTs the signed `notifyWrite` (records) and `notifyMembership` (member -//! list) payloads to each consumer service that holds a current -//! `SpaceCredential`. The set of recipient services lives in the per-actor -//! `space_credential_recipient` table; delivery attempts (with retry + -//! exponential backoff) live in the shared `notify_attempt` DLQ. +//! Spaces writes don't fan out via the public firehose; instead the owner's +//! PDS POSTs the contentless `notifyWrite` payload to each consumer service +//! that holds a current `SpaceCredential`. The set of recipient services lives +//! in the per-actor `space_credential_recipient` table; delivery attempts +//! (with retry + exponential backoff) live in the shared `notify_attempt` DLQ. //! //! This module ships: //! @@ -56,29 +54,34 @@ impl AttemptState { /// Append a notify-attempt row. /// -/// `payload_cbor` is the dag-cbor-encoded request body the consumer will see; -/// `nsid` distinguishes `com.atproto.space.notifyWrite` vs -/// `com.atproto.space.notifyMembership` for the worker's POST. +/// `payload` is the request body the consumer will see (JSON for the +/// contentless notifyWrite); `nsid` selects the worker's POST URL; +/// `content_type` is the request `Content-Type`; `auth_token`, when present, +/// is delivered as a `Bearer` service-auth header. pub async fn enqueue_notification( pool: &SqlitePool, target_service_did: &str, target_endpoint: &str, - payload_cbor: Vec, + payload: Vec, nsid: &str, + content_type: &str, + auth_token: Option<&str>, ) -> PdsResult { let id = format!("n-{}-{}", Utc::now().timestamp_millis(), random_suffix(8)); let now = Utc::now().to_rfc3339(); sqlx::query( "INSERT INTO notify_attempt (id, target_service_did, target_endpoint, payload_cbor, nsid, - attempt_count, next_attempt_at, state) - VALUES (?, ?, ?, ?, ?, 0, ?, 'pending')", + content_type, auth_token, attempt_count, next_attempt_at, state) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 'pending')", ) .bind(&id) .bind(target_service_did) .bind(target_endpoint) - .bind(&payload_cbor) + .bind(&payload) .bind(nsid) + .bind(content_type) + .bind(auth_token) .bind(&now) .execute(pool) .await @@ -97,25 +100,43 @@ pub struct DueAttempt { pub target_service_did: String, /// HTTPS endpoint base of the receiving service. pub target_endpoint: String, - /// DAG-CBOR-encoded request body. + /// Request body bytes (DAG-CBOR or JSON depending on `content_type`). pub payload_cbor: Vec, /// XRPC NSID — appended to the endpoint to compose the POST URL. pub nsid: String, + /// Request `Content-Type` header. + pub content_type: String, + /// Optional `Bearer` service-auth token. + pub auth_token: Option, /// How many times we've already tried this row. pub attempt_count: u32, } +/// Raw row shape returned by the `due_now` query (id, service_did, endpoint, +/// payload, nsid, content_type, auth_token, attempt_count). +type DueRow = ( + String, + String, + String, + Vec, + String, + String, + Option, + i64, +); + /// Read all `pending` rows whose `next_attempt_at <= now`. Caller is /// responsible for marking each row as delivered/failed/retry after the POST. pub async fn due_now(pool: &SqlitePool, limit: u32) -> PdsResult> { let now = Utc::now().to_rfc3339(); let limit = limit.clamp(1, 1000); - let rows: Vec<(String, String, String, Vec, String, i64)> = sqlx::query_as( - "SELECT id, target_service_did, target_endpoint, payload_cbor, nsid, attempt_count - FROM notify_attempt - WHERE state = 'pending' AND next_attempt_at <= ? - ORDER BY next_attempt_at ASC - LIMIT ?", + let rows: Vec = sqlx::query_as( + "SELECT id, target_service_did, target_endpoint, payload_cbor, nsid, + content_type, auth_token, attempt_count + FROM notify_attempt + WHERE state = 'pending' AND next_attempt_at <= ? + ORDER BY next_attempt_at ASC + LIMIT ?", ) .bind(&now) .bind(limit as i64) @@ -126,14 +147,18 @@ pub async fn due_now(pool: &SqlitePool, limit: u32) -> PdsResult })?; Ok(rows .into_iter() - .map(|(id, did, endpoint, payload, nsid, attempts)| DueAttempt { - id, - target_service_did: did, - target_endpoint: endpoint, - payload_cbor: payload, - nsid, - attempt_count: attempts as u32, - }) + .map( + |(id, did, endpoint, payload, nsid, content_type, auth_token, attempts)| DueAttempt { + id, + target_service_did: did, + target_endpoint: endpoint, + payload_cbor: payload, + nsid, + content_type, + auth_token, + attempt_count: attempts as u32, + }, + ) .collect()) } @@ -250,13 +275,15 @@ impl Notifier { let mut delivered = 0u32; for attempt in due { let endpoint = format!("{}/xrpc/{}", attempt.target_endpoint, attempt.nsid); - let result = self + let mut req = self .client .post(&endpoint) - .header(reqwest::header::CONTENT_TYPE, "application/cbor") - .body(attempt.payload_cbor.clone()) - .send() - .await; + .header(reqwest::header::CONTENT_TYPE, attempt.content_type.clone()) + .body(attempt.payload_cbor.clone()); + if let Some(token) = attempt.auth_token.as_deref() { + req = req.bearer_auth(token); + } + let result = req.send().await; match result { Ok(resp) if resp.status().is_success() => { mark_delivered(pool, &attempt.id).await?; @@ -320,6 +347,8 @@ mod tests { "https://appview.example", b"payload-bytes".to_vec(), "com.atproto.space.notifyWrite", + "application/json", + None, ) .await .unwrap(); @@ -339,6 +368,8 @@ mod tests { "https://x.example", b"data".to_vec(), "com.atproto.space.notifyWrite", + "application/json", + None, ) .await .unwrap(); @@ -356,6 +387,8 @@ mod tests { "https://x.example", b"data".to_vec(), "com.atproto.space.notifyWrite", + "application/json", + None, ) .await .unwrap(); @@ -378,6 +411,8 @@ mod tests { "https://x.example", b"data".to_vec(), "com.atproto.space.notifyWrite", + "application/json", + None, ) .await .unwrap(); diff --git a/crates/atproto-pds/src/oauth/consent.rs b/crates/atproto-pds/src/oauth/consent.rs index 3a141bf..ee66f74 100644 --- a/crates/atproto-pds/src/oauth/consent.rs +++ b/crates/atproto-pds/src/oauth/consent.rs @@ -7,19 +7,25 @@ //! endpoint with `approve=true|false`. //! //! Hand-rolled HTML (no Askama dep) — same approach as the admin dashboard. -//! the page now ships **friendly scope -//! descriptions** so users see "Allow `app.example` to read your -//! `app.bsky.group/default` records" instead of opaque scope strings like -//! `space:read:app.bsky.group/default`. The `describe_scope` helper is -//! the single source of truth — a future Askama refactor (deferred polish) -//! drops it into a template directly. +//! The page ships **friendly scope descriptions** so users see readable text +//! instead of opaque scope strings. Space scopes follow the 0016 spec grammar +//! (`space:[?did&skey&collection&action&manage]`): the space type +//! renders its declaration `name` resolved from its `com.atproto.lexicon.schema` +//! record (NSID fallback; spec line 434), the owner DID renders its +//! bidirectionally-verified handle (DID fallback), and a prominent warning is +//! shown when an app requests access to every space on the network +//! (`type=* && did=*`). The `describe_scope` helper is the single source of +//! truth — a future Askama refactor (deferred polish) drops it into a +//! template directly. use crate::http::errors::XrpcError; use crate::http::state::HttpState; +use atproto_oauth::scopes::{Scope, SpaceCollection, SpaceDid, SpacePermission, SpaceType}; use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; +use std::collections::BTreeMap; /// Query params accepted by the consent page. #[derive(Debug, Deserialize)] @@ -51,16 +57,159 @@ pub async fn consent_page( ) })?; - let html = render_consent(&q.request_uri, &request.client_id, &request.scope); + // Best-effort: resolve the space-owner DIDs named in any `space:` scope to + // their bidirectionally-verified handles, so the consent screen can render + // a human-readable owner instead of an opaque DID. Failures fall back to + // the raw DID. + let handles = resolve_space_owner_handles(&state, &request.scope).await; + + // Best-effort: resolve the space-type NSIDs named in any `space:` scope to + // their declaration `name` (spec line 434). Failures fall back to the NSID. + let type_names = resolve_space_type_names(&state, &request.scope).await; + + let html = render_consent( + &q.request_uri, + &request.client_id, + &request.scope, + &handles, + &type_names, + ); Ok(Html(html).into_response()) } -fn render_consent(request_uri: &str, client_id: &str, scope: &str) -> String { +/// Collect the distinct, concrete owner DIDs referenced by `space:` scopes in +/// `scope`, resolve each to a bidirectionally-verified handle, and return the +/// DID→handle map. DIDs that fail verification are simply omitted (callers +/// fall back to the raw DID). +async fn resolve_space_owner_handles(state: &HttpState, scope: &str) -> BTreeMap { + let mut dids: Vec = Vec::new(); + for token in scope.split_whitespace() { + if let Ok(Scope::Space(perm)) = Scope::parse(token) + && let SpaceDid::Did(did) = &perm.did + && !dids.contains(did) + { + dids.push(did.clone()); + } + } + + let mut out = BTreeMap::new(); + for did in dids { + if let Some(handle) = verify_bidirectional_handle(state, &did).await { + out.insert(did, handle); + } + } + out +} + +/// Collect the distinct, concrete space-type NSIDs referenced by `space:` +/// scopes in `scope`, resolve each to its declaration `name` (spec line 434), +/// and return the NSID→name map. NSIDs that fail to resolve are omitted +/// (callers fall back to the raw NSID). +async fn resolve_space_type_names(state: &HttpState, scope: &str) -> BTreeMap { + let mut nsids: Vec = Vec::new(); + for token in scope.split_whitespace() { + if let Ok(Scope::Space(perm)) = Scope::parse(token) + && let SpaceType::Nsid(nsid) = &perm.space_type + && !nsids.contains(nsid) + { + nsids.push(nsid.clone()); + } + } + + let mut out = BTreeMap::new(); + for nsid in nsids { + if let Some(name) = resolve_space_declaration_name(state, &nsid).await { + out.insert(nsid, name); + } + } + out +} + +/// Resolve a space-type NSID to its declaration `name` for the consent screen +/// (spec lines 126, 434), via the shared +/// [`SpaceDeclarationResolver`](crate::space::SpaceDeclarationResolver) +/// configured on [`HttpState`]. +/// +/// This delegates to the same NSID → `com.atproto.lexicon.schema` resolution +/// path (with shared TTL caching) used by the OAuth `space:` scope gate, so the +/// consent UI and the gate never diverge. Returns `None` when no resolver is +/// configured, resolution fails, or the declaration has an empty `name` +/// (callers fall back to the raw NSID). +async fn resolve_space_declaration_name(state: &HttpState, nsid: &str) -> Option { + let declaration = state + .space_declaration_resolver + .as_ref()? + .resolve(nsid) + .await?; + (!declaration.name.is_empty()).then_some(declaration.name) +} + +/// Resolve `did` to its handle and verify the binding bidirectionally: +/// resolve the DID document, take its first `at://` `alsoKnownAs`, re-resolve +/// that handle, and require the result to equal `did`. Returns the handle on +/// success, `None` on any failure (network, missing handle, mismatch, or no +/// DNS resolver configured). +async fn verify_bidirectional_handle(state: &HttpState, did: &str) -> Option { + let dns_resolver = state.dns_resolver.clone()?; + let plc_hostname = state + .plc_service + .as_ref() + .map(|p| p.directory_hostname().to_string())?; + let http_client = reqwest::Client::builder() + .user_agent(crate::user_agent()) + .timeout(std::time::Duration::from_secs(5)) + .build() + .ok()?; + + let resolver = atproto_identity::resolve::InnerIdentityResolver { + dns_resolver, + http_client: http_client.clone(), + plc_hostname, + }; + + // DID document → first at:// handle. + let document = resolver.resolve(did).await.ok()?; + let handle = document + .also_known_as + .iter() + .find_map(|aka| aka.strip_prefix("at://"))? + .to_string(); + + // Re-resolve the handle and require it to round-trip back to `did`. + let resolved = + atproto_identity::resolve::resolve_handle(&http_client, dns_resolver_ref(state)?, &handle) + .await + .ok()?; + if resolved == did { + Some(handle) + } else { + tracing::debug!( + did, + handle, + resolved, + "consent: bidirectional handle verification mismatch; rendering DID" + ); + None + } +} + +/// Borrow the configured DNS resolver, if any. +fn dns_resolver_ref(state: &HttpState) -> Option<&dyn atproto_identity::traits::DnsResolver> { + state.dns_resolver.as_deref() +} + +fn render_consent( + request_uri: &str, + client_id: &str, + scope: &str, + handles: &BTreeMap, + type_names: &BTreeMap, +) -> String { let scopes_list: String = scope .split_whitespace() .map(|s| { let raw = html_escape(s); - let description = describe_scope(s); + let description = describe_scope(s, handles, type_names); format!( "
  • {raw}
    {}
  • ", html_escape(&description), @@ -68,6 +217,15 @@ fn render_consent(request_uri: &str, client_id: &str, scope: &str) -> String { }) .collect(); + // Loud warning when any space scope grants access to *every* space on the + // network (`type=* && did=*`) — the prominent-warning requirement of spec + // lines 437-438. + let universal_warning = if has_universal_space_scope(scope) { + r#"
    Warning: this application is requesting access to every space on the network. This is an extremely broad permission. Only grant it to applications you deeply trust.
    "# + } else { + "" + }; + format!( r#" @@ -93,6 +251,10 @@ fn render_consent(request_uri: &str, client_id: &str, scope: &str) -> String { .scopes ul {{ margin: 0.3em 0 0 1.2em; padding: 0; }} .scopes li {{ margin-bottom: 0.4em; }} .scope-desc {{ color: #555; font-size: 0.85em; margin-left: 0.2em; }} + .space-warning {{ + background: #fff4f4; border: 1px solid #e0b4b4; color: #7a1f1f; + border-radius: 6px; padding: 0.8em 1em; margin: 1em 0; font-size: 0.9em; + }} button {{ padding: 0.6em 1.2em; border: 0; border-radius: 4px; font-size: 1em; cursor: pointer; margin-right: 0.5em; @@ -107,6 +269,8 @@ fn render_consent(request_uri: &str, client_id: &str, scope: &str) -> String {

    Authorize {client_id_safe}

    Sign in to grant access to your account.

    + {universal_warning} +
    Requested scopes:
      {scopes_list}
    @@ -176,22 +340,45 @@ fn render_consent(request_uri: &str, client_id: &str, scope: &str) -> String { client_id_safe = html_escape(client_id), request_uri_safe = html_escape(request_uri), scopes_list = scopes_list, + universal_warning = universal_warning, ) } +/// `true` when any `space:` scope in `scope` grants access to every space on +/// the network — i.e. its `type` is `*` **and** its `did` is `*`. This is the +/// broad-grant condition that requires a prominent consent warning (spec lines +/// 437-438). +fn has_universal_space_scope(scope: &str) -> bool { + scope.split_whitespace().any(|token| { + matches!( + Scope::parse(token), + Ok(Scope::Space(perm)) + if perm.space_type == SpaceType::All && perm.did == SpaceDid::All + ) + }) +} + /// Convert a raw OAuth scope token into a human-readable description for the /// consent page. Recognizes: /// /// - `atproto` → "core atproto session — sign in to your account" /// - `transition:generic` → "all atproto records (legacy transition scope)" /// - `transition:chat.bsky` → "Bluesky chat records (legacy transition scope)" -/// - `space:read:/` → "read your Spaces records under /" -/// - `space:write:/` → "create and update your Spaces records under /" -/// - `space:admin:/` → "manage members of your Spaces under /" +/// - `space:[?did&skey&collection&action]` → a description built from the +/// real space-scope grammar via [`describe_space_scope`]. /// /// Unknown scopes fall back to a generic "request access to scope " /// string so the user still sees something readable. -pub fn describe_scope(scope: &str) -> String { +/// +/// `handles` maps space-owner DIDs to their bidirectionally-verified handles; +/// space scopes render the handle when present, the DID otherwise. +/// `type_names` maps space-type NSIDs to their resolved declaration names +/// (spec line 434); absent entries fall back to the raw NSID. +pub fn describe_scope( + scope: &str, + handles: &BTreeMap, + type_names: &BTreeMap, +) -> String { if scope == "atproto" { return "core atproto session — sign in to your account".to_string(); } @@ -202,26 +389,103 @@ pub fn describe_scope(scope: &str) -> String { other => format!("legacy transition scope `{other}`"), }; } - if let Some(rest) = scope.strip_prefix("space:") { - // Format: space::/ e.g. space:read:app.bsky.group/default - let mut parts = rest.splitn(2, ':'); - let verb = parts.next().unwrap_or(""); - let target = parts.next().unwrap_or(""); - let target_friendly = if target.is_empty() { - "all your Spaces" - } else { - target - }; - return match verb { - "read" => format!("read your Spaces records under `{target_friendly}`"), - "write" => format!("create and update your Spaces records under `{target_friendly}`"), - "admin" => format!("manage members of your Spaces under `{target_friendly}`"), - other => format!("`{other}` Spaces access under `{target_friendly}`"), + if scope == "space" || scope.starts_with("space:") || scope.starts_with("space?") { + return match Scope::parse(scope) { + Ok(Scope::Space(perm)) => describe_space_scope(&perm, handles, type_names), + // A `space:` prefix that fails to parse is a malformed scope; show + // it verbatim rather than a misleading description. + _ => format!("malformed Spaces scope `{scope}`"), }; } format!("request access to scope `{scope}`") } +/// Build a human-readable description of a parsed `space:` permission, using +/// the real grammar (`type`, `did`, `skey`, `collection`, `action`). +/// +/// - The space *type* renders its declaration name when resolvable, falling +/// back to the raw NSID (or "any space type" for the `*` wildcard). +/// - The *owner* renders its verified handle (from `handles`) when available, +/// the raw DID otherwise, or "any owner" for `*`. +/// - The *actions* render as a friendly verb list (read / create / update / +/// delete / manage). +pub fn describe_space_scope( + perm: &SpacePermission, + handles: &BTreeMap, + type_names: &BTreeMap, +) -> String { + let type_label = match &perm.space_type { + SpaceType::All => "any space type".to_string(), + SpaceType::Nsid(nsid) => space_type_declaration_name(nsid, type_names), + }; + + let owner_label = match &perm.did { + SpaceDid::All => "any owner".to_string(), + SpaceDid::Did(did) => handles + .get(did) + .map(|h| format!("@{h}")) + .unwrap_or_else(|| did.clone()), + }; + + // Record action verbs in canonical order (BTreeSet iteration is sorted by + // the SpaceAction Ord: read_self, read, create, update, delete). + let actions: Vec<&str> = perm.action.iter().map(|a| a.as_str()).collect(); + let actions_label = if actions.is_empty() { + "no".to_string() + } else { + actions.join(", ") + }; + + // Collections constrain write and read_self actions. `Default` defers to the + // declaration's collections (not enumerable here); an explicit empty list + // means no write targets. + let collections_label = match &perm.collection { + atproto_oauth::scopes::SpaceCollections::Default => { + Some("the space type's declared collections".to_string()) + } + atproto_oauth::scopes::SpaceCollections::Explicit(set) if set.is_empty() => None, + atproto_oauth::scopes::SpaceCollections::Explicit(set) => { + let names: Vec = set + .iter() + .map(|c| match c { + SpaceCollection::All => "any collection".to_string(), + SpaceCollection::Nsid(nsid) => nsid.clone(), + }) + .collect(); + Some(names.join(", ")) + } + }; + + let mut out = format!("{actions_label} access to {type_label} spaces owned by {owner_label}"); + match &perm.skey { + atproto_oauth::scopes::SpaceSkey::Key(skey) => { + out.push_str(&format!(" (space key `{skey}`)")); + } + atproto_oauth::scopes::SpaceSkey::All => {} + } + if let Some(cols) = collections_label { + out.push_str(&format!(", collections: {cols}")); + } + // Space-management verbs are a separate axis; surface them prominently. + if !perm.manage.is_empty() { + let verbs: Vec<&str> = perm.manage.iter().map(|m| m.as_str()).collect(); + out.push_str(&format!("; manage the spaces ({})", verbs.join(", "))); + } + out +} + +/// Render a space-type NSID's declaration `name` (spec line 434), falling back +/// to the raw NSID when the declaration could not be resolved. +/// +/// `type_names` is the NSID→declaration-name map produced by +/// [`resolve_space_type_names`]; absent entries fall back to the NSID. +fn space_type_declaration_name(nsid: &str, type_names: &BTreeMap) -> String { + type_names + .get(nsid) + .cloned() + .unwrap_or_else(|| nsid.to_string()) +} + fn html_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { @@ -241,12 +505,22 @@ fn html_escape(s: &str) -> String { mod tests { use super::*; + fn no_handles() -> BTreeMap { + BTreeMap::new() + } + + fn no_names() -> BTreeMap { + BTreeMap::new() + } + #[test] fn render_includes_client_id_and_scopes() { let html = render_consent( "urn:ietf:params:oauth:request_uri:abcd", "https://app.example/client-metadata.json", "atproto transition:generic", + &no_handles(), + &no_names(), ); assert!(html.contains("https://app.example/client-metadata.json")); assert!(html.contains("atproto")); @@ -258,7 +532,13 @@ mod tests { #[test] fn render_escapes_client_id_html() { - let html = render_consent("uri", "https://evil/