···7788## [Unreleased]
991010+## [0.15.0-alpha.2] - 2026-06-26
1111+### Changed
1212+- 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.
1313+- Unified space-declaration resolution across the OAuth consent screen and the `space:` scope gate behind a shared resolver.
1414+1515+### Removed
1616+- Permissioned-data member-sync machinery (`getMemberState` / `getMemberOplog` / `notifyMembership`); member-list management (`addMember` / `removeMember` / `listMembers`) is retained.
1717+1018## [0.15.0-alpha.1] - 2026-05-09
1119### Added
1220- 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).
···242250- Core DID document handling
243251- Cryptographic key operations for P-256 curves
244252253253+[0.15.0-alpha.2]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.15.0-alpha.2
245254[0.15.0-alpha.1]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.15.0-alpha.1
246255[0.14.6]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.6
247256[0.14.5]: https://tangled.org/ngerakines.me/atproto-crates/tree/v0.14.5
···521521 /// The expected namespace.
522522 namespace: String,
523523 },
524524+525525+ /// A space definition has a `name` whose length is outside the 1..=64 range.
526526+ #[error(
527527+ "error-atproto-lexicon-data-validation-56 Space name length out of range: expected 1..=64, got {length}"
528528+ )]
529529+ SpaceNameLengthInvalid {
530530+ /// The actual length of the name.
531531+ length: usize,
532532+ },
533533+534534+ /// A space definition's collection NSID is invalid.
535535+ #[error(
536536+ "error-atproto-lexicon-data-validation-57 Space has invalid collection NSID '{nsid}': {reason}"
537537+ )]
538538+ SpaceInvalidCollectionNsid {
539539+ /// The invalid NSID string.
540540+ nsid: String,
541541+ /// Description of why the NSID is invalid.
542542+ reason: String,
543543+ },
544544+545545+ /// A space permission is missing the required `spaceType` field.
546546+ #[error(
547547+ "error-atproto-lexicon-data-validation-58 Space permission missing required 'spaceType' field"
548548+ )]
549549+ SpacePermissionMissingSpaceType,
550550+551551+ /// A space permission uses the `*` wildcard for `spaceType`, which is not
552552+ /// allowed inside a permission set.
553553+ #[error(
554554+ "error-atproto-lexicon-data-validation-59 Space permission 'spaceType' must not be the '*' wildcard"
555555+ )]
556556+ SpacePermissionWildcardSpaceType,
524557}
···458458 ResponseBodyObjectParsingFailed,
459459}
460460461461+/// Error returned when a required OAuth permission scope is missing.
462462+///
463463+/// Mirrors the reference `ScopeMissingError` from `@atproto/oauth-scopes`. The
464464+/// embedded scope string is the minimal scope that would have satisfied the
465465+/// attempted operation, as produced by the relevant `scope_needed_for` helper.
466466+#[derive(Debug, Clone, PartialEq, Eq, Error)]
467467+#[error("error-atproto-oauth-scope-1 Missing required scope: {scope}")]
468468+pub struct ScopeMissingError {
469469+ /// The minimal scope string that would satisfy the attempted operation.
470470+ pub scope: String,
471471+}
472472+473473+impl ScopeMissingError {
474474+ /// Create a new [`ScopeMissingError`] for the given required scope string.
475475+ pub fn new(scope: impl Into<String>) -> Self {
476476+ ScopeMissingError {
477477+ scope: scope.into(),
478478+ }
479479+ }
480480+}
481481+461482/// Error types that can occur when working with OAuth request storage operations
462483#[derive(Debug, Error)]
463484pub enum OAuthStorageError {
+289
crates/atproto-oauth/src/scopes.rs
···2121use std::fmt;
2222use std::str::FromStr;
23232424+/// `space:` permission scope (AT Protocol permissioned-data spaces).
2525+pub mod space_permission;
2626+2727+pub use space_permission::{
2828+ SpaceAction, SpaceCollection, SpaceCollections, SpaceDid, SpaceManageTarget, SpaceManageVerb,
2929+ SpacePermission, SpaceSkey, SpaceTarget, SpaceType,
3030+};
3131+2432/// Represents an AT Protocol OAuth scope
2533#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2634pub enum Scope {
···3442 Repo(RepoScope),
3543 /// RPC scope for method access
3644 Rpc(RpcScope),
4545+ /// Space scope for permissioned-data space operations
4646+ Space(SpacePermission),
3747 /// AT Protocol scope - required to indicate that other AT Protocol scopes will be used
3848 Atproto,
3949 /// Transition scope for migration operations
···319329 "blob",
320330 "repo",
321331 "rpc",
332332+ "space",
322333 "atproto",
323334 "transition",
324335 "include",
···359370 "blob" => Self::parse_blob(suffix),
360371 "repo" => Self::parse_repo(suffix),
361372 "rpc" => Self::parse_rpc(suffix),
373373+ "space" => Self::parse_space(suffix),
362374 "atproto" => Self::parse_atproto(suffix),
363375 "transition" => Self::parse_transition(suffix),
364376 "include" => Self::parse_include(suffix),
···566578 Ok(Scope::Rpc(RpcScope { lxm, aud }))
567579 }
568580581581+ fn parse_space(suffix: Option<&str>) -> Result<Self, ParseError> {
582582+ Ok(Scope::Space(SpacePermission::parse_suffix(suffix)?))
583583+ }
584584+569585 fn parse_atproto(suffix: Option<&str>) -> Result<Self, ParseError> {
570586 if suffix.is_some() {
571587 return Err(ParseError::InvalidResource(
···765781 }
766782 }
767783 }
784784+ Scope::Space(scope) => scope.to_scope_string(),
768785 Scope::Atproto => "atproto".to_string(),
769786 Scope::Transition(scope) => match scope {
770787 TransitionScope::Generic => "transition:generic".to_string(),
···873890874891 lxm_match && aud_match
875892 }
893893+ // Space scopes only grant themselves (exact match). Subset-based
894894+ // reduction is intentionally not performed for spaces; this keeps
895895+ // `parse_multiple_reduced` sound (it never drops a distinct grant).
896896+ (Scope::Space(a), Scope::Space(b)) => a == b,
876897 _ => false,
877898 }
878899 }
···10141035}
1015103610161037impl std::error::Error for ParseError {}
10381038+10391039+/// A set of granted OAuth scopes that can be queried for permission matches.
10401040+///
10411041+/// Mirrors the reference `ScopesSet` / `ScopePermissions`: it stores the raw
10421042+/// granted scope strings and, on demand, parses the ones relevant to a given
10431043+/// resource to evaluate whether a request is allowed.
10441044+///
10451045+/// Scope strings that fail to parse are ignored during matching (they simply
10461046+/// cannot grant anything), matching the reference where `fromString` returning
10471047+/// `null` means the scope does not contribute to a match.
10481048+#[derive(Debug, Clone, Default, PartialEq, Eq)]
10491049+pub struct ScopesSet {
10501050+ scopes: Vec<String>,
10511051+}
10521052+10531053+impl ScopesSet {
10541054+ /// Create an empty scope set.
10551055+ pub fn new() -> Self {
10561056+ ScopesSet::default()
10571057+ }
10581058+10591059+ /// Build a scope set from a space-separated OAuth scope string.
10601060+ pub fn from_scope_string(scope: &str) -> Self {
10611061+ ScopesSet {
10621062+ scopes: scope.split_whitespace().map(|s| s.to_string()).collect(),
10631063+ }
10641064+ }
10651065+10661066+ /// Build a scope set from an iterator of individual scope strings.
10671067+ pub fn from_scopes<I, S>(scopes: I) -> Self
10681068+ where
10691069+ I: IntoIterator<Item = S>,
10701070+ S: Into<String>,
10711071+ {
10721072+ ScopesSet {
10731073+ scopes: scopes.into_iter().map(Into::into).collect(),
10741074+ }
10751075+ }
10761076+10771077+ /// Add a scope string to the set.
10781078+ pub fn insert(&mut self, scope: impl Into<String>) {
10791079+ self.scopes.push(scope.into());
10801080+ }
10811081+10821082+ /// The raw granted scope strings.
10831083+ pub fn scopes(&self) -> &[String] {
10841084+ &self.scopes
10851085+ }
10861086+10871087+ /// Returns `true` if any granted `space:` scope satisfies the given record
10881088+ /// target. An omitted-`collection` grant confers no write targets (the
10891089+ /// `spaceType=*` / no-declaration case); use
10901090+ /// [`allows_space_with`](Self::allows_space_with) to resolve the collection
10911091+ /// default against a space type declaration's collections.
10921092+ pub fn allows_space(&self, target: &SpaceTarget) -> bool {
10931093+ self.allows_space_with(target, &[])
10941094+ }
10951095+10961096+ /// Like [`allows_space`](Self::allows_space) but resolves the per-grant
10971097+ /// `collection` default against `declared` (the space type declaration's
10981098+ /// `collections`) per spec line 413.
10991099+ pub fn allows_space_with(&self, target: &SpaceTarget, declared: &[String]) -> bool {
11001100+ self.scopes.iter().any(|scope| {
11011101+ matches!(Scope::parse(scope), Ok(Scope::Space(permission)) if permission.matches_with(target, declared))
11021102+ })
11031103+ }
11041104+11051105+ /// Returns `true` if any granted `space:` scope satisfies the given
11061106+ /// space-management target (spec lines 415-419).
11071107+ pub fn allows_space_manage(&self, target: &SpaceManageTarget) -> bool {
11081108+ self.scopes.iter().any(|scope| {
11091109+ matches!(Scope::parse(scope), Ok(Scope::Space(permission)) if permission.matches_manage(target))
11101110+ })
11111111+ }
11121112+11131113+ /// Asserts that some granted `space:` scope satisfies the given record
11141114+ /// target, returning a [`ScopeMissingError`](crate::errors::ScopeMissingError)
11151115+ /// carrying the minimal scope that would satisfy it otherwise.
11161116+ pub fn assert_space(
11171117+ &self,
11181118+ target: &SpaceTarget,
11191119+ ) -> Result<(), crate::errors::ScopeMissingError> {
11201120+ self.assert_space_with(target, &[])
11211121+ }
11221122+11231123+ /// Like [`assert_space`](Self::assert_space) but resolves the collection
11241124+ /// default against `declared` (spec line 413).
11251125+ pub fn assert_space_with(
11261126+ &self,
11271127+ target: &SpaceTarget,
11281128+ declared: &[String],
11291129+ ) -> Result<(), crate::errors::ScopeMissingError> {
11301130+ if self.allows_space_with(target, declared) {
11311131+ Ok(())
11321132+ } else {
11331133+ Err(crate::errors::ScopeMissingError::new(
11341134+ SpacePermission::scope_needed_for(target),
11351135+ ))
11361136+ }
11371137+ }
11381138+11391139+ /// Asserts that some granted `space:` scope satisfies the given
11401140+ /// space-management target (spec lines 415-419).
11411141+ pub fn assert_space_manage(
11421142+ &self,
11431143+ target: &SpaceManageTarget,
11441144+ ) -> Result<(), crate::errors::ScopeMissingError> {
11451145+ if self.allows_space_manage(target) {
11461146+ Ok(())
11471147+ } else {
11481148+ Err(crate::errors::ScopeMissingError::new(
11491149+ SpacePermission::scope_needed_for_manage(target),
11501150+ ))
11511151+ }
11521152+ }
11531153+}
1017115410181155#[cfg(test)]
10191156mod tests {
···22162353 let serialized = scope.to_string_normalized();
22172354 let reparsed = Scope::parse(&serialized).unwrap();
22182355 assert_eq!(scope, reparsed);
23562356+ }
23572357+23582358+ #[test]
23592359+ fn test_space_scope_parsing_dispatch() {
23602360+ // The `space` prefix is recognized and dispatched (no longer an
23612361+ // UnknownPrefix error).
23622362+ let scope = Scope::parse("space:com.example.space").unwrap();
23632363+ assert!(matches!(scope, Scope::Space(_)));
23642364+23652365+ // A bare `space` without a type is an error, not UnknownPrefix.
23662366+ assert!(matches!(
23672367+ Scope::parse("space"),
23682368+ Err(ParseError::MissingResource)
23692369+ ));
23702370+ }
23712371+23722372+ #[test]
23732373+ fn test_space_scope_normalization() {
23742374+ let tests = vec![
23752375+ ("space:com.example.space", "space:com.example.space"),
23762376+ ("space:*", "space:*"),
23772377+ // Explicit defaults stripped.
23782378+ (
23792379+ "space:com.example.space?did=*&skey=*",
23802380+ "space:com.example.space",
23812381+ ),
23822382+ (
23832383+ "space:com.example.space?action=read&action=create&action=update&action=delete",
23842384+ "space:com.example.space",
23852385+ ),
23862386+ (
23872387+ "space:com.example.space?did=did:plc:abc&action=read",
23882388+ "space:com.example.space?did=did:plc:abc&action=read",
23892389+ ),
23902390+ // `manage` is a separate parameter, preserved on normalization.
23912391+ (
23922392+ "space:com.example.space?manage=update&manage=delete",
23932393+ "space:com.example.space?manage=update&manage=delete",
23942394+ ),
23952395+ ];
23962396+23972397+ for (input, expected) in tests {
23982398+ let scope = Scope::parse(input).unwrap();
23992399+ assert_eq!(scope.to_string_normalized(), expected, "input: {input}");
24002400+ }
24012401+ }
24022402+24032403+ #[test]
24042404+ fn test_space_scope_grants_self_only() {
24052405+ let a = Scope::parse("space:com.example.space?action=read").unwrap();
24062406+ let b = Scope::parse("space:com.example.space?action=read").unwrap();
24072407+ let c = Scope::parse("space:com.example.space?manage=update").unwrap();
24082408+ let account = Scope::parse("account:email").unwrap();
24092409+24102410+ assert!(a.grants(&b));
24112411+ assert!(!a.grants(&c));
24122412+ assert!(!a.grants(&account));
24132413+ assert!(!account.grants(&a));
24142414+ }
24152415+24162416+ #[test]
24172417+ fn test_space_scope_in_serialize_multiple() {
24182418+ let scopes = vec![
24192419+ Scope::parse("space:com.example.space?action=read").unwrap(),
24202420+ Scope::Atproto,
24212421+ Scope::parse("account:email").unwrap(),
24222422+ ];
24232423+ assert_eq!(
24242424+ Scope::serialize_multiple(&scopes),
24252425+ "account:email atproto space:com.example.space?action=read"
24262426+ );
24272427+ }
24282428+24292429+ #[test]
24302430+ fn test_scopes_set_allows_space() {
24312431+ let set = ScopesSet::from_scope_string(
24322432+ "atproto space:com.example.space?did=did:plc:abc&skey=s1&collection=com.example.note&action=read&action=create",
24332433+ );
24342434+24352435+ // read is allowed.
24362436+ assert!(set.allows_space(&SpaceTarget::new(
24372437+ "com.example.space",
24382438+ "did:plc:abc",
24392439+ "s1",
24402440+ SpaceAction::Read,
24412441+ )));
24422442+24432443+ // create on covered collection is allowed.
24442444+ assert!(set.allows_space(&SpaceTarget::with_collection(
24452445+ "com.example.space",
24462446+ "did:plc:abc",
24472447+ "s1",
24482448+ SpaceAction::Create,
24492449+ "com.example.note",
24502450+ )));
24512451+24522452+ // delete is not granted.
24532453+ assert!(!set.allows_space(&SpaceTarget::with_collection(
24542454+ "com.example.space",
24552455+ "did:plc:abc",
24562456+ "s1",
24572457+ SpaceAction::Delete,
24582458+ "com.example.note",
24592459+ )));
24602460+24612461+ // a different space key is not granted.
24622462+ assert!(!set.allows_space(&SpaceTarget::new(
24632463+ "com.example.space",
24642464+ "did:plc:abc",
24652465+ "other",
24662466+ SpaceAction::Read,
24672467+ )));
24682468+ }
24692469+24702470+ #[test]
24712471+ fn test_scopes_set_assert_space() {
24722472+ let set = ScopesSet::from_scope_string("space:com.example.space?action=read");
24732473+24742474+ // Satisfied: Ok.
24752475+ assert!(
24762476+ set.assert_space(&SpaceTarget::new(
24772477+ "com.example.space",
24782478+ "did:plc:abc",
24792479+ "s1",
24802480+ SpaceAction::Read,
24812481+ ))
24822482+ .is_ok()
24832483+ );
24842484+24852485+ // Not satisfied: returns the minimal needed scope.
24862486+ let target = SpaceTarget::with_collection(
24872487+ "com.example.space",
24882488+ "did:plc:abc",
24892489+ "s1",
24902490+ SpaceAction::Create,
24912491+ "com.example.note",
24922492+ );
24932493+ let err = set.assert_space(&target).unwrap_err();
24942494+ assert_eq!(err.scope, SpacePermission::scope_needed_for(&target));
24952495+ assert!(err.to_string().contains("error-atproto-oauth-scope-1"));
24962496+ }
24972497+24982498+ #[test]
24992499+ fn test_scopes_set_ignores_unparseable_scopes() {
25002500+ // A non-space and a malformed scope are simply ignored for matching.
25012501+ let set = ScopesSet::from_scopes(["account:email", "space:com.example.space?action=read"]);
25022502+ assert!(set.allows_space(&SpaceTarget::new(
25032503+ "com.example.space",
25042504+ "did:plc:abc",
25052505+ "s1",
25062506+ SpaceAction::Read,
25072507+ )));
22192508 }
22202509}
···7070 uri TEXT PRIMARY KEY,
7171 is_owner INTEGER NOT NULL DEFAULT 0,
7272 is_member INTEGER NOT NULL DEFAULT 0,
7373- created_at TEXT NOT NULL
7373+ created_at TEXT NOT NULL,
7474+ -- simplespace config (com.atproto.simplespace.defs#spaceConfig).
7575+ -- mint_policy: 'public' | 'member-list' (default) | 'managing-app'.
7676+ mint_policy TEXT NOT NULL DEFAULT 'member-list',
7777+ -- app_access: JSON form of the #open / #allowList open union.
7878+ app_access TEXT NOT NULL DEFAULT '{"type":"open"}',
7979+ -- managing_app: optional service identifier of the managing application.
8080+ managing_app TEXT,
8181+ -- deleted_at: tombstone timestamp; non-NULL means the space is deleted
8282+ -- and all reads/writes must fail with SpaceNotFound.
8383+ deleted_at TEXT
7484);
75857686CREATE TABLE space_member_state (
···127137 PRIMARY KEY (space, rev, idx)
128138);
129139140140+-- Notify subscriptions (recipients of notifyWrite / notifyMembership fan-out).
141141+--
142142+-- A row may be registered two ways:
143143+-- * `getSpaceCredential` — owner records the credential consumer so future
144144+-- commits fan out to it (whole-space; `repo` IS NULL).
145145+-- * `registerNotify` — a space credential holder subscribes an endpoint, either
146146+-- to the whole space (`repo` IS NULL, space-host case) or to a single repo
147147+-- (`repo` = that account's DID, repo-host case).
148148+--
149149+-- `expires_at` is the registration lifetime (RFC 3339). NULL means no expiry
150150+-- (the `getSpaceCredential` self-registration path leaves it unset). Uniqueness
151151+-- is keyed on (space, repo, service_did) so per-repo and whole-space
152152+-- subscriptions for the same service coexist; `repo` uses the empty string as
153153+-- the "whole space" sentinel so it participates in the PRIMARY KEY (SQLite
154154+-- treats NULL as distinct in unique constraints, which would defeat upserts).
130155CREATE TABLE space_credential_recipient (
131156 space TEXT NOT NULL REFERENCES space(uri) ON DELETE CASCADE,
157157+ repo TEXT NOT NULL DEFAULT '',
132158 service_did TEXT NOT NULL,
133159 service_endpoint TEXT NOT NULL,
134160 last_issued_at TEXT NOT NULL,
135135- PRIMARY KEY (space, service_did)
161161+ expires_at TEXT,
162162+ PRIMARY KEY (space, repo, service_did)
136163);
···111111 target_endpoint TEXT NOT NULL,
112112 payload_cbor BYTEA NOT NULL,
113113 nsid TEXT NOT NULL,
114114+ content_type TEXT NOT NULL DEFAULT 'application/cbor',
115115+ auth_token TEXT,
114116 attempt_count BIGINT NOT NULL DEFAULT 0,
115117 last_attempt_at TEXT,
116118 next_attempt_at TEXT NOT NULL,
···281281 buf
282282}
283283284284-/// Strict-greater oplog cursor: `<space_uri>\0<rev>\0\xFF` so iteration
285285-/// resumes from the next rev forward.
286286-#[must_use]
287287-pub fn oplog_after_rev(space_uri: &str, rev: &str) -> Vec<u8> {
288288- let mut buf = Vec::with_capacity(space_uri.len() + rev.len() + 3);
289289- buf.extend_from_slice(space_uri.as_bytes());
290290- buf.push(0);
291291- buf.extend_from_slice(rev.as_bytes());
292292- buf.push(0);
293293- buf.push(0xFF);
294294- buf
295295-}
296296-297284// ---------------------------------------------------------------------------
298285// Public-realm key encodings (§1.2). Each row keys a per-DID prefix so
299286// range scans by DID are cheap and a single fjall keyspace can hold all
···481468 }
482469483470 #[test]
484484- fn oplog_after_rev_skips_current_rev() {
485485- let mid = oplog_key("ats://x/y/z", "abc", 0);
486486- let cursor = oplog_after_rev("ats://x/y/z", "abc");
487487- assert!(cursor > mid);
488488- let next_rev_first = oplog_key("ats://x/y/z", "abd", 0);
489489- assert!(cursor < next_rev_first);
471471+ fn oplog_key_orders_across_revs() {
472472+ // A composite `(rev, idx)` cursor resumes at the exact op key, so the
473473+ // last idx of one rev sorts before the first idx of the next rev.
474474+ let rev_a_last = oplog_key("ats://x/y/z", "abc", 99);
475475+ let rev_b_first = oplog_key("ats://x/y/z", "abd", 0);
476476+ assert!(rev_a_last < rev_b_first);
490477 }
491478}
···88use atproto_space::SpaceError;
99use atproto_space::errors::SpaceResult;
1010use atproto_space::storage::{
1111- OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, RepoState,
1212- SpaceRepoStorage,
1111+ OplogCursor, OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow,
1212+ RepoState, SpaceRepoStorage,
1313};
1414use atproto_space::types::SpaceUri;
1515use sqlx::SqlitePool;
···251251 async fn read_oplog(
252252 &self,
253253 space: &SpaceUri,
254254- since: Option<&str>,
254254+ since: Option<&OplogCursor>,
255255 limit: u32,
256256 ) -> SpaceResult<OplogPage> {
257257 let limit = limit.clamp(1, 1000);
···264264 Option<String>,
265265 Option<String>,
266266 )> = match since {
267267- Some(s) => {
267267+ Some(cur) => {
268268+ // `(rev, idx) > (since.rev, since.idx)` so an atomic batch
269269+ // (entries sharing a rev) that exceeds `limit` pages fully.
268270 sqlx::query_as(
269271 "SELECT rev, idx, action, collection, rkey, cid, prev
270270- FROM space_record_oplog WHERE space = ? AND rev > ?
272272+ FROM space_record_oplog
273273+ WHERE space = ? AND (rev > ? OR (rev = ? AND idx > ?))
271274 ORDER BY rev ASC, idx ASC LIMIT ?",
272275 )
273276 .bind(space.to_string())
274274- .bind(s)
277277+ .bind(&cur.rev)
278278+ .bind(&cur.rev)
279279+ .bind(cur.idx as i64)
275280 .bind(limit as i64)
276281 .fetch_all(&self.pool)
277282 .await
···313318mod tests {
314319 use super::*;
315320 use crate::actor_store::sql::SqlActorStore;
316316- use atproto_space::set_hash::{SetHash, XorSha256SetHash};
321321+ use atproto_space::set_hash::{LtHash, SetHash};
317322 use atproto_space::space_repo::{Op, OpAction, SpaceRepo};
318323 use atproto_space::types::{SpaceKey, SpaceType};
319324···330335 SqlSpaceRepoStorage::new(store.pool().clone())
331336 }
332337333333- type TestRepo = SpaceRepo<SqlSpaceRepoStorage, XorSha256SetHash>;
338338+ type TestRepo = SpaceRepo<SqlSpaceRepoStorage, LtHash>;
334339335340 #[tokio::test(flavor = "multi_thread")]
336341 async fn create_and_read_record() {
···390395 assert_eq!(page.ops[1].idx, 1);
391396 }
392397398398+ /// Regression: a single atomic batch larger than the page `limit` pages
399399+ /// fully via the SQL `(rev > ? OR (rev = ? AND idx > ?))` predicate, with no
400400+ /// tail skipped. A bare-rev `rev > ?` cursor would skip ops `limit..N`.
401401+ #[tokio::test(flavor = "multi_thread")]
402402+ async fn batch_larger_than_limit_pages_fully() {
403403+ let space = test_space();
404404+ let repo: TestRepo = SpaceRepo::new(space.clone(), fresh_storage().await);
405405+406406+ const N: usize = 7;
407407+ const LIMIT: u32 = 3;
408408+409409+ let ops: Vec<Op> = (0..N)
410410+ .map(|i| Op {
411411+ action: OpAction::Create,
412412+ collection: "c".to_string(),
413413+ rkey: format!("k{i}"),
414414+ cid: Some(format!("cid{i}")),
415415+ value: Some(vec![]),
416416+ })
417417+ .collect();
418418+ let prepared = repo.format_commit(&ops).await.unwrap();
419419+ repo.apply_commit(prepared).await.unwrap();
420420+421421+ let mut seen: Vec<(String, u32)> = Vec::new();
422422+ let mut cursor: Option<OplogCursor> = None;
423423+ loop {
424424+ let page = repo.read_oplog(cursor.as_ref(), LIMIT).await.unwrap();
425425+ for op in &page.ops {
426426+ seen.push((op.rev.clone(), op.idx));
427427+ }
428428+ let caught_up = (page.ops.len() as u32) < LIMIT;
429429+ cursor = page
430430+ .ops
431431+ .last()
432432+ .map(|o| OplogCursor::new(o.rev.clone(), o.idx));
433433+ if caught_up {
434434+ break;
435435+ }
436436+ }
437437+438438+ assert_eq!(seen.len(), N, "all batch ops delivered, none skipped");
439439+ let rev = &seen[0].0;
440440+ for (i, (r, idx)) in seen.iter().enumerate() {
441441+ assert_eq!(r, rev, "all ops share the batch rev");
442442+ assert_eq!(*idx as usize, i, "idx is dense and monotonic");
443443+ }
444444+ }
445445+393446 #[tokio::test(flavor = "multi_thread")]
394447 async fn list_records_paginates() {
395448 let space = test_space();
···454507 assert!(repo.get_record("c", "k").await.unwrap().is_none());
455508 }
456509510510+ /// The persisted lattice state round-trips through
511511+ /// `state_bytes()`/`from_state_bytes()`, preserving both state and digest.
457512 #[test]
458458- fn xor_set_hash_matches_in_memory() {
459459- let mut h1 = XorSha256SetHash::empty();
460460- let mut h2 = XorSha256SetHash::empty();
461461- h1.add(b"x");
462462- h2.add(b"x");
463463- assert_eq!(h1.digest(), h2.digest());
513513+ fn set_hash_state_round_trips() {
514514+ let mut h = LtHash::empty();
515515+ h.add(b"x");
516516+ let state = h.state_bytes();
517517+ let rehydrated = LtHash::from_state_bytes(&state).unwrap();
518518+ assert_eq!(rehydrated.state_bytes(), state);
519519+ assert_eq!(rehydrated.digest(), h.digest());
464520 }
465521}
+1-1
crates/atproto-pds/src/admin/handlers.rs
···762762 format!("invalid space URI: {e}"),
763763 )
764764 })?;
765765- let store = crate::actor_store::sql::SqlActorStore::open(manager.data_dir(), &space.owner_did)
765765+ let store = crate::actor_store::sql::SqlActorStore::open(manager.data_dir(), &space.space_did)
766766 .await
767767 .map_err(XrpcError::from)?;
768768 if input.takedown {
+30-14
crates/atproto-pds/src/bin/pds.rs
···195195 /// loop walks each per-actor store on each tick and prunes oplog
196196 /// rows whose TID sorts below the cutoff. `0` disables the sweep.
197197 /// Receivers that lag behind this window must re-sync via
198198- /// `getRepoState` + `exportSpaces`.
198198+ /// `getRepoState`.
199199 #[arg(
200200 long,
201201 env = "PDS_SPACE_OPLOG_RETENTION_DAYS",
···225225 space_notify_retry_max_attempts: u32,
226226227227 /// SpaceCredential TTL in seconds. Default
228228- /// 10800 (3h, matching `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS`).
228228+ /// 7200 (2h, matching `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS`).
229229 #[arg(
230230 long,
231231 env = "PDS_SPACE_CREDENTIAL_TTL_SECONDS",
···240240 /// accepted (back-compat for dev / test deployments).
241241 #[arg(long, env = "PDS_SERVICE_HANDLE_DOMAINS", value_delimiter = ',')]
242242 service_handle_domains: Vec<String>,
243243-244244- /// When set, `space.addMember` / `space.removeMember` send an
245245- /// notification email to the affected member via `EmailService`
246246- /// The flag is a sentinel — set to any
247247- /// truthy value (`1`, `true`) to enable.
248248- #[arg(long, env = "PDS_NOTIFY_MEMBERSHIP_EMAIL", default_value_t = false)]
249249- notify_membership_email: bool,
250243251244 /// Comma-separated list of crawler hostnames to notify via
252245 /// `com.atproto.sync.requestCrawl`. Each
···457450 args.data_dir.clone(),
458451 account_manager.clone(),
459452 ));
460460- let space_writer = Arc::new(SpaceWriter::new(
461461- account_manager.clone(),
462462- args.data_dir.clone(),
463463- ));
453453+ let space_writer = Arc::new(
454454+ SpaceWriter::new(account_manager.clone(), args.data_dir.clone())
455455+ .with_plc_directory(args.plc_directory.clone()),
456456+ );
464457 let space_reader = Arc::new(SpaceReader::new(
465458 account_manager.clone(),
466459 args.data_dir.clone(),
···599592 .with_public_realm_backend(public_realm_backend.clone())
600593 .with_space_credential_ttl(args.space_credential_ttl_seconds)
601594 .with_service_handle_domains(args.service_handle_domains.clone())
602602- .with_notify_membership_email(args.notify_membership_email)
603595 .with_crawlers(args.crawlers.clone())
604596 // Persist OAuth in-flight state (PAR / auth-codes / refresh handles)
605597 // to the accounts DB so the lifecycle survives PDS restart. See
···620612 }
621613 #[cfg(feature = "hickory-dns")]
622614 {
615615+ // Space-type declaration resolver (NSID → declared `collections`) for
616616+ // the bare `space:` grant default (spec line 413). Reuses the same
617617+ // DNS resolver + PLC hostname as handle resolution; results are
618618+ // TTL-cached to avoid resolving on every authorization check.
619619+ let plc_hostname = args
620620+ .plc_directory
621621+ .as_deref()
622622+ .and_then(|url| url.split("://").nth(1).map(|h| h.to_string()))
623623+ .unwrap_or_else(|| "plc.directory".to_string());
624624+ let decl_http = reqwest::Client::builder()
625625+ .user_agent(user_agent())
626626+ .timeout(std::time::Duration::from_secs(5))
627627+ .build()
628628+ .unwrap_or_default();
629629+ let network = atproto_pds::space::NetworkSpaceDeclarationResolver::new(
630630+ dns_resolver.clone(),
631631+ plc_hostname,
632632+ decl_http,
633633+ );
634634+ let cached = atproto_pds::space::CachingSpaceDeclarationResolver::new(
635635+ Arc::new(network),
636636+ std::time::Duration::from_secs(300),
637637+ );
638638+ state = state.with_space_declaration_resolver(Arc::new(cached));
623639 state = state.with_dns_resolver(dns_resolver);
624640 }
625641 if let (Some(did), Some(url)) = (
+17
crates/atproto-pds/src/errors.rs
···5353 what: String,
5454 },
55555656+ /// error-atproto-pds-space-2: the referenced space does not exist or has
5757+ /// been tombstoned (`deleted_at IS NOT NULL`). Surfaced to clients as the
5858+ /// `SpaceNotFound` XRPC error.
5959+ #[error("error-atproto-pds-space-2 space not found: {uri}")]
6060+ SpaceNotFound {
6161+ /// URI of the missing or deleted space.
6262+ uri: String,
6363+ },
6464+6565+ /// error-atproto-pds-space-3: the caller is not the owner of the space.
6666+ /// Surfaced to clients as the `NotSpaceOwner` XRPC error.
6767+ #[error("error-atproto-pds-space-3 not the space owner: {uri}")]
6868+ NotSpaceOwner {
6969+ /// URI of the space.
7070+ uri: String,
7171+ },
7272+5673 /// error-atproto-pds-account-1: account state transition rejected.
5774 #[error("error-atproto-pds-account-1 invalid account state transition: {from} -> {to}")]
5875 InvalidAccountTransition {
+1-1
crates/atproto-pds/src/gc.rs
···3232pub const DEFAULT_NOTIFY_FAILED_RETENTION_DAYS: i64 = 30;
3333/// Default retention for `space_*_oplog` rows.
3434/// Receivers that lag behind this window need a full re-sync via
3535-/// `getRepoState` + `exportSpaces`. Operators tighten via
3535+/// `getRepoState`. Operators tighten via
3636/// `PDS_SPACE_OPLOG_RETENTION_DAYS`.
3737pub const DEFAULT_SPACE_OPLOG_RETENTION_DAYS: i64 = 30;
3838
+33
crates/atproto-pds/src/http/auth.rs
···6060 }
6161 }
62626363+ /// OAuth `client_id` of the requesting app, when the token is an OAuth
6464+ /// access token. `None` for app-password sessions (which have no
6565+ /// associated OAuth client).
6666+ #[must_use]
6767+ pub fn client_id(&self) -> Option<&str> {
6868+ match self {
6969+ AuthSubject::AppPassword(_) => None,
7070+ AuthSubject::OAuth(c) => Some(&c.client_id),
7171+ }
7272+ }
7373+6374 /// `true` when this is an OAuth token bound to a DPoP key (the
6475 /// `cnf.jkt` claim is present).
6576 #[must_use]
6677 pub fn is_dpop_bound(&self) -> bool {
6778 matches!(self, AuthSubject::OAuth(c) if c.cnf.is_some())
7979+ }
8080+8181+ /// `true` when this is an OAuth access token (as opposed to an
8282+ /// app-password session). Space scope gating only applies to OAuth
8383+ /// tokens — app-password sessions carry no `space:` grants and so can
8484+ /// never satisfy [`assert_space`](atproto_oauth::scopes::ScopesSet::assert_space).
8585+ #[must_use]
8686+ pub fn is_oauth(&self) -> bool {
8787+ matches!(self, AuthSubject::OAuth(_))
8888+ }
8989+9090+ /// The granted OAuth scope set, parsed from the access token's `scope`
9191+ /// claim. Returns an empty set for app-password sessions (which have no
9292+ /// OAuth scope string), so callers can uniformly run
9393+ /// [`assert_space`](atproto_oauth::scopes::ScopesSet::assert_space)
9494+ /// against it — an empty set simply satisfies nothing.
9595+ #[must_use]
9696+ pub fn scopes(&self) -> atproto_oauth::scopes::ScopesSet {
9797+ match self {
9898+ AuthSubject::AppPassword(_) => atproto_oauth::scopes::ScopesSet::new(),
9999+ AuthSubject::OAuth(c) => atproto_oauth::scopes::ScopesSet::from_scope_string(&c.scope),
100100+ }
68101 }
6910270103 /// `true` if the token is allowed to perform privileged operations
+10
crates/atproto-pds/src/http/errors.rs
···5050 PdsError::NotFound { what } => {
5151 XrpcError::new(StatusCode::BAD_REQUEST, "NotFound", what)
5252 }
5353+ PdsError::SpaceNotFound { uri } => XrpcError::new(
5454+ StatusCode::BAD_REQUEST,
5555+ "SpaceNotFound",
5656+ format!("no such space {uri}"),
5757+ ),
5858+ PdsError::NotSpaceOwner { uri } => XrpcError::new(
5959+ StatusCode::FORBIDDEN,
6060+ "NotSpaceOwner",
6161+ format!("not the owner of {uri}"),
6262+ ),
5363 PdsError::AuthDenied { reason } => {
5464 XrpcError::new(StatusCode::FORBIDDEN, "Forbidden", reason)
5565 }
···44//! 1. **OAuth bearer** — same DPoP-bound HS256 token used for the public
55//! realm. Verified by [`crate::oauth::token::verify_oauth_jwt`]. Used for
66//! own-PDS reads and writes.
77-//! 2. **MemberGrant JWT** (`typ=space_member_grant`) — passed to
88-//! `getSpaceCredential`. Signed by the member's atproto signing key.
99-//! 3. **SpaceCredential JWT** (`typ=space_credential`) — passed to
77+//! 2. **Delegation token** (`typ=atproto-space-delegation+jwt`) — presented in
88+//! the `Authorization: Bearer` header to `getSpaceCredential`. Signed by the
99+//! member's atproto signing key (header `kid="#atproto"`).
1010+//! 3. **SpaceCredential JWT** (`typ=atproto-space-credential+jwt`) — passed to
1011//! `getRecord`/`listRecords`/`getRepoState`/etc. by remote consumers.
1111-//! Signed by the space owner's atproto signing key.
1212+//! Signed by the space authority's `#atproto_space` signing key.
1213//!
1313-//! the design (§15.7), MemberGrant verification at the owner's PDS
1414-//! requires resolving the member's DID document to obtain their signing
1515-//! key. The **same-PDS** case (the member is also a locally-managed
1616-//! account on this PDS) looks up the signing key directly via the
1717-//! `AccountManager` + `KeyStore`. Cross-PDS resolution through
1818-//! `atproto-identity` is wired — the resolver picks the path automatically based on
1919-//! whether the DID is locally managed.
1414+//! Delegation-token verification at the authority's PDS requires resolving the
1515+//! member's DID document to obtain their `#atproto` signing key. The
1616+//! **same-PDS** case (the member is also a locally-managed account on this PDS)
1717+//! looks up the signing key directly via the `AccountManager` + `KeyStore`.
1818+//! Cross-PDS resolution through `atproto-identity` is wired — the resolver
1919+//! picks the path automatically based on whether the DID is locally managed.
20202121use crate::account::AccountManager;
2222use crate::http::errors::XrpcError;
2323-use crate::security::JtiReplayGuard;
2423use atproto_identity::key::{KeyData, to_public};
2524use atproto_space::credential::{
2626- LXM_GET_SPACE_CREDENTIAL, MemberGrant, TYP_MEMBER_GRANT, TYP_SPACE_CREDENTIAL,
2727- verify_member_grant,
2525+ DelegationToken, TYP_DELEGATION_TOKEN, TYP_SPACE_CREDENTIAL, verify_delegation_token,
2826};
2927use atproto_space::types::SpaceUri;
3028use axum::http::StatusCode;
3129use base64::{Engine as _, engine::general_purpose};
3230use serde::Deserialize;
3333-use sha2::{Digest, Sha256};
3431use std::sync::Arc;
3535-use std::time::{Duration, SystemTime, UNIX_EPOCH};
36323733/// JWT typ discriminator inspection.
3834#[derive(Debug, Deserialize)]
···5551/// Recognised Spaces token shapes.
5652#[derive(Debug, PartialEq, Eq)]
5753pub enum SpaceTokenKind {
5858- /// `space_member_grant`.
5959- MemberGrant,
6060- /// `space_credential`.
5454+ /// `atproto-space-delegation+jwt`.
5555+ DelegationToken,
5656+ /// `atproto-space-credential+jwt`.
6157 SpaceCredential,
6258 /// Anything else (likely an OAuth access token).
6359 Other(String),
···6864pub fn classify(token: &str) -> Option<SpaceTokenKind> {
6965 let typ = classify_token_typ(token)?;
7066 Some(match typ.as_str() {
7171- TYP_MEMBER_GRANT => SpaceTokenKind::MemberGrant,
6767+ TYP_DELEGATION_TOKEN => SpaceTokenKind::DelegationToken,
7268 TYP_SPACE_CREDENTIAL => SpaceTokenKind::SpaceCredential,
7369 _ => SpaceTokenKind::Other(typ),
7470 })
···107103}
108104109105/// Resolve a locally-managed account's *public* signing key (used for
110110-/// verifying MemberGrants issued by that account).
106106+/// verifying delegation tokens issued by that account).
111107pub async fn local_public_key(accounts: &AccountManager, did: &str) -> Result<KeyData, XrpcError> {
112108 let private = local_signing_key(accounts, did).await?;
113109 to_public(&private).map_err(|e| {
···119115 })
120116}
121117122122-/// Verify a MemberGrant against a same-PDS member's signing key, with replay
123123-/// protection.
124124-///
125125-/// MemberGrants don't carry an explicit `jti` claim — instead we synthesize
126126-/// one from `(iss, iat, lxm, space, clientId)` so the replay guard rejects
127127-/// duplicate exchanges of the same grant. TTL is the grant's remaining
128128-/// lifetime (`exp - now`).
129129-///
130130-/// Returns the decoded payload on success. Used by `getSpaceCredential`.
118118+/// Peek the unverified `iss` claim of a delegation token without checking the
119119+/// signature, so the caller knows which member key to resolve.
131120///
132121/// # Errors
133122///
134134-/// - 400 `InvalidToken` if the grant is unparseable / expired / claim mismatch.
135135-/// - 401 `AuthenticationRequired` if the issuer (member) is not known on this PDS.
136136-/// - 409 `Replay` if the same grant has already been exchanged within its TTL.
137137-pub async fn verify_local_member_grant(
138138- accounts: &Arc<AccountManager>,
139139- jti_guard: &JtiReplayGuard,
140140- grant_jwt: &str,
141141- expected_owner_did: &str,
142142- expected_space: &SpaceUri,
143143- expected_client_id: &str,
144144-) -> Result<MemberGrant, XrpcError> {
145145- // Peek the issuer claim without verifying signature, so we know which
146146- // member's key to fetch. We re-verify with the proper key below.
123123+/// Returns 400 `InvalidToken` when the token is structurally malformed.
124124+pub fn peek_delegation_token(grant_jwt: &str) -> Result<DelegationToken, XrpcError> {
147125 let payload_b64 = grant_jwt.split('.').nth(1).ok_or_else(|| {
148126 XrpcError::new(
149127 StatusCode::BAD_REQUEST,
150128 "InvalidToken",
151151- "MemberGrant: missing payload",
129129+ "delegation token: missing payload",
152130 )
153131 })?;
154132 let payload_bytes = general_purpose::URL_SAFE_NO_PAD
···157135 XrpcError::new(
158136 StatusCode::BAD_REQUEST,
159137 "InvalidToken",
160160- "MemberGrant: payload not base64url",
138138+ "delegation token: payload not base64url",
161139 )
162140 })?;
163163- let unverified: MemberGrant = serde_json::from_slice(&payload_bytes).map_err(|_| {
141141+ serde_json::from_slice(&payload_bytes).map_err(|_| {
164142 XrpcError::new(
165143 StatusCode::BAD_REQUEST,
166144 "InvalidToken",
167167- "MemberGrant: payload not JSON",
145145+ "delegation token: payload not JSON",
168146 )
169169- })?;
147147+ })
148148+}
170149171171- if unverified.lxm != LXM_GET_SPACE_CREDENTIAL {
172172- return Err(XrpcError::new(
173173- StatusCode::BAD_REQUEST,
174174- "InvalidToken",
175175- format!("MemberGrant lxm mismatch: {}", unverified.lxm),
176176- ));
177177- }
150150+/// Verify a delegation token against a same-PDS member's signing key.
151151+///
152152+/// Returns the decoded payload on success. Used by `getSpaceCredential`.
153153+///
154154+/// # Errors
155155+///
156156+/// - 400 `InvalidToken` if the token is unparseable / expired / claim mismatch.
157157+/// - 404 `AccountNotFound` if the issuer (member) is not known on this PDS.
158158+pub async fn verify_local_delegation_token(
159159+ accounts: &Arc<AccountManager>,
160160+ grant_jwt: &str,
161161+ expected_authority_did: &str,
162162+ expected_space: &SpaceUri,
163163+) -> Result<DelegationToken, XrpcError> {
164164+ // Peek the issuer claim without verifying signature, so we know which
165165+ // member's key to fetch. We re-verify with the proper key below.
166166+ let unverified = peek_delegation_token(grant_jwt)?;
178167179168 let member_pub = local_public_key(accounts, &unverified.iss).await?;
180180- let payload = verify_member_grant(
169169+ let payload = verify_delegation_token(
181170 grant_jwt,
182182- expected_owner_did,
171171+ expected_authority_did,
183172 expected_space,
184184- expected_client_id,
185173 &member_pub,
186174 )
187175 .map_err(|e| {
188176 XrpcError::new(
189177 StatusCode::FORBIDDEN,
190178 "InvalidToken",
191191- format!("MemberGrant verification: {e}"),
179179+ format!("delegation token verification: {e}"),
192180 )
193181 })?;
194194-195195- // Replay protection. Synthesize a JTI from the structural identity of the
196196- // grant; record with TTL = remaining lifetime so the guard self-cleans.
197197- let synthetic_jti = synthesize_member_grant_jti(&payload);
198198- let now = SystemTime::now()
199199- .duration_since(UNIX_EPOCH)
200200- .map(|d| d.as_secs())
201201- .unwrap_or(0);
202202- let ttl = Duration::from_secs(payload.exp.saturating_sub(now));
203203- jti_guard
204204- .check_and_insert(&synthetic_jti, ttl)
205205- .await
206206- .map_err(|e| {
207207- XrpcError::new(
208208- StatusCode::CONFLICT,
209209- "Replay",
210210- format!("MemberGrant replay rejected: {e}"),
211211- )
212212- })?;
213182214183 Ok(payload)
215184}
216185217217-/// Verify a MemberGrant issued by a member on a *remote* PDS by resolving
186186+/// Verify a delegation token issued by a member on a *remote* PDS by resolving
218187/// their DID document for the atproto signing key.
219188///
220220-/// The flow mirrors [`verify_local_member_grant`] but the public-key
189189+/// The flow mirrors [`verify_local_delegation_token`] but the public-key
221190/// lookup goes through `atproto-identity` instead of the local
222191/// `AccountManager`:
223192///
224224-/// 1. Peek the JWT payload to learn the issuer DID + claim shape.
193193+/// 1. Peek the JWT payload to learn the issuer DID.
225194/// 2. Resolve the issuer's DID document via the configured PLC directory
226195/// (for `did:plc`) or `.well-known/did.json` (for `did:web`).
227196/// 3. Find the verification method whose id ends in `#atproto`; decode the
228197/// multibase public key into a `KeyData`.
229229-/// 4. Re-verify the JWT against that key, plus the same JTI replay guard
230230-/// used on the local path.
198198+/// 4. Re-verify the JWT against that key.
231199///
232200/// `plc_directory_hostname` is the configured PLC directory (e.g.
233201/// `plc.directory`); pass `None` to use the upstream default.
234202///
235203/// # Errors
236204///
237237-/// - 400 `InvalidToken` — grant unparseable / wrong `lxm` / claim mismatch.
205205+/// - 400 `InvalidToken` — token unparseable / claim mismatch.
238206/// - 401 `AuthenticationRequired` — DID document doesn't resolve, or
239207/// doesn't carry an `#atproto` verification method.
240208/// - 403 `InvalidToken` — signature verification failed.
241241-/// - 409 `Replay` — the same grant has already been exchanged.
242242-pub async fn verify_remote_member_grant(
209209+pub async fn verify_remote_delegation_token(
243210 http: &reqwest::Client,
244244- jti_guard: &JtiReplayGuard,
245211 grant_jwt: &str,
246246- expected_owner_did: &str,
212212+ expected_authority_did: &str,
247213 expected_space: &SpaceUri,
248248- expected_client_id: &str,
249214 plc_directory_hostname: Option<&str>,
250250-) -> Result<MemberGrant, XrpcError> {
251251- let payload_b64 = grant_jwt.split('.').nth(1).ok_or_else(|| {
252252- XrpcError::new(
253253- StatusCode::BAD_REQUEST,
254254- "InvalidToken",
255255- "MemberGrant: missing payload",
256256- )
257257- })?;
258258- let payload_bytes = general_purpose::URL_SAFE_NO_PAD
259259- .decode(payload_b64.as_bytes())
260260- .map_err(|_| {
261261- XrpcError::new(
262262- StatusCode::BAD_REQUEST,
263263- "InvalidToken",
264264- "MemberGrant: payload not base64url",
265265- )
266266- })?;
267267- let unverified: MemberGrant = serde_json::from_slice(&payload_bytes).map_err(|_| {
268268- XrpcError::new(
269269- StatusCode::BAD_REQUEST,
270270- "InvalidToken",
271271- "MemberGrant: payload not JSON",
272272- )
273273- })?;
274274-275275- if unverified.lxm != LXM_GET_SPACE_CREDENTIAL {
276276- return Err(XrpcError::new(
277277- StatusCode::BAD_REQUEST,
278278- "InvalidToken",
279279- format!("MemberGrant lxm mismatch: {}", unverified.lxm),
280280- ));
281281- }
215215+) -> Result<DelegationToken, XrpcError> {
216216+ let unverified = peek_delegation_token(grant_jwt)?;
282217283218 let member_pub = remote_atproto_signing_key(http, &unverified.iss, plc_directory_hostname)
284219 .await
···289224 format!("resolve member DID document for {}: {e}", unverified.iss),
290225 )
291226 })?;
292292- let payload = verify_member_grant(
227227+ let payload = verify_delegation_token(
293228 grant_jwt,
294294- expected_owner_did,
229229+ expected_authority_did,
295230 expected_space,
296296- expected_client_id,
297231 &member_pub,
298232 )
299233 .map_err(|e| {
300234 XrpcError::new(
301235 StatusCode::FORBIDDEN,
302236 "InvalidToken",
303303- format!("MemberGrant verification: {e}"),
237237+ format!("delegation token verification: {e}"),
304238 )
305239 })?;
306240307307- let synthetic_jti = synthesize_member_grant_jti(&payload);
308308- let now = SystemTime::now()
309309- .duration_since(UNIX_EPOCH)
310310- .map(|d| d.as_secs())
311311- .unwrap_or(0);
312312- let ttl = Duration::from_secs(payload.exp.saturating_sub(now));
313313- jti_guard
314314- .check_and_insert(&synthetic_jti, ttl)
315315- .await
316316- .map_err(|e| {
317317- XrpcError::new(
318318- StatusCode::CONFLICT,
319319- "Replay",
320320- format!("MemberGrant replay rejected: {e}"),
321321- )
322322- })?;
323323-324241 Ok(payload)
325242}
326243327327-/// Resolve a remote DID's atproto signing key (the `#atproto` verification
328328-/// method's `publicKeyMultibase`). Returns `KeyData` ready for
329329-/// `verify_member_grant` etc.
330330-async fn remote_atproto_signing_key(
244244+/// Fetch a `did:plc:`/`did:web:` DID document for remote verification.
245245+async fn fetch_remote_document(
331246 http: &reqwest::Client,
332247 did: &str,
333248 plc_directory_hostname: Option<&str>,
334334-) -> anyhow::Result<KeyData> {
335335- use atproto_identity::key::identify_key;
336336- use atproto_identity::model::VerificationMethod;
249249+) -> anyhow::Result<atproto_identity::model::Document> {
337250 use atproto_identity::plc::query as plc_query;
338251 use atproto_identity::web::query as web_query;
339339- let document = if did.starts_with("did:plc:") {
252252+ if did.starts_with("did:plc:") {
340253 let host = plc_directory_hostname.unwrap_or("plc.directory");
341341- plc_query(http, host, did).await?
254254+ Ok(plc_query(http, host, did).await?)
342255 } else if did.starts_with("did:web:") {
343343- web_query(http, did).await?
256256+ Ok(web_query(http, did).await?)
344257 } else {
345345- anyhow::bail!("unsupported DID method for remote member-grant verification: {did}");
346346- };
347347- let mut atproto_pub: Option<String> = None;
348348- for method in &document.verification_method {
349349- if let VerificationMethod::Multikey {
350350- id,
351351- public_key_multibase,
352352- ..
353353- } = method
354354- {
355355- // Match either `#atproto` (relative) or
356356- // `did:plc:xxx#atproto` (absolute). Spec allows either form.
357357- if id.ends_with("#atproto") {
358358- atproto_pub = Some(public_key_multibase.clone());
359359- break;
360360- }
361361- }
258258+ anyhow::bail!("unsupported DID method for remote space verification: {did}")
362259 }
363363- let mb = atproto_pub.ok_or_else(|| {
364364- anyhow::anyhow!("DID document has no #atproto Multikey verification method")
365365- })?;
260260+}
261261+262262+/// Turn a multibase / `did:key:` value into `KeyData`.
263263+fn multibase_to_key(mb: &str) -> anyhow::Result<KeyData> {
264264+ use atproto_identity::key::identify_key;
366265 // `identify_key` accepts either a bare multibase value or a `did:key:`
367266 // wrapper. Normalize so the call site is robust to either form.
368267 let did_key = if mb.starts_with("did:key:") {
369369- mb
268268+ mb.to_string()
370269 } else {
371371- format!("did:key:{}", mb)
270270+ format!("did:key:{mb}")
372271 };
373272 Ok(identify_key(&did_key)?)
374273}
375274376376-/// Build a deterministic JTI for a MemberGrant payload. We hash the load-bearing
377377-/// claims so two grants with the same `(iss, iat, lxm, space, clientId)`
378378-/// collide (replay) but distinct issuances do not.
379379-fn synthesize_member_grant_jti(grant: &MemberGrant) -> String {
380380- let mut hasher = Sha256::new();
381381- hasher.update(b"mg:");
382382- hasher.update(grant.iss.as_bytes());
383383- hasher.update(b"|");
384384- hasher.update(grant.iat.to_be_bytes());
385385- hasher.update(b"|");
386386- hasher.update(grant.lxm.as_bytes());
387387- hasher.update(b"|");
388388- hasher.update(grant.space.as_bytes());
389389- hasher.update(b"|");
390390- hasher.update(grant.client_id.as_bytes());
391391- let digest = hasher.finalize();
392392- hex::encode(digest)
275275+/// Resolve a remote DID's atproto signing key (the `#atproto` verification
276276+/// method's `publicKeyMultibase`). Returns `KeyData` ready for
277277+/// `verify_delegation_token` etc. The delegation token's `kid` is `#atproto`
278278+/// per 0016 line 162, so this resolves the member's public-data signing key.
279279+async fn remote_atproto_signing_key(
280280+ http: &reqwest::Client,
281281+ did: &str,
282282+ plc_directory_hostname: Option<&str>,
283283+) -> anyhow::Result<KeyData> {
284284+ let document = fetch_remote_document(http, did, plc_directory_hostname).await?;
285285+ let mb = document
286286+ .verification_method_multibase("atproto")
287287+ .ok_or_else(|| {
288288+ anyhow::anyhow!("DID document has no #atproto Multikey verification method")
289289+ })?;
290290+ multibase_to_key(mb)
291291+}
292292+293293+/// Resolve a remote space authority's credential-verification key.
294294+///
295295+/// Per 0016 §"Space authority" (lines 87-92) the authority DID exposes the
296296+/// credential-signing public key as the `#atproto_space` verification method,
297297+/// which MAY coincide in value with `#atproto` (line 92). This prefers the
298298+/// dedicated `#atproto_space` entry and falls back to `#atproto` for authority
299299+/// DID documents that exercise the coincidence allowance without publishing a
300300+/// distinct entry.
301301+pub async fn remote_space_credential_key(
302302+ http: &reqwest::Client,
303303+ authority_did: &str,
304304+ plc_directory_hostname: Option<&str>,
305305+) -> anyhow::Result<KeyData> {
306306+ let document = fetch_remote_document(http, authority_did, plc_directory_hostname).await?;
307307+ let mb = space_credential_multibase(&document).ok_or_else(|| {
308308+ anyhow::anyhow!(
309309+ "authority DID document has no #atproto_space or #atproto Multikey verification method"
310310+ )
311311+ })?;
312312+ multibase_to_key(mb)
313313+}
314314+315315+/// Select the authority's credential-verification public key from a DID
316316+/// document: prefer `#atproto_space`, fall back to `#atproto` (0016 line 92).
317317+fn space_credential_multibase(document: &atproto_identity::model::Document) -> Option<&str> {
318318+ document
319319+ .verification_method_multibase("atproto_space")
320320+ .or_else(|| document.verification_method_multibase("atproto"))
321321+}
322322+323323+/// Resolve a remote space authority's host endpoint.
324324+///
325325+/// Per 0016 §"Space authority" (lines 87-92) the authority DID exposes its host
326326+/// as the `#atproto_space_host` service, which MAY coincide with `#atproto_pds`
327327+/// (line 92). This prefers the dedicated `#atproto_space_host` entry and falls
328328+/// back to `#atproto_pds`.
329329+pub async fn remote_space_host_endpoint(
330330+ http: &reqwest::Client,
331331+ authority_did: &str,
332332+ plc_directory_hostname: Option<&str>,
333333+) -> anyhow::Result<String> {
334334+ let document = fetch_remote_document(http, authority_did, plc_directory_hostname).await?;
335335+ space_host_endpoint(&document)
336336+ .map(|s| s.to_string())
337337+ .ok_or_else(|| {
338338+ anyhow::anyhow!(
339339+ "authority DID document has no #atproto_space_host or #atproto_pds service"
340340+ )
341341+ })
342342+}
343343+344344+/// Select the authority's host endpoint from a DID document: prefer
345345+/// `#atproto_space_host`, fall back to `#atproto_pds` (0016 line 92).
346346+fn space_host_endpoint(document: &atproto_identity::model::Document) -> Option<&str> {
347347+ document
348348+ .service_endpoint("atproto_space_host")
349349+ .or_else(|| document.service_endpoint("atproto_pds"))
393350}
394351395352#[cfg(test)]
···398355 use crate::account::{AccountDirectory, AccountManager, CreateAccountParams};
399356 use crate::keys::{KeyStore, MemoryKeyStore};
400357 use atproto_identity::key::KeyType;
401401- use atproto_space::credential::{MEMBER_GRANT_TTL_SECS, create_member_grant};
358358+ use atproto_identity::model::Document;
359359+ use atproto_space::credential::{DELEGATION_TOKEN_TTL_SECS, create_delegation_token};
402360 use atproto_space::types::{SpaceKey, SpaceType};
403361 use std::sync::Arc;
404362 use tempfile::TempDir;
405363364364+ fn doc_from(json: &str) -> Document {
365365+ serde_json::from_str(json).expect("DID document parses")
366366+ }
367367+368368+ #[test]
369369+ fn space_credential_key_prefers_atproto_space_vm() {
370370+ // 0016 lines 87-92: prefer the dedicated #atproto_space VM when present.
371371+ let doc = doc_from(
372372+ r##"{"id":"did:plc:authority",
373373+ "verificationMethod":[
374374+ {"id":"#atproto","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zATPROTO"},
375375+ {"id":"#atproto_space","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zSPACE"}
376376+ ]}"##,
377377+ );
378378+ assert_eq!(space_credential_multibase(&doc), Some("zSPACE"));
379379+ }
380380+381381+ #[test]
382382+ fn space_credential_key_falls_back_to_atproto_vm() {
383383+ // Line 92 MAY-coincide: authorities that don't publish a distinct
384384+ // #atproto_space entry fall back to the #atproto signing key.
385385+ let doc = doc_from(
386386+ r##"{"id":"did:plc:authority",
387387+ "verificationMethod":[
388388+ {"id":"#atproto","type":"Multikey","controller":"did:plc:authority","publicKeyMultibase":"zATPROTO"}
389389+ ]}"##,
390390+ );
391391+ assert_eq!(space_credential_multibase(&doc), Some("zATPROTO"));
392392+ }
393393+394394+ #[test]
395395+ fn space_credential_key_absent_when_neither_present() {
396396+ let doc = doc_from(r##"{"id":"did:plc:authority","verificationMethod":[]}"##);
397397+ assert_eq!(space_credential_multibase(&doc), None);
398398+ }
399399+400400+ #[test]
401401+ fn space_host_prefers_atproto_space_host_service() {
402402+ // 0016 lines 87-92: prefer the dedicated #atproto_space_host service.
403403+ let doc = doc_from(
404404+ r##"{"id":"did:plc:authority",
405405+ "service":[
406406+ {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"},
407407+ {"id":"#atproto_space_host","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://host.example.com"}
408408+ ]}"##,
409409+ );
410410+ assert_eq!(space_host_endpoint(&doc), Some("https://host.example.com"));
411411+ }
412412+413413+ #[test]
414414+ fn space_host_falls_back_to_atproto_pds_service() {
415415+ // Line 92 MAY-coincide: fall back to #atproto_pds when no dedicated
416416+ // host service is published.
417417+ let doc = doc_from(
418418+ r##"{"id":"did:plc:authority",
419419+ "service":[
420420+ {"id":"#atproto_pds","type":"AtprotoPersonalDataServer","serviceEndpoint":"https://pds.example.com"}
421421+ ]}"##,
422422+ );
423423+ assert_eq!(space_host_endpoint(&doc), Some("https://pds.example.com"));
424424+ }
425425+426426+ #[test]
427427+ fn space_host_absent_when_neither_present() {
428428+ let doc = doc_from(r##"{"id":"did:plc:authority","service":[]}"##);
429429+ assert_eq!(space_host_endpoint(&doc), None);
430430+ }
431431+406432 async fn fresh_manager(dir: &std::path::Path) -> Arc<AccountManager> {
407433 let accounts_db = AccountDirectory::open(&dir.join("accounts.sqlite"))
408434 .await
···441467 #[tokio::test(flavor = "multi_thread")]
442468 async fn classify_token_kinds() {
443469 // synthetic header-only jwt-shaped strings
444444- let mg_header = general_purpose::URL_SAFE_NO_PAD
445445- .encode(br#"{"alg":"ES256","typ":"space_member_grant"}"#);
446446- let sc_header =
447447- general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"space_credential"}"#);
448448- let jwt_a = format!("{}.payload.sig", mg_header);
470470+ let dt_header = general_purpose::URL_SAFE_NO_PAD
471471+ .encode(br##"{"alg":"ES256","typ":"atproto-space-delegation+jwt","kid":"#atproto"}"##);
472472+ let sc_header = general_purpose::URL_SAFE_NO_PAD.encode(
473473+ br##"{"alg":"ES256","typ":"atproto-space-credential+jwt","kid":"#atproto_space"}"##,
474474+ );
475475+ let jwt_a = format!("{}.payload.sig", dt_header);
449476 let jwt_b = format!("{}.payload.sig", sc_header);
450450- assert_eq!(classify(&jwt_a), Some(SpaceTokenKind::MemberGrant));
477477+ assert_eq!(classify(&jwt_a), Some(SpaceTokenKind::DelegationToken));
451478 assert_eq!(classify(&jwt_b), Some(SpaceTokenKind::SpaceCredential));
452479 }
453480454454- fn fresh_jti_guard() -> JtiReplayGuard {
455455- JtiReplayGuard::new(1024)
456456- }
457457-458481 #[tokio::test(flavor = "multi_thread")]
459459- async fn local_member_grant_round_trip() {
460460- let tmp = TempDir::new().unwrap();
461461- let manager = fresh_manager(tmp.path()).await;
462462- let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap();
463463-464464- let space = test_space();
465465- let grant = create_member_grant(
466466- "did:plc:alice",
467467- "did:plc:owner",
468468- &space,
469469- "https://app.example/client-metadata.json",
470470- &alice_priv,
471471- MEMBER_GRANT_TTL_SECS,
472472- )
473473- .unwrap();
474474-475475- let guard = fresh_jti_guard();
476476- let payload = verify_local_member_grant(
477477- &manager,
478478- &guard,
479479- &grant,
480480- "did:plc:owner",
481481- &space,
482482- "https://app.example/client-metadata.json",
483483- )
484484- .await
485485- .unwrap();
486486- assert_eq!(payload.iss, "did:plc:alice");
487487- }
488488-489489- #[tokio::test(flavor = "multi_thread")]
490490- async fn member_grant_replay_rejected() {
482482+ async fn local_delegation_token_round_trip() {
491483 let tmp = TempDir::new().unwrap();
492484 let manager = fresh_manager(tmp.path()).await;
493485 let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap();
494486495487 let space = test_space();
496496- let grant = create_member_grant(
488488+ let grant = create_delegation_token(
497489 "did:plc:alice",
498498- "did:plc:owner",
499490 &space,
500500- "client",
501491 &alice_priv,
502502- MEMBER_GRANT_TTL_SECS,
492492+ DELEGATION_TOKEN_TTL_SECS,
503493 )
504494 .unwrap();
505495506506- let guard = fresh_jti_guard();
507507-508508- // First exchange: succeeds.
509509- verify_local_member_grant(&manager, &guard, &grant, "did:plc:owner", &space, "client")
496496+ let payload = verify_local_delegation_token(&manager, &grant, "did:plc:owner", &space)
510497 .await
511498 .unwrap();
512512-513513- // Second exchange of the same grant: rejected as a replay.
514514- let result =
515515- verify_local_member_grant(&manager, &guard, &grant, "did:plc:owner", &space, "client")
516516- .await;
517517- assert!(result.is_err(), "second exchange should be rejected");
499499+ assert_eq!(payload.iss, "did:plc:alice");
518500 }
519501520502 #[tokio::test(flavor = "multi_thread")]
521521- async fn member_grant_unknown_issuer_rejected() {
503503+ async fn delegation_token_unknown_issuer_rejected() {
522504 let tmp = TempDir::new().unwrap();
523505 let manager = fresh_manager(tmp.path()).await;
524506 let alice_priv = local_signing_key(&manager, "did:plc:alice").await.unwrap();
525507526508 let space = test_space();
527509 // Issue with a *different* iss claim — verify lookup of the unknown
528528- // issuer fails. We hand-craft the payload to bypass create_member_grant.
510510+ // issuer fails. We hand-craft the payload to bypass
511511+ // create_delegation_token.
529512 let header = general_purpose::URL_SAFE_NO_PAD
530530- .encode(br#"{"alg":"ES256K","typ":"space_member_grant"}"#);
513513+ .encode(br##"{"alg":"ES256K","typ":"atproto-space-delegation+jwt","kid":"#atproto"}"##);
531514 let bad_payload = serde_json::to_vec(&serde_json::json!({
532515 "iss": "did:plc:nobody",
533533- "aud": "did:plc:owner",
534534- "space": space.to_string(),
535535- "clientId": "client",
536536- "lxm": "com.atproto.space.getSpaceCredential",
516516+ "aud": "did:plc:owner#atproto_space_host",
517517+ "sub": space.to_string(),
537518 "iat": 1_700_000_000,
538519 "exp": 9_999_999_999u64,
520520+ "jti": "nonce",
539521 }))
540522 .unwrap();
541523 let payload_b64 = general_purpose::URL_SAFE_NO_PAD.encode(&bad_payload);
···547529 general_purpose::URL_SAFE_NO_PAD.encode(&sig)
548530 );
549531550550- let guard = fresh_jti_guard();
551551- let result =
552552- verify_local_member_grant(&manager, &guard, &token, "did:plc:owner", &space, "client")
553553- .await;
532532+ let result = verify_local_delegation_token(&manager, &token, "did:plc:owner", &space).await;
554533 assert!(result.is_err());
555534 }
556535}
+2240-583
crates/atproto-pds/src/http/space_handlers.rs
···11-//! XRPC HTTP handlers for `com.atproto.space.*`.
11+//! XRPC HTTP handlers for `com.atproto.space.*` and `com.atproto.simplespace.*`.
22+//!
33+//! Surface:
24//!
33-//! Surface ():
55+//! Simplespace management (owner-only, OAuth):
66+//! - `POST /xrpc/com.atproto.simplespace.createSpace`
77+//! - `POST /xrpc/com.atproto.simplespace.addMember`
88+//! - `POST /xrpc/com.atproto.simplespace.removeMember`
99+//! - `GET /xrpc/com.atproto.simplespace.listMembers`
410//!
55-//! Management (owner-only, OAuth):
66-//! - `POST /xrpc/com.atproto.space.createSpace`
1111+//! Space reads (OAuth):
712//! - `GET /xrpc/com.atproto.space.getSpace`
813//! - `GET /xrpc/com.atproto.space.listSpaces`
99-//! - `POST /xrpc/com.atproto.space.addMember`
1010-//! - `POST /xrpc/com.atproto.space.removeMember`
1111-//! - `GET /xrpc/com.atproto.space.getMembers`
1214//!
1315//! Records (member-OAuth or remote SpaceCredential):
1416//! - `POST /xrpc/com.atproto.space.applyWrites`
1717+//! - `POST /xrpc/com.atproto.space.createRecord`
1818+//! - `POST /xrpc/com.atproto.space.putRecord`
1919+//! - `POST /xrpc/com.atproto.space.deleteRecord`
1520//! - `GET /xrpc/com.atproto.space.getRecord`
1621//! - `GET /xrpc/com.atproto.space.listRecords`
1722//!
1823//! Sync (read-only state + oplog):
1924//! - `GET /xrpc/com.atproto.space.getRepoState`
2020-//! - `GET /xrpc/com.atproto.space.getRepoOplog`
2121-//! - `GET /xrpc/com.atproto.space.getMemberState`
2222-//! - `GET /xrpc/com.atproto.space.getMemberOplog`
2525+//! - `GET /xrpc/com.atproto.space.listRepoOps`
2326//!
2427//! Credentials (member + owner two-step flow):
2525-//! - `POST /xrpc/com.atproto.space.getMemberGrant` (member-OAuth)
2828+//! - `GET /xrpc/com.atproto.space.getDelegationToken` (member-OAuth)
2629//! - `POST /xrpc/com.atproto.space.getSpaceCredential` (no auth — grant *is* the auth)
27302831use crate::account::AccountManager;
2932use crate::actor_store::sql::SqlActorStore;
3030-use crate::errors::PdsError;
3131-use crate::http::auth::{bearer_token, request_htm_htu, require_authn_sub};
3333+use crate::http::auth::{bearer_token, request_htm_htu, require_authn, require_authn_sub};
3234use crate::http::errors::XrpcError;
3335use crate::http::space_auth::{
3434- SpaceTokenKind, classify, local_signing_key, verify_local_member_grant,
3636+ SpaceTokenKind, classify, local_signing_key, peek_delegation_token,
3737+ verify_local_delegation_token,
3538};
3639use crate::http::state::HttpState;
3740use crate::space::notify::upsert_recipient;
···3942use crate::space::writer::{SpaceCommitResult, SpaceWriteAction, SpaceWriteOp};
4043use crate::space::{SpaceReader, SpaceService, SpaceSync, SpaceWriter};
4144use atproto_space::credential::{
4242- MEMBER_GRANT_TTL_SECS, create_member_grant, create_space_credential,
4545+ DELEGATION_TOKEN_TTL_SECS, create_delegation_token, create_space_credential,
4346};
4747+use atproto_space::storage::OplogCursor;
4448use atproto_space::types::SpaceUri;
4549use axum::Json;
4650use axum::extract::{Query, State};
···122126 require_authn_sub(parts, state, &htm, &htu).await
123127}
124128129129+/// Like [`require_session_subject`] but returns the full
130130+/// [`AuthSubject`](crate::http::auth::AuthSubject), so the caller can both
131131+/// read the subject DID and run an [`assert_space_scope`] check on OAuth
132132+/// tokens.
133133+async fn require_session_auth(
134134+ parts: &Parts,
135135+ state: &HttpState,
136136+) -> Result<crate::http::auth::AuthSubject, XrpcError> {
137137+ let (htm, htu) = request_htm_htu(parts);
138138+ require_authn(parts, state, &htm, &htu).await
139139+}
140140+125141// ---------------------------------------------------------------------------
126142// Management endpoints.
127143// ---------------------------------------------------------------------------
128144129129-/// Inputs for `createSpace`.
145145+/// Inputs for `com.atproto.simplespace.createSpace`.
146146+///
147147+/// Matches the authoritative lexicon: `{did, type, skey?, config?}`. `did`
148148+/// is the DID of the space authority — it defaults to the authenticated
149149+/// caller and, if supplied, must equal the caller. `skey` auto-generates a
150150+/// TID when absent. `config` carries the initial `#spaceConfig`.
130151#[derive(Debug, Deserialize)]
131152pub struct CreateSpaceInput {
153153+ /// DID of the space (the authority). Defaults to the caller.
154154+ pub did: Option<String>,
132155 /// NSID space type (e.g., `app.bsky.group`).
133133- #[serde(rename = "spaceType")]
156156+ #[serde(rename = "type")]
134157 pub space_type: String,
135135- /// Caller-chosen key.
136136- #[serde(rename = "spaceKey")]
137137- pub space_key: String,
158158+ /// Space key. Auto-generated as a TID when omitted.
159159+ pub skey: Option<String>,
160160+ /// Initial space configuration (`com.atproto.simplespace.defs#spaceConfig`).
161161+ pub config: Option<serde_json::Value>,
138162}
139163140140-/// Output of `createSpace` / `getSpace`.
164164+/// Output of `createSpace`: `{uri}`.
165165+#[derive(Debug, Serialize)]
166166+pub struct CreateSpaceResponse {
167167+ /// URI of the created space.
168168+ pub uri: String,
169169+}
170170+171171+/// `getSpace` output (`{uri, config}`).
172172+pub use crate::space::GetSpaceOutput;
173173+/// Internal view of a space (re-exported for `listSpaces`).
141174pub use crate::space::SpaceInfo;
142175143143-/// `POST /xrpc/com.atproto.space.createSpace`.
176176+/// `POST /xrpc/com.atproto.simplespace.createSpace`.
144177pub async fn create_space(
145178 State(state): State<HttpState>,
146179 parts: Parts,
147180 Json(input): Json<CreateSpaceInput>,
148148-) -> Result<Json<SpaceInfo>, XrpcError> {
149149- let owner_did = require_session_subject(&parts, &state).await?;
181181+) -> Result<Json<CreateSpaceResponse>, XrpcError> {
182182+ let subject = require_session_auth(&parts, &state).await?;
183183+ let caller = subject.sub().to_string();
184184+ // The space authority defaults to the caller; an explicit `did` must
185185+ // match (callers may only create spaces under their own authority).
186186+ let authority_did = match input.did {
187187+ Some(ref d) if d != &caller => {
188188+ return Err(XrpcError::new(
189189+ StatusCode::FORBIDDEN,
190190+ "NotSpaceOwner",
191191+ "space did must equal the authenticated caller",
192192+ ));
193193+ }
194194+ Some(d) => d,
195195+ None => caller,
196196+ };
197197+ let skey = input
198198+ .skey
199199+ .unwrap_or_else(|| atproto_record::tid::Tid::new().to_string());
200200+ // OAuth `space:` scope gate (manage). Build the target URI from the
201201+ // resolved authority/type/skey; no-op for app-password sessions.
202202+ let scope_uri = parse_space_uri(&format!(
203203+ "{}{}/{}/{}",
204204+ atproto_space::types::ATS_SCHEME,
205205+ authority_did,
206206+ input.space_type,
207207+ skey
208208+ ))?;
209209+ assert_space_manage(
210210+ &subject,
211211+ &scope_uri,
212212+ atproto_oauth::scopes::SpaceManageVerb::Create,
213213+ )?;
214214+ let config = match input.config {
215215+ Some(ref v) => crate::space::SpaceConfig::from_create_input(v).map_err(XrpcError::from)?,
216216+ None => crate::space::SpaceConfig::default(),
217217+ };
150218 let svc = space_service(&state)?;
151219 let info = svc
152152- .create_space(&owner_did, &input.space_type, &input.space_key)
220220+ .create_space(&authority_did, &input.space_type, &skey, config)
221221+ .await
222222+ .map_err(XrpcError::from)?;
223223+ Ok(Json(CreateSpaceResponse { uri: info.uri }))
224224+}
225225+226226+/// Inputs for `com.atproto.simplespace.updateSpace`.
227227+#[derive(Debug, Deserialize)]
228228+pub struct UpdateSpaceInput {
229229+ /// Space URI to update.
230230+ pub space: String,
231231+ /// New mint policy, if provided.
232232+ #[serde(rename = "mintPolicy")]
233233+ pub mint_policy: Option<String>,
234234+ /// New managing-app identifier. Empty string clears to NULL.
235235+ #[serde(rename = "managingApp")]
236236+ pub managing_app: Option<String>,
237237+ /// New app-access union, if provided (replaces wholesale).
238238+ #[serde(rename = "appAccess")]
239239+ pub app_access: Option<serde_json::Value>,
240240+}
241241+242242+/// `POST /xrpc/com.atproto.simplespace.updateSpace`.
243243+pub async fn update_space(
244244+ State(state): State<HttpState>,
245245+ parts: Parts,
246246+ Json(input): Json<UpdateSpaceInput>,
247247+) -> Result<StatusCode, XrpcError> {
248248+ let subject = require_session_auth(&parts, &state).await?;
249249+ let owner = subject.sub().to_string();
250250+ let uri = parse_space_uri(&input.space)?;
251251+ assert_space_manage(
252252+ &subject,
253253+ &uri,
254254+ atproto_oauth::scopes::SpaceManageVerb::Update,
255255+ )?;
256256+ // Reassemble the config-field object the patch parser expects.
257257+ let mut obj = serde_json::Map::new();
258258+ if let Some(p) = input.mint_policy {
259259+ obj.insert("mintPolicy".to_string(), serde_json::Value::String(p));
260260+ }
261261+ if let Some(a) = input.managing_app {
262262+ obj.insert("managingApp".to_string(), serde_json::Value::String(a));
263263+ }
264264+ if let Some(v) = input.app_access {
265265+ obj.insert("appAccess".to_string(), v);
266266+ }
267267+ let patch = crate::space::SpaceConfigPatch::from_update_input(&serde_json::Value::Object(obj))
268268+ .map_err(XrpcError::from)?;
269269+ space_service(&state)?
270270+ .update_space(&owner, &uri, patch)
271271+ .await
272272+ .map_err(XrpcError::from)?;
273273+ Ok(StatusCode::OK)
274274+}
275275+276276+/// Inputs for `com.atproto.simplespace.deleteSpace`.
277277+#[derive(Debug, Deserialize)]
278278+pub struct DeleteSpaceInput {
279279+ /// Space URI to tombstone.
280280+ pub space: String,
281281+}
282282+283283+/// `POST /xrpc/com.atproto.simplespace.deleteSpace`.
284284+pub async fn delete_space(
285285+ State(state): State<HttpState>,
286286+ parts: Parts,
287287+ Json(input): Json<DeleteSpaceInput>,
288288+) -> Result<StatusCode, XrpcError> {
289289+ let subject = require_session_auth(&parts, &state).await?;
290290+ let owner = subject.sub().to_string();
291291+ let uri = parse_space_uri(&input.space)?;
292292+ assert_space_manage(
293293+ &subject,
294294+ &uri,
295295+ atproto_oauth::scopes::SpaceManageVerb::Delete,
296296+ )?;
297297+ space_service(&state)?
298298+ .delete_space(&owner, &uri)
153299 .await
154300 .map_err(XrpcError::from)?;
155155- Ok(Json(info))
301301+302302+ // Best-effort: notify registered recipients + members that the space was
303303+ // deleted (com.atproto.space.notifySpaceDeleted). Failures are swallowed —
304304+ // the tombstone is already durable.
305305+ fire_notify_space_deleted(&state, &uri, &owner).await;
306306+307307+ Ok(StatusCode::OK)
308308+}
309309+310310+/// Best-effort fan-out of `notifySpaceDeleted` to every registered recipient
311311+/// and member of `uri` after the authority deletes the space. Resolves each
312312+/// target's PDS endpoint, mints a service-auth token (iss = authority, aud =
313313+/// target), and POSTs. All errors are logged and swallowed.
314314+async fn fire_notify_space_deleted(state: &HttpState, uri: &SpaceUri, authority_did: &str) {
315315+ let Ok(manager) = account_manager(state) else {
316316+ return;
317317+ };
318318+ let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname());
319319+ let http = reqwest::Client::builder()
320320+ .timeout(std::time::Duration::from_secs(10))
321321+ .user_agent(crate::user_agent())
322322+ .build()
323323+ .unwrap_or_default();
324324+325325+ // Owner signing key to mint the outbound service-auth tokens.
326326+ let signing_key = match crate::http::space_auth::local_signing_key(manager, authority_did).await
327327+ {
328328+ Ok(k) => k,
329329+ Err(e) => {
330330+ tracing::warn!(error = ?e, space = %uri, "notifySpaceDeleted: owner signing key unavailable; skipping fan-out");
331331+ return;
332332+ }
333333+ };
334334+335335+ // Open the owner's per-actor store to read recipients + members.
336336+ let owner_store = match SqlActorStore::open(manager.data_dir(), authority_did).await {
337337+ Ok(s) => s,
338338+ Err(e) => {
339339+ tracing::warn!(error = ?e, space = %uri, "notifySpaceDeleted: owner store unavailable; skipping fan-out");
340340+ return;
341341+ }
342342+ };
343343+344344+ // Collect distinct target DIDs: recipient services + members.
345345+ let mut targets: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
346346+ if let Ok(rows) = sqlx::query_as::<_, (String,)>(
347347+ "SELECT DISTINCT service_did FROM space_credential_recipient WHERE space = ?",
348348+ )
349349+ .bind(uri.to_string())
350350+ .fetch_all(owner_store.pool())
351351+ .await
352352+ {
353353+ for (did,) in rows {
354354+ targets.insert(did);
355355+ }
356356+ }
357357+ if let Ok(rows) = sqlx::query_as::<_, (String,)>("SELECT did FROM space_member WHERE space = ?")
358358+ .bind(uri.to_string())
359359+ .fetch_all(owner_store.pool())
360360+ .await
361361+ {
362362+ for (did,) in rows {
363363+ targets.insert(did);
364364+ }
365365+ }
366366+ targets.remove(authority_did);
367367+368368+ for target in targets {
369369+ if !target.starts_with("did:") {
370370+ continue;
371371+ }
372372+ let endpoint = match crate::space::recipient::resolve_service_endpoint(
373373+ &http,
374374+ &format!("{target}#atproto_pds"),
375375+ plc_dir,
376376+ )
377377+ .await
378378+ {
379379+ Ok(Some(ep)) => ep,
380380+ _ => continue,
381381+ };
382382+ let token = match crate::space::service_auth::mint_service_auth(
383383+ &signing_key,
384384+ authority_did,
385385+ &target,
386386+ "com.atproto.space.notifySpaceDeleted",
387387+ crate::space::service_auth::NOTIFY_SERVICE_AUTH_TTL_SECS,
388388+ ) {
389389+ Ok(t) => t,
390390+ Err(_) => continue,
391391+ };
392392+ let url = format!(
393393+ "{}/xrpc/com.atproto.space.notifySpaceDeleted",
394394+ endpoint.trim_end_matches('/')
395395+ );
396396+ let body = serde_json::json!({ "space": uri.to_string() });
397397+ let _ = http.post(&url).bearer_auth(&token).json(&body).send().await;
398398+ }
156399}
157400158401/// Query params for `getSpace`.
159402#[derive(Debug, Deserialize)]
160403pub struct GetSpaceQuery {
161161- /// Full `ats://...` URI of the space.
404404+ /// Full space URI.
162405 pub space: String,
163406}
164407165408/// `GET /xrpc/com.atproto.space.getSpace`.
409409+///
410410+/// A **host** query authorized by a **space credential** (spec XRPC table line
411411+/// 481). A space credential confers whole-space read access, so this accepts
412412+/// either a space credential or a covering OAuth `read` scope, mirroring the
413413+/// other host/repo read methods. The `read` scope is whole-space and so is not
414414+/// collection-constrained.
166415pub async fn get_space(
167416 State(state): State<HttpState>,
168417 parts: Parts,
169418 Query(q): Query<GetSpaceQuery>,
170170-) -> Result<Json<SpaceInfo>, XrpcError> {
171171- let viewer = require_session_subject(&parts, &state).await?;
419419+) -> Result<Json<GetSpaceOutput>, XrpcError> {
172420 let uri = parse_space_uri(&q.space)?;
421421+ let subject = require_any_authn(&parts, &state, &uri).await?;
422422+ assert_space_read_opt(&state, &subject, &uri).await?;
423423+ // The space authority hosts the space config; describe from the authority's
424424+ // store regardless of which member's credential authorized the read.
425425+ let viewer = match &subject {
426426+ Some(s) => s.sub().to_string(),
427427+ None => uri.space_did.clone(),
428428+ };
173429 let svc = space_service(&state)?;
174174- svc.get_space(&viewer, &uri)
430430+ let out = svc
431431+ .get_space(&viewer, &uri)
175432 .await
176176- .map_err(XrpcError::from)?
177177- .map(Json)
178178- .ok_or_else(|| {
179179- XrpcError::new(
180180- StatusCode::NOT_FOUND,
181181- "SpaceNotFound",
182182- format!("no such space {uri}"),
183183- )
184184- })
433433+ .map_err(XrpcError::from)?;
434434+ Ok(Json(out))
185435}
186436187437/// Query params for `listSpaces`.
···236486 pub did: String,
237487}
238488239239-/// `POST /xrpc/com.atproto.space.addMember`.
489489+/// `POST /xrpc/com.atproto.simplespace.addMember`.
240490pub async fn add_member(
241491 State(state): State<HttpState>,
242492 parts: Parts,
243493 Json(input): Json<MemberInput>,
244494) -> Result<StatusCode, XrpcError> {
245245- let owner = require_session_subject(&parts, &state).await?;
495495+ let subject = require_session_auth(&parts, &state).await?;
496496+ let owner = subject.sub().to_string();
246497 let uri = parse_space_uri(&input.space)?;
498498+ assert_space_manage(
499499+ &subject,
500500+ &uri,
501501+ atproto_oauth::scopes::SpaceManageVerb::Update,
502502+ )?;
247503 space_service(&state)?
248504 .add_member(&owner, &uri, &input.did)
249505 .await
250506 .map_err(XrpcError::from)?;
251251- if state.notify_membership_email {
252252- notify_membership_change(&state, &input.did, &uri, "added").await;
253253- }
254507 Ok(StatusCode::OK)
255508}
256509257257-/// `POST /xrpc/com.atproto.space.removeMember`.
510510+/// `POST /xrpc/com.atproto.simplespace.removeMember`.
258511pub async fn remove_member(
259512 State(state): State<HttpState>,
260513 parts: Parts,
261514 Json(input): Json<MemberInput>,
262515) -> Result<StatusCode, XrpcError> {
263263- let owner = require_session_subject(&parts, &state).await?;
516516+ let subject = require_session_auth(&parts, &state).await?;
517517+ let owner = subject.sub().to_string();
264518 let uri = parse_space_uri(&input.space)?;
519519+ assert_space_manage(
520520+ &subject,
521521+ &uri,
522522+ atproto_oauth::scopes::SpaceManageVerb::Update,
523523+ )?;
265524 space_service(&state)?
266525 .remove_member(&owner, &uri, &input.did)
267526 .await
268527 .map_err(XrpcError::from)?;
269269- if state.notify_membership_email {
270270- notify_membership_change(&state, &input.did, &uri, "removed").await;
271271- }
272528 Ok(StatusCode::OK)
273529}
274530275275-/// Best-effort: send a notification email to the affected member when
276276-/// `PDS_NOTIFY_MEMBERSHIP_EMAIL` is enabled. Logs
277277-/// continues on any failure (no email address, send error).
278278-async fn notify_membership_change(state: &HttpState, member_did: &str, uri: &SpaceUri, verb: &str) {
279279- // Lookup member's email — only send when the affected member is on
280280- // this PDS and has a confirmed email. Cross-PDS members are skipped
281281- // (we'd need a service-auth proxy to reach their PDS, out of scope).
282282- let directory = state.reader.accounts();
283283- let row = match directory.lookup_did(member_did).await {
284284- Ok(Some(r)) => r,
285285- _ => {
286286- tracing::debug!(member_did, "membership-email: member not local; skipping");
287287- return;
288288- }
289289- };
290290- let Some(email) = row.email else {
291291- tracing::debug!(
292292- member_did,
293293- "membership-email: no email on account; skipping"
294294- );
295295- return;
296296- };
297297- let subject = match verb {
298298- "added" => "You've been added to a Spaces group",
299299- "removed" => "You've been removed from a Spaces group",
300300- _ => "Spaces membership change",
301301- };
302302- let body = format!(
303303- "Your account ({member_did}) has been {verb} {} the Spaces group {uri}.\n\n\
304304- If you didn't expect this, contact the space owner via your client.",
305305- if verb == "added" { "to" } else { "from" }
306306- );
307307- if let Err(e) = state.email.send(&email, subject, &body).await {
308308- tracing::warn!(error = ?e, member_did, "membership-email send failed");
309309- }
310310-}
311311-312312-/// Query params for `getMembers`.
531531+/// Query params for `listMembers`.
313532#[derive(Debug, Deserialize)]
314533pub struct GetMembersQuery {
315534 /// Space URI.
···320539 pub limit: Option<u32>,
321540}
322541323323-/// Output of `getMembers`.
542542+/// Output of `listMembers`.
324543#[derive(Debug, Serialize)]
325544pub struct GetMembersResponse {
326545 /// Member DIDs on this page.
···343562 pub added_at: String,
344563}
345564346346-/// `GET /xrpc/com.atproto.space.getMembers`.
565565+/// `GET /xrpc/com.atproto.simplespace.listMembers`.
347566pub async fn get_members(
348567 State(state): State<HttpState>,
349568 parts: Parts,
350569 Query(q): Query<GetMembersQuery>,
351570) -> Result<Json<GetMembersResponse>, XrpcError> {
352352- let owner = require_session_subject(&parts, &state).await?;
571571+ let subject = require_session_auth(&parts, &state).await?;
572572+ let owner = subject.sub().to_string();
353573 let uri = parse_space_uri(&q.space)?;
574574+ assert_space_scope(
575575+ &state,
576576+ &subject,
577577+ &uri,
578578+ atproto_oauth::scopes::SpaceAction::Read,
579579+ None,
580580+ )
581581+ .await?;
354582 let page = space_service(&state)?
355583 .list_members(&owner, &uri, q.cursor.as_deref(), q.limit.unwrap_or(50))
356584 .await
···405633 parts: Parts,
406634 Json(input): Json<ApplyWritesInput>,
407635) -> Result<Json<SpaceCommitResult>, XrpcError> {
408408- let member_did = require_session_subject(&parts, &state).await?;
636636+ let auth = require_session_auth(&parts, &state).await?;
637637+ let member_did = auth.sub().to_string();
409638 let uri = parse_space_uri(&input.space)?;
410639 let writer = space_writer(&state)?;
411640···419648420649 let mut ops = Vec::with_capacity(input.writes.len());
421650 for w in input.writes {
422422- let action = match w.action.as_str() {
423423- "create" => SpaceWriteAction::Create,
424424- "update" => SpaceWriteAction::Update,
425425- "delete" => SpaceWriteAction::Delete,
651651+ let (action, scope_action) = match w.action.as_str() {
652652+ "create" => (
653653+ SpaceWriteAction::Create,
654654+ atproto_oauth::scopes::SpaceAction::Create,
655655+ ),
656656+ "update" => (
657657+ SpaceWriteAction::Update,
658658+ atproto_oauth::scopes::SpaceAction::Update,
659659+ ),
660660+ "delete" => (
661661+ SpaceWriteAction::Delete,
662662+ atproto_oauth::scopes::SpaceAction::Delete,
663663+ ),
426664 other => {
427665 return Err(XrpcError::new(
428666 StatusCode::BAD_REQUEST,
···431669 ));
432670 }
433671 };
672672+ // OAuth `space:` scope gate — each op's action must be covered for
673673+ // its collection (no-op for app-password sessions).
674674+ assert_space_scope(&state, &auth, &uri, scope_action, Some(&w.collection)).await?;
434675 ops.push(SpaceWriteOp {
435676 action,
436677 collection: w.collection,
···446687 .map_err(XrpcError::from)
447688}
448689690690+// ---------------------------------------------------------------------------
691691+// Single-op record writes: createRecord / putRecord / deleteRecord.
692692+//
693693+// Each is a thin wrapper over the SpaceWriter single-op path. The `repo`
694694+// field names the DID being written to and MUST equal the authenticated
695695+// subject — members write only to their own per-actor store.
696696+// ---------------------------------------------------------------------------
697697+698698+/// Output of `createRecord` / `putRecord`.
699699+#[derive(Debug, Serialize)]
700700+pub struct WriteRecordResponse {
701701+ /// Six-segment space-URI of the written record.
702702+ pub uri: String,
703703+ /// CID of the record value (DAG-CBOR).
704704+ pub cid: String,
705705+ /// Validation status when known.
706706+ #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")]
707707+ pub validation_status: Option<String>,
708708+}
709709+710710+/// Inputs for `createRecord`.
711711+#[derive(Debug, Deserialize)]
712712+pub struct CreateRecordInput {
713713+ /// Space URI.
714714+ pub space: String,
715715+ /// DID of the repo to write to (the authenticated member).
716716+ pub repo: String,
717717+ /// NSID collection.
718718+ pub collection: String,
719719+ /// Record key (optional — auto-TID when omitted).
720720+ pub rkey: Option<String>,
721721+ /// Lexicon validation toggle (reserved; not yet enforced).
722722+ #[allow(dead_code)]
723723+ pub validate: Option<bool>,
724724+ /// Record value (must contain a `$type` field).
725725+ pub record: serde_json::Value,
726726+}
727727+728728+/// `POST /xrpc/com.atproto.space.createRecord`.
729729+pub async fn create_record_write(
730730+ State(state): State<HttpState>,
731731+ parts: Parts,
732732+ Json(input): Json<CreateRecordInput>,
733733+) -> Result<Json<WriteRecordResponse>, XrpcError> {
734734+ let auth = require_session_auth(&parts, &state).await?;
735735+ let subject = auth.sub().to_string();
736736+ require_repo_matches_subject(&input.repo, &subject)?;
737737+ let uri = parse_space_uri(&input.space)?;
738738+ assert_space_scope(
739739+ &state,
740740+ &auth,
741741+ &uri,
742742+ atproto_oauth::scopes::SpaceAction::Create,
743743+ Some(&input.collection),
744744+ )
745745+ .await?;
746746+ let writer = space_writer(&state)?;
747747+ let result = writer
748748+ .create_record(
749749+ &subject,
750750+ &uri,
751751+ input.collection,
752752+ input.rkey.unwrap_or_default(),
753753+ input.record,
754754+ )
755755+ .await
756756+ .map_err(XrpcError::from)?;
757757+ Ok(Json(single_write_response(result)?))
758758+}
759759+760760+/// Inputs for `putRecord`.
761761+#[derive(Debug, Deserialize)]
762762+pub struct PutRecordInput {
763763+ /// Space URI.
764764+ pub space: String,
765765+ /// DID of the repo to write to (the authenticated member).
766766+ pub repo: String,
767767+ /// NSID collection.
768768+ pub collection: String,
769769+ /// Record key.
770770+ pub rkey: String,
771771+ /// Lexicon validation toggle (reserved; not yet enforced).
772772+ #[allow(dead_code)]
773773+ pub validate: Option<bool>,
774774+ /// Record value.
775775+ pub record: serde_json::Value,
776776+}
777777+778778+/// `POST /xrpc/com.atproto.space.putRecord`.
779779+pub async fn put_record_write(
780780+ State(state): State<HttpState>,
781781+ parts: Parts,
782782+ Json(input): Json<PutRecordInput>,
783783+) -> Result<Json<WriteRecordResponse>, XrpcError> {
784784+ let auth = require_session_auth(&parts, &state).await?;
785785+ let subject = auth.sub().to_string();
786786+ require_repo_matches_subject(&input.repo, &subject)?;
787787+ let uri = parse_space_uri(&input.space)?;
788788+ // putRecord may either create or update the record, so it requires both
789789+ // the `create` and `update` actions per the 0016 OAuth-scope rules (spec
790790+ // lines 405-411), asserting both before the upsert.
791791+ assert_space_scope(
792792+ &state,
793793+ &auth,
794794+ &uri,
795795+ atproto_oauth::scopes::SpaceAction::Create,
796796+ Some(&input.collection),
797797+ )
798798+ .await?;
799799+ assert_space_scope(
800800+ &state,
801801+ &auth,
802802+ &uri,
803803+ atproto_oauth::scopes::SpaceAction::Update,
804804+ Some(&input.collection),
805805+ )
806806+ .await?;
807807+ let writer = space_writer(&state)?;
808808+ let result = writer
809809+ .put_record(&subject, &uri, input.collection, input.rkey, input.record)
810810+ .await
811811+ .map_err(XrpcError::from)?;
812812+ Ok(Json(single_write_response(result)?))
813813+}
814814+815815+/// Inputs for `deleteRecord`.
816816+#[derive(Debug, Deserialize)]
817817+pub struct DeleteRecordInput {
818818+ /// Space URI.
819819+ pub space: String,
820820+ /// DID of the repo to delete from (the authenticated member).
821821+ pub repo: String,
822822+ /// NSID collection.
823823+ pub collection: String,
824824+ /// Record key.
825825+ pub rkey: String,
826826+}
827827+828828+/// `POST /xrpc/com.atproto.space.deleteRecord`.
829829+pub async fn delete_record_write(
830830+ State(state): State<HttpState>,
831831+ parts: Parts,
832832+ Json(input): Json<DeleteRecordInput>,
833833+) -> Result<Json<serde_json::Value>, XrpcError> {
834834+ let auth = require_session_auth(&parts, &state).await?;
835835+ let subject = auth.sub().to_string();
836836+ require_repo_matches_subject(&input.repo, &subject)?;
837837+ let uri = parse_space_uri(&input.space)?;
838838+ assert_space_scope(
839839+ &state,
840840+ &auth,
841841+ &uri,
842842+ atproto_oauth::scopes::SpaceAction::Delete,
843843+ Some(&input.collection),
844844+ )
845845+ .await?;
846846+ let writer = space_writer(&state)?;
847847+ writer
848848+ .delete_record(&subject, &uri, input.collection, input.rkey)
849849+ .await
850850+ .map_err(XrpcError::from)?;
851851+ Ok(Json(serde_json::json!({})))
852852+}
853853+854854+/// Enforce that the `repo` field of a record-write request names the
855855+/// authenticated subject. Members may only write to their own per-actor
856856+/// store.
857857+fn require_repo_matches_subject(repo: &str, subject: &str) -> Result<(), XrpcError> {
858858+ if repo == subject {
859859+ Ok(())
860860+ } else {
861861+ Err(XrpcError::new(
862862+ StatusCode::FORBIDDEN,
863863+ "InvalidRequest",
864864+ "repo must equal the authenticated subject",
865865+ ))
866866+ }
867867+}
868868+869869+/// Project a single-op [`SpaceCommitResult`] into a `createRecord` /
870870+/// `putRecord` output `{uri, cid}`.
871871+fn single_write_response(result: SpaceCommitResult) -> Result<WriteRecordResponse, XrpcError> {
872872+ let uri = result.uris.into_iter().next().ok_or_else(|| {
873873+ XrpcError::new(
874874+ StatusCode::INTERNAL_SERVER_ERROR,
875875+ "InternalError",
876876+ "write produced no record URI",
877877+ )
878878+ })?;
879879+ let cid = result.cids.into_iter().next().flatten().ok_or_else(|| {
880880+ XrpcError::new(
881881+ StatusCode::INTERNAL_SERVER_ERROR,
882882+ "InternalError",
883883+ "write produced no record CID",
884884+ )
885885+ })?;
886886+ Ok(WriteRecordResponse {
887887+ uri,
888888+ cid,
889889+ validation_status: None,
890890+ })
891891+}
892892+449893/// Query params for `getRecord`.
450894#[derive(Debug, Deserialize)]
451895pub struct GetSpaceRecordQuery {
···455899 pub collection: String,
456900 /// Record key.
457901 pub rkey: String,
902902+ /// DID of the member whose repo to read from. If omitted, defaults to
903903+ /// the authenticated subject (OAuth auth). Required when using
904904+ /// space-credential auth.
905905+ pub repo: Option<String>,
458906}
459907460908/// Output of `getRecord`.
···475923 Query(q): Query<GetSpaceRecordQuery>,
476924) -> Result<Json<GetSpaceRecordResponse>, XrpcError> {
477925 let uri = parse_space_uri(&q.space)?;
478478- let auth = resolve_record_auth(&parts, &state).await?;
926926+ let resolved = resolve_record_auth(&parts, &state, q.repo.as_deref()).await?;
927927+ if let Some(subject) = &resolved.subject {
928928+ assert_space_record_read(
929929+ &state,
930930+ subject,
931931+ &uri,
932932+ &resolved.target_repo,
933933+ Some(&q.collection),
934934+ )
935935+ .await?;
936936+ }
479937 let row = space_reader(&state)?
480480- .get_record(&uri, auth, &q.collection, &q.rkey)
938938+ .get_record(
939939+ &uri,
940940+ resolved.auth,
941941+ &resolved.target_repo,
942942+ &q.collection,
943943+ &q.rkey,
944944+ )
481945 .await
482946 .map_err(XrpcError::from)?
483947 .ok_or_else(|| {
···506970pub struct ListSpaceRecordsQuery {
507971 /// Space URI.
508972 pub space: String,
509509- /// NSID collection.
510510- pub collection: String,
511511- /// Cursor (last `rkey`).
973973+ /// NSID collection. When omitted, records are listed across every
974974+ /// collection in the space (one page per collection, no cross-collection
975975+ /// cursor).
976976+ pub collection: Option<String>,
977977+ /// Cursor (last `rkey`). Ignored when `collection` is omitted.
512978 pub cursor: Option<String>,
513979 /// Page size.
514980 pub limit: Option<u32>,
981981+ /// DID of the member whose repo to read from. If omitted, defaults to
982982+ /// the authenticated subject (OAuth auth). Required when using
983983+ /// space-credential auth.
984984+ pub repo: Option<String>,
515985}
516986517517-/// One record in `listRecords`.
987987+/// One record in `listRecords` — keys-only per
988988+/// `com.atproto.space.listRecords#record` (`{collection, rkey, cid}`). Fetch
989989+/// the value separately via `getRecord`.
518990#[derive(Debug, Serialize)]
519991pub struct SpaceRecordItem {
520520- /// AT-URI.
521521- pub uri: String,
522522- /// CID.
992992+ /// NSID collection.
993993+ pub collection: String,
994994+ /// Record key.
995995+ pub rkey: String,
996996+ /// CID of the record value.
523997 pub cid: String,
524524- /// Decoded value.
525525- pub value: serde_json::Value,
526998}
5279995281000/// Output of `listRecords`.
···5421014 Query(q): Query<ListSpaceRecordsQuery>,
5431015) -> Result<Json<ListSpaceRecordsResponse>, XrpcError> {
5441016 let uri = parse_space_uri(&q.space)?;
545545- let auth = resolve_record_auth(&parts, &state).await?;
10171017+ let resolved = resolve_record_auth(&parts, &state, q.repo.as_deref()).await?;
10181018+ if let Some(subject) = &resolved.subject {
10191019+ // listRecords may span every collection in the repo (collection
10201020+ // omitted). A `read_self` grant is collection-constrained, so a
10211021+ // cross-collection list of the own repo requires a whole-space `read`
10221022+ // grant; pass `None` to force the `read` path in that case.
10231023+ assert_space_record_read(
10241024+ &state,
10251025+ subject,
10261026+ &uri,
10271027+ &resolved.target_repo,
10281028+ q.collection.as_deref(),
10291029+ )
10301030+ .await?;
10311031+ }
5461032 let page = space_reader(&state)?
5471033 .list_records(
5481034 &uri,
549549- auth,
550550- &q.collection,
10351035+ resolved.auth,
10361036+ &resolved.target_repo,
10371037+ q.collection.as_deref(),
5511038 q.cursor.as_deref(),
5521039 q.limit.unwrap_or(50),
5531040 )
5541041 .await
5551042 .map_err(XrpcError::from)?;
556556- let mut records = Vec::with_capacity(page.records.len());
557557- for r in page.records {
558558- let value: serde_json::Value = atproto_dasl::from_slice(&r.value).map_err(|e| {
559559- XrpcError::new(
560560- StatusCode::INTERNAL_SERVER_ERROR,
561561- "InternalError",
562562- format!("decode record value: {e}"),
563563- )
564564- })?;
565565- records.push(SpaceRecordItem {
566566- uri: format!("{}/{}/{}", uri, r.collection, r.rkey),
10431043+ let records: Vec<SpaceRecordItem> = page
10441044+ .records
10451045+ .into_iter()
10461046+ .map(|r| SpaceRecordItem {
10471047+ collection: r.collection,
10481048+ rkey: r.rkey,
5671049 cid: r.cid,
568568- value,
569569- });
570570- }
10501050+ })
10511051+ .collect();
5711052 Ok(Json(ListSpaceRecordsResponse {
5721053 records,
5731054 cursor: page.cursor,
5741055 }))
5751056}
576105710581058+/// Resolved auth + read-target DID for a Spaces record read.
10591059+struct ResolvedRecordAuth<'a> {
10601060+ auth: SpaceReadAuth<'a>,
10611061+ target_repo: String,
10621062+ /// The bearer subject when the request authenticated via a session/OAuth
10631063+ /// access token. `None` for SpaceCredential auth, which pre-authorizes
10641064+ /// whole-space read at the auth layer and skips the `space:` scope gate.
10651065+ subject: Option<crate::http::auth::AuthSubject>,
10661066+}
10671067+5771068/// Decide which auth flavor a record-read uses based on the bearer token's
578578-/// `typ` header. Owns the borrow of `parts` so callers don't need separate
579579-/// branches.
10691069+/// `typ` header, validate the `repo` parameter against the auth mode, and
10701070+/// return the resolved target DID.
10711071+///
10721072+/// - **OAuth / session bearer** — `repo` may be omitted (defaults to the
10731073+/// authenticated subject) or supplied to read another member's per-actor
10741074+/// store on this PDS.
10751075+/// - **SpaceCredential** — `repo` is **required**; returns 400
10761076+/// `InvalidRequest` when missing because a SpaceCredential is not bound
10771077+/// to any one member's repo.
10781078+/// - **Delegation token** — rejected; delegation tokens must be exchanged at
10791079+/// `getSpaceCredential` before being used to read records.
5801080async fn resolve_record_auth<'a>(
5811081 parts: &'a Parts,
5821082 state: &HttpState,
583583-) -> Result<SpaceReadAuth<'a>, XrpcError> {
10831083+ repo: Option<&str>,
10841084+) -> Result<ResolvedRecordAuth<'a>, XrpcError> {
5841085 let raw = bearer_token(parts)?;
5851086 match classify(raw) {
586586- Some(SpaceTokenKind::SpaceCredential) => Ok(SpaceReadAuth::SpaceCredential {
587587- token: raw,
588588- // Issuer-binding simplification: bind to the issuer's
589589- // `client_id` claim rather than enforcing an HTTP-layer
590590- // expected client. The SpaceReader will re-verify the JWT
591591- // including its `client_id` claim; we pass the same value
592592- // here so that check is a no-op. The follow-up tracked in
593593- // pulls the expected `client_id` from a
594594- // DPoP/cnf binding on the peer access token wrapping this
595595- // credential.
596596- expected_client_id: extract_credential_client_id(raw)
597597- .map(|s| Box::leak(s.into_boxed_str()) as &str)
598598- .unwrap_or(""),
599599- }),
600600- Some(SpaceTokenKind::MemberGrant) => Err(XrpcError::new(
10871087+ Some(SpaceTokenKind::SpaceCredential) => {
10881088+ let repo = repo.ok_or_else(|| {
10891089+ XrpcError::new(
10901090+ StatusCode::BAD_REQUEST,
10911091+ "InvalidRequest",
10921092+ "repo is required for space credential auth",
10931093+ )
10941094+ })?;
10951095+ Ok(ResolvedRecordAuth {
10961096+ auth: SpaceReadAuth::SpaceCredential { token: raw },
10971097+ target_repo: repo.to_string(),
10981098+ subject: None,
10991099+ })
11001100+ }
11011101+ Some(SpaceTokenKind::DelegationToken) => Err(XrpcError::new(
6011102 StatusCode::BAD_REQUEST,
6021103 "InvalidToken",
603603- "MemberGrant cannot be used to read records; exchange it at getSpaceCredential first",
11041104+ "delegation token cannot be used to read records; exchange it at getSpaceCredential first",
6041105 )),
6051106 _ => {
6061107 // Treat as a session-style or OAuth access token. The unified
6071108 // helper transparently accepts both shapes and enforces DPoP
6081109 // when an OAuth token carries a `cnf.jkt` thumbprint.
6091110 let (htm, htu) = request_htm_htu(parts);
610610- let sub = require_authn_sub(parts, state, &htm, &htu).await?;
611611- let did_static: &'a str = Box::leak(sub.into_boxed_str());
612612- Ok(SpaceReadAuth::OwnPds {
613613- account_did: did_static,
11111111+ let subject = require_authn(parts, state, &htm, &htu).await?;
11121112+ let sub = subject.sub().to_string();
11131113+ let did_static: &'a str = Box::leak(sub.clone().into_boxed_str());
11141114+ let target_repo = repo.map(|r| r.to_string()).unwrap_or(sub);
11151115+ Ok(ResolvedRecordAuth {
11161116+ auth: SpaceReadAuth::OwnPds {
11171117+ account_did: did_static,
11181118+ },
11191119+ target_repo,
11201120+ subject: Some(subject),
6141121 })
6151122 }
6161123 }
6171124}
6181125619619-/// Best-effort extraction of `clientId` from a SpaceCredential JWT *without*
620620-/// signature verification — used solely to forward the value into
621621-/// `SpaceReadAuth::SpaceCredential::expected_client_id` so the reader's
622622-/// re-verification accepts the same bound `client_id`.
623623-fn extract_credential_client_id(token: &str) -> Option<String> {
624624- let payload_b64 = token.split('.').nth(1)?;
625625- let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
626626- .decode(payload_b64.as_bytes())
627627- .ok()?;
628628- let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
629629- Some(value.get("clientId")?.as_str()?.to_string())
630630-}
631631-6321126// ---------------------------------------------------------------------------
6331127// Sync endpoints.
6341128// ---------------------------------------------------------------------------
···6381132pub struct RepoStateQuery {
6391133 /// Space URI.
6401134 pub space: String,
641641- /// Member DID whose record-state to fetch.
642642- pub member: String,
11351135+ /// DID of the account whose repo state to retrieve.
11361136+ pub repo: String,
6431137}
6441138645645-/// Output of `getRepoState` / `getMemberState`.
11391139+/// JSON wire form of a signed commit (`com.atproto.space.defs#signedCommit`).
11401140+///
11411141+/// The four byte fields are emitted in atproto's lex-data `bytes` form
11421142+/// (`{"$bytes": "<base64>"}`, standard alphabet, unpadded) rather than the
11431143+/// JSON array that [`atproto_space::Commit`]'s `serde_bytes` derive would
11441144+/// produce, so the wire shape matches the lexicon and the 0016 spec
11451145+/// `#signedCommit` field table (lines 307-316).
11461146+#[derive(Debug, Serialize)]
11471147+pub struct SignedCommitDto {
11481148+ /// `sha256` of the LtHash state (32 bytes), as `{"$bytes": ...}`.
11491149+ pub hash: BytesValue,
11501150+ /// `HMAC-SHA256` over `hash`, as `{"$bytes": ...}`.
11511151+ pub mac: BytesValue,
11521152+ /// Per-commit fresh IKM (32 bytes), as `{"$bytes": ...}`.
11531153+ pub ikm: BytesValue,
11541154+ /// `sign(ctx)` over the commit context, as `{"$bytes": ...}`.
11551155+ pub sig: BytesValue,
11561156+ /// Commit revision (TID).
11571157+ pub rev: String,
11581158+}
11591159+11601160+/// atproto lex-data `bytes` value — serializes as `{"$bytes": "<base64>"}`
11611161+/// (standard alphabet, unpadded).
11621162+#[derive(Debug)]
11631163+pub struct BytesValue(Vec<u8>);
11641164+11651165+impl Serialize for BytesValue {
11661166+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
11671167+ where
11681168+ S: serde::Serializer,
11691169+ {
11701170+ use serde::ser::SerializeMap;
11711171+ let b64 = base64::engine::general_purpose::STANDARD_NO_PAD.encode(&self.0);
11721172+ let mut map = serializer.serialize_map(Some(1))?;
11731173+ map.serialize_entry("$bytes", &b64)?;
11741174+ map.end()
11751175+ }
11761176+}
11771177+11781178+impl SignedCommitDto {
11791179+ /// Convert an [`atproto_space::Commit`] into its `$bytes`-encoded wire DTO.
11801180+ fn from_commit(c: atproto_space::Commit) -> Self {
11811181+ Self {
11821182+ hash: BytesValue(c.hash),
11831183+ mac: BytesValue(c.mac),
11841184+ ikm: BytesValue(c.ikm),
11851185+ sig: BytesValue(c.sig),
11861186+ rev: c.rev,
11871187+ }
11881188+ }
11891189+}
11901190+11911191+/// Output of `getRepoState`.
11921192+///
11931193+/// `commit` is absent when the repo has never been written to, per
11941194+/// `com.atproto.space.getRepoState`.
6461195#[derive(Debug, Serialize)]
6471196pub struct StateResponse {
648648- /// Hex-encoded SetHash digest. `null` if empty.
649649- #[serde(rename = "setHash")]
650650- pub set_hash: Option<String>,
651651- /// Latest rev (TID). `null` if empty.
652652- pub rev: Option<String>,
11971197+ /// The current signed commit, or absent when empty.
11981198+ #[serde(skip_serializing_if = "Option::is_none")]
11991199+ pub commit: Option<SignedCommitDto>,
12001200+}
12011201+12021202+/// Build a signed commit from a persisted SetHash state + rev.
12031203+///
12041204+/// Rehydrates the [`PdsSetHash`](crate::realm::PdsSetHash) lattice from the
12051205+/// 2048-byte state persisted in [`RepoState`](atproto_space::RepoState),
12061206+/// derives the 32-byte commitment, and signs a [`SpaceContext`] (the full
12071207+/// `ats://` space URI + rev) with `signing_key`, per the 0016 Permissioned Data
12081208+/// draft (§ Commit signature). Returns `None` when the state is empty (no
12091209+/// commits yet).
12101210+fn signed_commit_from_state(
12111211+ space: &SpaceUri,
12121212+ state: &atproto_space::RepoState,
12131213+ signing_key: &atproto_identity::key::KeyData,
12141214+) -> Result<Option<SignedCommitDto>, XrpcError> {
12151215+ use atproto_space::set_hash::SetHash;
12161216+ let (Some(state_bytes), Some(rev)) = (state.set_hash.as_deref(), state.rev.as_deref()) else {
12171217+ return Ok(None);
12181218+ };
12191219+ let set_hash = crate::realm::PdsSetHash::from_state_bytes(state_bytes).map_err(|e| {
12201220+ XrpcError::new(
12211221+ StatusCode::INTERNAL_SERVER_ERROR,
12221222+ "InternalError",
12231223+ format!("rehydrate set hash: {e}"),
12241224+ )
12251225+ })?;
12261226+ let ctx = atproto_space::SpaceContext {
12271227+ space: space.to_string(),
12281228+ rev: rev.to_string(),
12291229+ };
12301230+ let commit = atproto_space::create_commit(&set_hash, &ctx, signing_key).map_err(|e| {
12311231+ XrpcError::new(
12321232+ StatusCode::INTERNAL_SERVER_ERROR,
12331233+ "InternalError",
12341234+ format!("sign commit: {e}"),
12351235+ )
12361236+ })?;
12371237+ Ok(Some(SignedCommitDto::from_commit(commit)))
6531238}
65412396551240/// `GET /xrpc/com.atproto.space.getRepoState`.
12411241+///
12421242+/// Returns the repo account's current signed commit (`records` scope, signed
12431243+/// by the repo account's atproto signing key). `commit` is absent when the
12441244+/// repo is empty.
6561245pub async fn get_repo_state(
6571246 State(state): State<HttpState>,
6581247 parts: Parts,
6591248 Query(q): Query<RepoStateQuery>,
6601249) -> Result<Json<StateResponse>, XrpcError> {
661661- require_any_authn(&parts, &state).await?;
6621250 let uri = parse_space_uri(&q.space)?;
12511251+ let subject = require_any_authn(&parts, &state, &uri).await?;
12521252+ assert_space_read_opt(&state, &subject, &uri).await?;
6631253 let st = space_sync(&state)?
664664- .get_repo_state(&uri, &q.member)
12541254+ .get_repo_state(&uri, &q.repo)
6651255 .await
6661256 .map_err(XrpcError::from)?;
667667- Ok(Json(StateResponse {
668668- set_hash: st.set_hash.as_deref().map(hex::encode),
669669- rev: st.rev,
670670- }))
12571257+ let manager = account_manager(&state)?;
12581258+ let signing_key = local_signing_key(manager, &q.repo).await?;
12591259+ let commit = signed_commit_from_state(&uri, &st, &signing_key)?;
12601260+ Ok(Json(StateResponse { commit }))
6711261}
6721262673673-/// Query params for `getRepoOplog`.
12631263+/// Query params for `listRepoOps`.
6741264#[derive(Debug, Deserialize)]
6751265pub struct RepoOplogQuery {
6761266 /// Space URI.
6771267 pub space: String,
678678- /// Member DID.
679679- pub member: String,
680680- /// Rev to start *after* (exclusive).
12681268+ /// DID of the account whose oplog to retrieve.
12691269+ pub repo: String,
12701270+ /// Opaque `(rev, idx)` cursor (`"<rev>__<idx>"`) to start *after*
12711271+ /// (exclusive). Carries the last op delivered on the prior page so that an
12721272+ /// atomic batch larger than `limit` is not skipped across paging.
6811273 pub since: Option<String>,
6821274 /// Page size.
6831275 pub limit: Option<u32>,
6841276}
6851277686686-/// One oplog entry in the wire form.
12781278+/// One records-oplog entry, wire shape per `com.atproto.space.listRepoOps#opEntry`.
12791279+///
12801280+/// Exactly `{ rev, collection, rkey, cid, prev }`. `cid` is `null` for deletes;
12811281+/// `prev` is `null` for creates (both keys are always present, per the
12821282+/// lexicon's `nullable` set).
6871283#[derive(Debug, Serialize)]
688688-pub struct OplogEntryDto {
689689- /// Rev (TID).
12841284+pub struct RecordOpEntry {
12851285+ /// Rev (TID). Ops sharing a rev belong to the same batch.
6901286 pub rev: String,
691691- /// Index within the batch.
692692- pub idx: u32,
693693- /// Action.
694694- pub action: String,
695695- /// NSID collection (records only).
696696- #[serde(skip_serializing_if = "Option::is_none")]
697697- pub collection: Option<String>,
698698- /// Record key (records only).
699699- #[serde(skip_serializing_if = "Option::is_none")]
700700- pub rkey: Option<String>,
701701- /// New CID (records only).
702702- #[serde(skip_serializing_if = "Option::is_none")]
12871287+ /// NSID collection.
12881288+ pub collection: String,
12891289+ /// Record key.
12901290+ pub rkey: String,
12911291+ /// New record CID; `null` for deletes.
7031292 pub cid: Option<String>,
704704- /// Prior CID (records only).
705705- #[serde(skip_serializing_if = "Option::is_none")]
12931293+ /// Prior record CID; `null` for creates.
7061294 pub prev: Option<String>,
707707- /// DID (members only).
708708- #[serde(skip_serializing_if = "Option::is_none")]
709709- pub did: Option<String>,
7101295}
7111296712712-/// Output of `getRepoOplog` / `getMemberOplog`.
12971297+/// Output of `listRepoOps`.
12981298+///
12991299+/// `commit` is included only when the page reaches the head of the oplog
13001300+/// (`ops.len() < limit`), so a caught-up consumer can verify the resulting
13011301+/// state; it is omitted on backfill responses.
7131302#[derive(Debug, Serialize)]
714714-pub struct OplogResponse {
13031303+pub struct RepoOpsResponse {
7151304 /// Oplog ops on this page (rev,idx ascending).
716716- pub ops: Vec<OplogEntryDto>,
717717- /// Current state at read time.
718718- pub state: StateResponse,
13051305+ pub ops: Vec<RecordOpEntry>,
13061306+ /// The repo's current signed commit, when caught up. Absent on backfill
13071307+ /// or when the repo is empty.
13081308+ #[serde(skip_serializing_if = "Option::is_none")]
13091309+ pub commit: Option<SignedCommitDto>,
13101310+ /// Opaque `(rev, idx)` cursor for the next page (the last op on this page),
13111311+ /// when more may remain. Encoded as `"<rev>__<idx>"` so a batch larger than
13121312+ /// `limit` resumes within the batch rather than skipping its tail.
13131313+ #[serde(skip_serializing_if = "Option::is_none")]
13141314+ pub cursor: Option<String>,
7191315}
7201316721721-/// `GET /xrpc/com.atproto.space.getRepoOplog`.
722722-pub async fn get_repo_oplog(
13171317+/// `GET /xrpc/com.atproto.space.listRepoOps`.
13181318+///
13191319+/// Incremental sync for a per-account repo within a space. On a caught-up
13201320+/// page (fewer ops than `limit`), attaches the repo's current signed commit
13211321+/// (`records` scope, signed by the repo account's key).
13221322+pub async fn list_repo_ops(
7231323 State(state): State<HttpState>,
7241324 parts: Parts,
7251325 Query(q): Query<RepoOplogQuery>,
726726-) -> Result<Json<OplogResponse>, XrpcError> {
727727- require_any_authn(&parts, &state).await?;
13261326+) -> Result<Json<RepoOpsResponse>, XrpcError> {
7281327 let uri = parse_space_uri(&q.space)?;
13281328+ let subject = require_any_authn(&parts, &state, &uri).await?;
13291329+ assert_space_read_opt(&state, &subject, &uri).await?;
13301330+ let limit = q.limit.unwrap_or(100);
13311331+ let since = match q.since.as_deref() {
13321332+ Some(token) => Some(OplogCursor::from_token(token).map_err(|_| {
13331333+ XrpcError::new(
13341334+ StatusCode::BAD_REQUEST,
13351335+ "InvalidRequest",
13361336+ "since cursor is malformed",
13371337+ )
13381338+ })?),
13391339+ None => None,
13401340+ };
7291341 let page = space_sync(&state)?
730730- .get_repo_oplog(&uri, &q.member, q.since.as_deref(), q.limit.unwrap_or(100))
731731- .await
732732- .map_err(XrpcError::from)?;
733733- Ok(Json(oplog_to_dto(page)))
734734-}
735735-736736-/// Query params for `getMemberState`.
737737-#[derive(Debug, Deserialize)]
738738-pub struct MemberStateQuery {
739739- /// Space URI.
740740- pub space: String,
741741-}
742742-743743-/// `GET /xrpc/com.atproto.space.getMemberState`.
744744-pub async fn get_member_state(
745745- State(state): State<HttpState>,
746746- parts: Parts,
747747- Query(q): Query<MemberStateQuery>,
748748-) -> Result<Json<StateResponse>, XrpcError> {
749749- require_any_authn(&parts, &state).await?;
750750- let uri = parse_space_uri(&q.space)?;
751751- let st = space_sync(&state)?
752752- .get_member_state(&uri)
13421342+ .list_repo_ops(&uri, &q.repo, since.as_ref(), limit)
7531343 .await
7541344 .map_err(XrpcError::from)?;
755755- Ok(Json(StateResponse {
756756- set_hash: st.set_hash.as_deref().map(hex::encode),
757757- rev: st.rev,
758758- }))
759759-}
7601345761761-/// Query params for `getMemberOplog`.
762762-#[derive(Debug, Deserialize)]
763763-pub struct MemberOplogQuery {
764764- /// Space URI.
765765- pub space: String,
766766- /// Rev to start after.
767767- pub since: Option<String>,
768768- /// Page size.
769769- pub limit: Option<u32>,
770770-}
13461346+ let caught_up = (page.ops.len() as u32) < limit;
13471347+ // Next-page cursor is the `(rev, idx)` of the last op on this page, so a
13481348+ // batch larger than `limit` resumes within the batch on the next call.
13491349+ let cursor = if caught_up {
13501350+ None
13511351+ } else {
13521352+ page.ops
13531353+ .last()
13541354+ .map(|o| OplogCursor::new(o.rev.clone(), o.idx).to_token())
13551355+ };
13561356+ let ops: Vec<RecordOpEntry> = page
13571357+ .ops
13581358+ .into_iter()
13591359+ .map(|o| RecordOpEntry {
13601360+ rev: o.rev,
13611361+ collection: o.collection.unwrap_or_default(),
13621362+ rkey: o.rkey.unwrap_or_default(),
13631363+ cid: o.cid,
13641364+ prev: o.prev,
13651365+ })
13661366+ .collect();
7711367772772-/// `GET /xrpc/com.atproto.space.getMemberOplog`.
773773-pub async fn get_member_oplog(
774774- State(state): State<HttpState>,
775775- parts: Parts,
776776- Query(q): Query<MemberOplogQuery>,
777777-) -> Result<Json<OplogResponse>, XrpcError> {
778778- require_any_authn(&parts, &state).await?;
779779- let uri = parse_space_uri(&q.space)?;
780780- let page = space_sync(&state)?
781781- .get_member_oplog(&uri, q.since.as_deref(), q.limit.unwrap_or(100))
782782- .await
783783- .map_err(XrpcError::from)?;
784784- Ok(Json(oplog_to_dto(page)))
785785-}
13681368+ let commit = if caught_up {
13691369+ let manager = account_manager(&state)?;
13701370+ let signing_key = local_signing_key(manager, &q.repo).await?;
13711371+ signed_commit_from_state(&uri, &page.state, &signing_key)?
13721372+ } else {
13731373+ None
13741374+ };
7861375787787-fn oplog_to_dto(page: atproto_space::storage::OplogPage) -> OplogResponse {
788788- OplogResponse {
789789- ops: page
790790- .ops
791791- .into_iter()
792792- .map(|o| OplogEntryDto {
793793- rev: o.rev,
794794- idx: o.idx,
795795- action: o.action,
796796- collection: o.collection,
797797- rkey: o.rkey,
798798- cid: o.cid,
799799- prev: o.prev,
800800- did: o.did,
801801- })
802802- .collect(),
803803- state: StateResponse {
804804- set_hash: page.state.set_hash.as_deref().map(hex::encode),
805805- rev: page.state.rev,
806806- },
807807- }
13761376+ Ok(Json(RepoOpsResponse {
13771377+ ops,
13781378+ commit,
13791379+ cursor,
13801380+ }))
8081381}
80913828101383// ---------------------------------------------------------------------------
8111384// Credential mint endpoints.
8121385// ---------------------------------------------------------------------------
8131386814814-/// Inputs for `getMemberGrant`.
13871387+/// Query params for `getDelegationToken`.
8151388#[derive(Debug, Deserialize)]
816816-pub struct GetMemberGrantInput {
13891389+pub struct GetDelegationTokenQuery {
8171390 /// Space URI.
8181391 pub space: String,
819819- /// OAuth `client_id` of the requesting app.
820820- #[serde(rename = "clientId")]
821821- pub client_id: String,
8221392}
8231393824824-/// Output of `getMemberGrant` and `getSpaceCredential`.
13941394+/// Output of `getDelegationToken` — `{ token }` only, per the
13951395+/// `com.atproto.space.getDelegationToken` lexicon.
8251396#[derive(Debug, Serialize)]
826826-pub struct TokenWrapper {
827827- /// The compact-form JWT.
13971397+pub struct DelegationTokenResponse {
13981398+ /// The compact-form delegation JWT.
8281399 pub token: String,
829829- /// Expiry in seconds since epoch.
830830- #[serde(rename = "expiresAt")]
831831- pub expires_at: u64,
14001400+}
14011401+14021402+/// Output of `getSpaceCredential` — `{ credential }`, the bare JWT, per the
14031403+/// `com.atproto.space.getSpaceCredential` lexicon (spec lines 246).
14041404+#[derive(Debug, Serialize)]
14051405+pub struct SpaceCredentialResponse {
14061406+ /// The compact-form space-credential JWT.
14071407+ pub credential: String,
8321408}
8331409834834-/// `POST /xrpc/com.atproto.space.getMemberGrant` — member-OAuth gated.
835835-/// Mints a [`MemberGrant`](atproto_space::credential::MemberGrant) signed by
836836-/// the member's atproto signing key, scoped to the given `clientId`.
837837-pub async fn get_member_grant(
14101410+/// `GET /xrpc/com.atproto.space.getDelegationToken` — member-OAuth gated.
14111411+/// Mints a [`DelegationToken`](atproto_space::credential::DelegationToken)
14121412+/// signed by the member's atproto signing key (header `kid="#atproto"`).
14131413+///
14141414+/// The delegation token asserts only the user-to-app delegation; it carries no
14151415+/// app identity. It is `aud`-addressed to the space host
14161416+/// (`<spaceDid>#atproto_space_host`) and `sub`-bound to the space URI. The
14171417+/// output body is exactly `{ "token": <jwt> }` — the token is later exchanged
14181418+/// with the space authority at `getSpaceCredential`.
14191419+pub async fn get_delegation_token(
8381420 State(state): State<HttpState>,
8391421 parts: Parts,
840840- Json(input): Json<GetMemberGrantInput>,
841841-) -> Result<Json<TokenWrapper>, XrpcError> {
842842- let member_did = require_session_subject(&parts, &state).await?;
843843- let uri = parse_space_uri(&input.space)?;
844844- let manager = account_manager(&state)?;
845845- let signing_key = local_signing_key(manager, &member_did).await?;
846846- let token = create_member_grant(
847847- &member_did,
848848- &uri.owner_did,
14221422+ Query(q): Query<GetDelegationTokenQuery>,
14231423+) -> Result<Json<DelegationTokenResponse>, XrpcError> {
14241424+ let (htm, htu) = request_htm_htu(&parts);
14251425+ let subject = require_authn(&parts, &state, &htm, &htu).await?;
14261426+ let member_did = subject.sub().to_string();
14271427+ // The delegation token proves an app is acting on the user's behalf, so
14281428+ // the request must come from an OAuth session (which carries a client
14291429+ // identity). The token itself records nothing about the app — app
14301430+ // identity is the client attestation's job — but we still reject
14311431+ // app-password sessions here, matching the OAuth-gated flow.
14321432+ if subject.client_id().is_none() {
14331433+ return Err(XrpcError::new(
14341434+ StatusCode::FORBIDDEN,
14351435+ "InvalidRequest",
14361436+ "getDelegationToken requires OAuth auth with a client_id",
14371437+ ));
14381438+ }
14391439+ let uri = parse_space_uri(&q.space)?;
14401440+ // OAuth `space:` read-scope gate before minting. No-op for app-password
14411441+ // sessions, which are rejected above for lacking a client_id anyway.
14421442+ assert_space_scope(
14431443+ &state,
14441444+ &subject,
8491445 &uri,
850850- &input.client_id,
851851- &signing_key,
852852- MEMBER_GRANT_TTL_SECS,
14461446+ atproto_oauth::scopes::SpaceAction::Read,
14471447+ None,
8531448 )
854854- .map_err(|e| {
855855- XrpcError::new(
856856- StatusCode::INTERNAL_SERVER_ERROR,
857857- "InternalError",
858858- format!("mint MemberGrant: {e}"),
859859- )
860860- })?;
861861- Ok(Json(TokenWrapper {
862862- token,
863863- expires_at: now_secs() + MEMBER_GRANT_TTL_SECS,
864864- }))
14491449+ .await?;
14501450+ let manager = account_manager(&state)?;
14511451+ let signing_key = local_signing_key(manager, &member_did).await?;
14521452+ let token = create_delegation_token(&member_did, &uri, &signing_key, DELEGATION_TOKEN_TTL_SECS)
14531453+ .map_err(|e| {
14541454+ XrpcError::new(
14551455+ StatusCode::INTERNAL_SERVER_ERROR,
14561456+ "InternalError",
14571457+ format!("mint delegation token: {e}"),
14581458+ )
14591459+ })?;
14601460+ Ok(Json(DelegationTokenResponse { token }))
8651461}
8661462867867-/// Inputs for `getSpaceCredential` — the grant *is* the auth.
14631463+/// Inputs for `getSpaceCredential`. The delegation token is presented in the
14641464+/// `Authorization: Bearer` header (not the body); the body carries the target
14651465+/// space and an optional client attestation.
8681466#[derive(Debug, Deserialize)]
8691467pub struct GetSpaceCredentialInput {
870870- /// MemberGrant compact-form JWT.
871871- pub grant: String,
14681468+ /// The space being requested, an `ats://` URI.
14691469+ pub space: String,
14701470+ /// Optional client attestation (compact JWT) establishing the app's
14711471+ /// identity. Required only when the space gates on app identity
14721472+ /// (`appAccess` is `#allowList`). Matches the lexicon
14731473+ /// `clientAttestation` field.
14741474+ #[serde(rename = "clientAttestation", default)]
14751475+ pub client_attestation: Option<String>,
8721476}
8731477874874-/// `POST /xrpc/com.atproto.space.getSpaceCredential` — grant-gated.
875875-/// Verifies the [`MemberGrant`](atproto_space::credential::MemberGrant)
876876-/// against the member's signing key, then mints a
14781478+/// `POST /xrpc/com.atproto.space.getSpaceCredential` — delegation-token gated.
14791479+/// Reads the [`DelegationToken`](atproto_space::credential::DelegationToken)
14801480+/// from the `Authorization: Bearer` header, verifies it against the member's
14811481+/// `#atproto` signing key, enforces single-use via its `jti`, then mints a
8771482/// [`SpaceCredential`](atproto_space::credential::SpaceCredential) signed by
878878-/// the owner's signing key.
14831483+/// the authority's `#atproto_space` signing key.
8791484pub async fn get_space_credential(
8801485 State(state): State<HttpState>,
14861486+ parts: Parts,
8811487 Json(input): Json<GetSpaceCredentialInput>,
882882-) -> Result<Json<TokenWrapper>, XrpcError> {
14881488+) -> Result<Json<SpaceCredentialResponse>, XrpcError> {
8831489 let manager = account_manager(&state)?;
8841490885885- // First, peek the grant payload to learn space + clientId so we can
886886- // verify against the right key.
887887- let payload_b64 = input.grant.split('.').nth(1).ok_or_else(|| {
888888- XrpcError::new(StatusCode::BAD_REQUEST, "InvalidToken", "grant: malformed")
889889- })?;
890890- let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
891891- .decode(payload_b64.as_bytes())
14911491+ // The delegation token is the bearer credential.
14921492+ let grant_jwt = bearer_token(&parts)?;
14931493+14941494+ let space = parse_space_uri(&input.space)?;
14951495+14961496+ // Peek the delegation token to learn its issuer (the member) so we know
14971497+ // which key to resolve, and confirm it targets this space.
14981498+ let unverified = peek_delegation_token(grant_jwt)?;
14991499+15001500+ // Try the local path first (fast); on `AccountNotFound` (the
15011501+ // member is not on this PDS), fall through to the remote path that
15021502+ // resolves the member's DID document via atproto-identity.
15031503+ let payload =
15041504+ match verify_local_delegation_token(manager, grant_jwt, &space.space_did, &space).await {
15051505+ Ok(p) => p,
15061506+ Err(e) if e.status == StatusCode::NOT_FOUND && e.name == "AccountNotFound" => {
15071507+ tracing::debug!(
15081508+ member = %unverified.iss,
15091509+ "member not local; attempting cross-PDS DID-document resolution"
15101510+ );
15111511+ let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname());
15121512+ let http = reqwest::Client::builder()
15131513+ .timeout(std::time::Duration::from_secs(10))
15141514+ .user_agent(crate::user_agent())
15151515+ .build()
15161516+ .unwrap_or_default();
15171517+ crate::http::space_auth::verify_remote_delegation_token(
15181518+ &http,
15191519+ grant_jwt,
15201520+ &space.space_did,
15211521+ &space,
15221522+ plc_dir,
15231523+ )
15241524+ .await?
15251525+ }
15261526+ Err(e) => return Err(e),
15271527+ };
15281528+15291529+ // Enforce single-use of the delegation token via its `jti` (spec line
15301530+ // 149). Consume it before minting so a replayed token is refused.
15311531+ let dt_ttl = std::time::Duration::from_secs(payload.exp.saturating_sub(now_secs()));
15321532+ state
15331533+ .jti_guard
15341534+ .check_and_insert(&payload.jti, dt_ttl)
15351535+ .await
8921536 .map_err(|_| {
8931537 XrpcError::new(
894894- StatusCode::BAD_REQUEST,
15381538+ StatusCode::FORBIDDEN,
8951539 "InvalidToken",
896896- "grant: payload not base64url",
15401540+ "delegation token already used (single-use replay)",
8971541 )
8981542 })?;
899899- let unverified: atproto_space::credential::MemberGrant = serde_json::from_slice(&bytes)
900900- .map_err(|_| {
901901- XrpcError::new(
902902- StatusCode::BAD_REQUEST,
903903- "InvalidToken",
904904- "grant: payload not JSON",
15431543+15441544+ let owner_signing = local_signing_key(manager, &space.space_did).await?;
15451545+15461546+ // ── Mint-time authorization (defs.json: a credential is minted only when
15471547+ // the user is authorized by `mintPolicy` AND their app by `appAccess`).
15481548+ //
15491549+ // The requesting member is the delegation token's issuer. App identity is
15501550+ // established solely by the optional client attestation: when one is
15511551+ // presented we verify it (which yields the attested client_id) and use
15521552+ // that for the APP axis and the credential's `client_id`. When none is
15531553+ // presented the credential's `client_id` is omitted (spec lines 221, 228).
15541554+ let mint_http = reqwest::Client::builder()
15551555+ .timeout(std::time::Duration::from_secs(10))
15561556+ .user_agent(crate::user_agent())
15571557+ .build()
15581558+ .unwrap_or_default();
15591559+15601560+ let attested_client_id: Option<String> = match input.client_attestation.as_deref() {
15611561+ Some(att) => Some(
15621562+ crate::space::mint_authz::verify_client_attestation(
15631563+ &mint_http,
15641564+ &state.jti_guard,
15651565+ att,
15661566+ &space,
9051567 )
906906- })?;
15681568+ .await
15691569+ .map_err(mint_denial_to_xrpc)?,
15701570+ ),
15711571+ None => None,
15721572+ };
9071573908908- let space = parse_space_uri(&unverified.space)?;
909909- // §3.3: try the local path first (fast); on `AccountNotFound` (the
910910- // member is not on this PDS), fall through to the remote path that
911911- // resolves the member's DID document via atproto-identity.
912912- let payload = match verify_local_member_grant(
913913- manager,
914914- &state.jti_guard,
915915- &input.grant,
916916- &space.owner_did,
917917- &space,
918918- &unverified.client_id,
919919- )
920920- .await
15741574+ let svc = space_service(&state)?;
15751575+ let inputs = svc
15761576+ .load_mint_authz_inputs(&space, &payload.iss)
15771577+ .await
15781578+ .map_err(XrpcError::from)?;
15791579+ if !inputs.found {
15801580+ return Err(XrpcError::new(
15811581+ StatusCode::NOT_FOUND,
15821582+ "SpaceNotFound",
15831583+ format!("space not found: {space}"),
15841584+ ));
15851585+ }
15861586+ if inputs.deleted {
15871587+ return Err(XrpcError::new(
15881588+ StatusCode::NOT_FOUND,
15891589+ "SpaceDeleted",
15901590+ format!("space deleted: {space}"),
15911591+ ));
15921592+ }
15931593+15941594+ // USER axis (mintPolicy).
15951595+ match crate::space::mint_authz::user_axis_local(inputs.config.mint_policy, inputs.is_member)
15961596+ .map_err(mint_denial_to_xrpc)?
9211597 {
922922- Ok(p) => p,
923923- Err(e) if e.status == StatusCode::NOT_FOUND && e.name == "AccountNotFound" => {
924924- tracing::debug!(
925925- member = %unverified.iss,
926926- "member not local; attempting cross-PDS DID-document resolution"
927927- );
15981598+ Some(()) => {}
15991599+ None => {
16001600+ // managing-app: ask the managingApp via checkUserAccess.
16011601+ let managing_app = inputs.config.managing_app.as_deref().ok_or_else(|| {
16021602+ XrpcError::new(
16031603+ StatusCode::FORBIDDEN,
16041604+ "NotAuthorized",
16051605+ "mintPolicy is managing-app but no managingApp is configured",
16061606+ )
16071607+ })?;
9281608 let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname());
929929- let http = reqwest::Client::builder()
930930- .timeout(std::time::Duration::from_secs(10))
931931- .user_agent(crate::user_agent())
932932- .build()
933933- .unwrap_or_default();
934934- crate::http::space_auth::verify_remote_member_grant(
935935- &http,
936936- &state.jti_guard,
937937- &input.grant,
938938- &space.owner_did,
16091609+ let endpoint = crate::space::recipient::resolve_service_endpoint(
16101610+ &mint_http,
16111611+ managing_app,
16121612+ plc_dir,
16131613+ )
16141614+ .await
16151615+ .map_err(XrpcError::from)?
16161616+ .ok_or_else(|| {
16171617+ XrpcError::new(
16181618+ StatusCode::FORBIDDEN,
16191619+ "NotAuthorized",
16201620+ format!("could not resolve managingApp service endpoint: {managing_app}"),
16211621+ )
16221622+ })?;
16231623+ crate::space::mint_authz::check_user_access(
16241624+ &mint_http,
16251625+ &endpoint,
16261626+ managing_app,
16271627+ &owner_signing,
16281628+ &space.space_did,
9391629 &space,
940940- &unverified.client_id,
941941- plc_dir,
16301630+ &payload.iss,
16311631+ attested_client_id.as_deref(),
9421632 )
943943- .await?
16331633+ .await
16341634+ .map_err(mint_denial_to_xrpc)?;
9441635 }
945945- Err(e) => return Err(e),
946946- };
16361636+ }
16371637+16381638+ // APP axis (appAccess).
16391639+ crate::space::mint_authz::app_axis(&inputs.config.app_access, attested_client_id.as_deref())
16401640+ .map_err(mint_denial_to_xrpc)?;
9471641948948- let owner_signing = local_signing_key(manager, &space.owner_did).await?;
16421642+ // The credential's `client_id` is the attested application identity, or
16431643+ // omitted entirely when the request carried no attestation.
9491644 let credential_ttl = state.space_credential_ttl_secs;
9501645 let token = create_space_credential(
951951- &space.owner_did,
16461646+ &space.space_did,
9521647 &space,
953953- &payload.client_id,
16481648+ attested_client_id.as_deref(),
9541649 &owner_signing,
9551650 credential_ttl,
9561651 )
···9671662 // `(space, service_did)` — re-issuing to the same client just bumps
9681663 // `last_issued_at`.
9691664 //
970970- // §3.2: discover the consumer's actual `(service_did, service_endpoint)`
971971- // by resolving `<host_of_client_id>/.well-known/atproto-did` and the
972972- // resulting DID document's `AtprotoPersonalDataServer` service. Falls
973973- // back to a documented stub `(grant.iss, client_id-origin)` when any
974974- // step fails — that's the same behavior the §3.2 audit identified, but
975975- // now flagged as `fully_resolved=false` so operators can audit.
16651665+ // Recipient discovery is keyed off the *attested* client_id (the
16661666+ // consumer's client-metadata URL): we resolve
16671667+ // `<host_of_client_id>/.well-known/atproto-did` and the resulting DID
16681668+ // document's `AtprotoPersonalDataServer` service, falling back to a
16691669+ // documented stub when any step fails. When the request carried no
16701670+ // attestation there is no consumer URL to resolve, so we register the
16711671+ // member's own DID as the recipient via the stub.
9761672 let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname());
9771673 let recipient_http = reqwest::Client::builder()
9781674 .timeout(std::time::Duration::from_secs(10))
9791675 .user_agent(crate::user_agent())
9801676 .build()
9811677 .unwrap_or_default();
982982- let resolved = match crate::space::recipient::resolve_recipient(
983983- &recipient_http,
984984- &payload.iss,
985985- &payload.client_id,
986986- plc_dir,
987987- )
988988- .await
989989- {
990990- Ok(r) => r,
991991- Err(e) => {
992992- tracing::warn!(
993993- error = ?e,
994994- client_id = %payload.client_id,
995995- "recipient resolution failed; falling back to stub"
996996- );
997997- crate::space::recipient::stub_recipient(&payload.iss, &payload.client_id)
998998- }
16781678+ let resolved = match attested_client_id.as_deref() {
16791679+ Some(client_id) => match crate::space::recipient::resolve_recipient(
16801680+ &recipient_http,
16811681+ &payload.iss,
16821682+ client_id,
16831683+ plc_dir,
16841684+ )
16851685+ .await
16861686+ {
16871687+ Ok(r) => r,
16881688+ Err(e) => {
16891689+ tracing::warn!(
16901690+ error = ?e,
16911691+ client_id = %client_id,
16921692+ "recipient resolution failed; falling back to stub"
16931693+ );
16941694+ crate::space::recipient::stub_recipient(&payload.iss, client_id)
16951695+ }
16961696+ },
16971697+ None => crate::space::recipient::stub_recipient(&payload.iss, &payload.iss),
9991698 };
10001699 if !resolved.fully_resolved {
10011700 tracing::warn!(
10021002- client_id = %payload.client_id,
17011701+ member = %payload.iss,
10031702 stub_did = %resolved.service_did,
10041703 stub_endpoint = %resolved.service_endpoint,
10051704 "recipient resolved via stub; consumer DID document was unreachable or missing a PDS service entry"
10061705 );
10071706 }
1008170710091009- match SqlActorStore::open(manager.data_dir(), &space.owner_did).await {
17081708+ match SqlActorStore::open(manager.data_dir(), &space.space_did).await {
10101709 Ok(owner_store) => {
10111710 if let Err(e) = upsert_recipient(
10121711 owner_store.pool(),
···10191718 tracing::warn!(
10201719 error = ?e,
10211720 space = %space,
10221022- client_id = %payload.client_id,
10231023- "register space_credential_recipient failed; this consumer will not receive notifyWrite/notifyMembership"
17211721+ member = %payload.iss,
17221722+ "register space_credential_recipient failed; this consumer will not receive notifyWrite"
10241723 );
10251724 }
10261725 }
···10331732 }
10341733 }
1035173410361036- Ok(Json(TokenWrapper {
10371037- token,
10381038- expires_at: now_secs() + credential_ttl,
10391039- }))
17351735+ Ok(Json(SpaceCredentialResponse { credential: token }))
10401736}
1041173710421738// ---------------------------------------------------------------------------
···10531749 })
10541750}
1055175110561056-/// Sync endpoints accept either a session/OAuth access token or a
10571057-/// SpaceCredential. / G35 the PDS does not enforce
10581058-/// membership at sync time — we just require *some* valid token shape.
10591059-/// OAuth tokens with a DPoP `cnf.jkt` binding still trigger the proof
10601060-/// check via the unified helper.
10611061-async fn require_any_authn(parts: &Parts, state: &HttpState) -> Result<(), XrpcError> {
17521752+/// Host/sync read endpoints accept either a session/OAuth access token or a
17531753+/// `SpaceCredential` bound to `space`. The PDS does not enforce membership at
17541754+/// sync time, but a presented credential MUST verify: when the bearer's `typ`
17551755+/// classifies as a `SpaceCredential`, its signature is checked against the
17561756+/// space authority's `#atproto_space` key and its `iss`/`sub`/`exp` are bound
17571757+/// to `space`. A forged, unsigned, expired, or wrong-space credential is
17581758+/// rejected with 401 rather than admitted on its `typ` string alone.
17591759+/// OAuth tokens with a DPoP `cnf.jkt` binding still trigger the proof check via
17601760+/// the unified helper.
17611761+///
17621762+/// Returns the bearer [`AuthSubject`](crate::http::auth::AuthSubject) for a
17631763+/// session/OAuth access token, or `None` for a verified `SpaceCredential`
17641764+/// (which pre-authorizes whole-space read at the auth layer). Callers gate the
17651765+/// `space:` scope only on the returned subject.
17661766+async fn require_any_authn(
17671767+ parts: &Parts,
17681768+ state: &HttpState,
17691769+ space: &SpaceUri,
17701770+) -> Result<Option<crate::http::auth::AuthSubject>, XrpcError> {
10621771 let raw = bearer_token(parts)?;
10631772 if let Some(SpaceTokenKind::SpaceCredential) = classify(raw) {
10641064- // We don't have the space URI here for full verification, but the
10651065- // structural typ check is enough — the reader handlers re-verify
10661066- // signatures against owner keys on every record read. Sync endpoints
10671067- // are intentionally permissive; consumers do inductive verification.
10681068- return Ok(());
17731773+ space_reader(state)?
17741774+ .verify_space_credential_for(space, raw)
17751775+ .await
17761776+ .map_err(|e| {
17771777+ XrpcError::new(
17781778+ StatusCode::UNAUTHORIZED,
17791779+ "Unauthorized",
17801780+ format!("invalid space credential: {e}"),
17811781+ )
17821782+ })?;
17831783+ return Ok(None);
10691784 }
10701785 let (htm, htu) = request_htm_htu(parts);
10711071- require_authn_sub(parts, state, &htm, &htu)
17861786+ require_authn(parts, state, &htm, &htu).await.map(Some)
17871787+}
17881788+17891789+/// Host/sync read auth restricted to a verified `SpaceCredential` (spec XRPC
17901790+/// table: `getSpace` and `listRepos` are "space credential" only). Rejects an
17911791+/// OAuth/session bearer with 401 — only a credential minted by the space
17921792+/// authority is acceptable — and verifies the credential against `space`.
17931793+async fn require_space_credential(
17941794+ parts: &Parts,
17951795+ state: &HttpState,
17961796+ space: &SpaceUri,
17971797+) -> Result<(), XrpcError> {
17981798+ let raw = bearer_token(parts)?;
17991799+ if classify(raw) != Some(SpaceTokenKind::SpaceCredential) {
18001800+ return Err(XrpcError::new(
18011801+ StatusCode::UNAUTHORIZED,
18021802+ "Unauthorized",
18031803+ "this method requires a space credential",
18041804+ ));
18051805+ }
18061806+ space_reader(state)?
18071807+ .verify_space_credential_for(space, raw)
10721808 .await
10731073- .map(|_| ())
18091809+ .map_err(|e| {
18101810+ XrpcError::new(
18111811+ StatusCode::UNAUTHORIZED,
18121812+ "Unauthorized",
18131813+ format!("invalid space credential: {e}"),
18141814+ )
18151815+ })
18161816+}
18171817+18181818+// ---------------------------------------------------------------------------
18191819+// OAuth `space:` scope enforcement.
18201820+//
18211821+// Enforces the 0016 `space:` OAuth scope rules (spec lines 369-419): only
18221822+// OAuth credentials carry granular `space:` permissions. App-password
18231823+// sessions (`access`) and SpaceCredential auth pre-authorize at the auth
18241824+// layer and skip the scope check entirely. A missing scope maps to 403.
18251825+// ---------------------------------------------------------------------------
18261826+18271827+/// Assert that the OAuth scope set granted to `subject` permits `action` on
18281828+/// the space `uri`. Collection-scoped (`create`/`update`/`delete`) targets
18291829+/// must pass `collection`; `read`/`manage` leave it `None`.
18301830+///
18311831+/// No-op for non-OAuth subjects (app-password sessions): they carry no
18321832+/// `space:` grants and are authorized at the session layer, matching the
18331833+/// reference `auth.credentials.type !== 'oauth'` early-return. A scope
18341834+/// shortfall on an OAuth subject becomes a 403 `InvalidToken` carrying the
18351835+/// minimal scope that would have satisfied the request.
18361836+/// Gate the `read` action for a sync/read endpoint whose auth was resolved
18371837+/// via [`require_any_authn`]: `Some(subject)` runs the OAuth scope check,
18381838+/// `None` (SpaceCredential auth) skips it.
18391839+async fn assert_space_read_opt(
18401840+ state: &HttpState,
18411841+ subject: &Option<crate::http::auth::AuthSubject>,
18421842+ uri: &SpaceUri,
18431843+) -> Result<(), XrpcError> {
18441844+ match subject {
18451845+ Some(s) => {
18461846+ assert_space_scope(
18471847+ state,
18481848+ s,
18491849+ uri,
18501850+ atproto_oauth::scopes::SpaceAction::Read,
18511851+ None,
18521852+ )
18531853+ .await
18541854+ }
18551855+ None => Ok(()),
18561856+ }
18571857+}
18581858+18591859+async fn assert_space_scope(
18601860+ state: &HttpState,
18611861+ subject: &crate::http::auth::AuthSubject,
18621862+ uri: &SpaceUri,
18631863+ action: atproto_oauth::scopes::SpaceAction,
18641864+ collection: Option<&str>,
18651865+) -> Result<(), XrpcError> {
18661866+ if !subject.is_oauth() {
18671867+ return Ok(());
18681868+ }
18691869+ let target = match collection {
18701870+ Some(c) => atproto_oauth::scopes::SpaceTarget::with_collection(
18711871+ uri.space_type.as_str(),
18721872+ &uri.space_did,
18731873+ uri.space_key.as_str(),
18741874+ action,
18751875+ c,
18761876+ ),
18771877+ None => atproto_oauth::scopes::SpaceTarget::new(
18781878+ uri.space_type.as_str(),
18791879+ &uri.space_did,
18801880+ uri.space_key.as_str(),
18811881+ action,
18821882+ ),
18831883+ };
18841884+ // Resolve the space type declaration's collections so a bare grant's
18851885+ // omitted-`collection` default matches the declared collections (spec line
18861886+ // 413). Whole-space `read` ignores collection, so the lookup is skipped.
18871887+ let declared = match action {
18881888+ atproto_oauth::scopes::SpaceAction::Read => Vec::new(),
18891889+ _ => declared_collections(state, uri.space_type.as_str()).await,
18901890+ };
18911891+ subject
18921892+ .scopes()
18931893+ .assert_space_with(&target, &declared)
18941894+ .map_err(|e| {
18951895+ tracing::debug!(
18961896+ space = %uri,
18971897+ action = action.as_str(),
18981898+ needed = %e.scope,
18991899+ "space scope assertion failed"
19001900+ );
19011901+ XrpcError::new(
19021902+ StatusCode::FORBIDDEN,
19031903+ "InvalidToken",
19041904+ format!(
19051905+ "insufficient OAuth scope for this space operation; need `{}`",
19061906+ e.scope
19071907+ ),
19081908+ )
19091909+ })
19101910+}
19111911+19121912+/// Assert that the OAuth scope set granted to `subject` permits the
19131913+/// space-management `verb` on the space `uri` (spec lines 415-419). The verb
19141914+/// maps onto the management surface at the call site (e.g. `update` authorizes
19151915+/// `updateSpace`/`addMember`/`removeMember`). No-op for non-OAuth subjects.
19161916+fn assert_space_manage(
19171917+ subject: &crate::http::auth::AuthSubject,
19181918+ uri: &SpaceUri,
19191919+ verb: atproto_oauth::scopes::SpaceManageVerb,
19201920+) -> Result<(), XrpcError> {
19211921+ if !subject.is_oauth() {
19221922+ return Ok(());
19231923+ }
19241924+ let target = atproto_oauth::scopes::SpaceManageTarget::new(
19251925+ uri.space_type.as_str(),
19261926+ &uri.space_did,
19271927+ uri.space_key.as_str(),
19281928+ verb,
19291929+ );
19301930+ subject.scopes().assert_space_manage(&target).map_err(|e| {
19311931+ tracing::debug!(
19321932+ space = %uri,
19331933+ verb = verb.as_str(),
19341934+ needed = %e.scope,
19351935+ "space manage scope assertion failed"
19361936+ );
19371937+ XrpcError::new(
19381938+ StatusCode::FORBIDDEN,
19391939+ "InvalidToken",
19401940+ format!(
19411941+ "insufficient OAuth scope for this space-management operation; need `{}`",
19421942+ e.scope
19431943+ ),
19441944+ )
19451945+ })
19461946+}
19471947+19481948+/// Assert that the OAuth scope set granted to `subject` permits reading the
19491949+/// record(s) at `uri` from `target_repo` (spec lines 392-413).
19501950+///
19511951+/// - Reading the holder's **own** repo (`target_repo == subject.sub()`) is
19521952+/// satisfied by either a whole-space `read` grant or a collection-covering
19531953+/// `read_self` grant. A `read_self` grant is collection-constrained, so a
19541954+/// cross-collection listing (`collection == None`) of the own repo falls
19551955+/// back to requiring whole-space `read`.
19561956+/// - Reading **another** member's repo requires whole-space `read`
19571957+/// (collection-independent).
19581958+///
19591959+/// No-op for non-OAuth subjects (app-password sessions).
19601960+async fn assert_space_record_read(
19611961+ state: &HttpState,
19621962+ subject: &crate::http::auth::AuthSubject,
19631963+ uri: &SpaceUri,
19641964+ target_repo: &str,
19651965+ collection: Option<&str>,
19661966+) -> Result<(), XrpcError> {
19671967+ let own_repo = subject.sub() == target_repo;
19681968+ match (own_repo, collection) {
19691969+ // Own repo, single collection: read_self (also satisfied by read).
19701970+ (true, Some(c)) => {
19711971+ assert_space_scope(
19721972+ state,
19731973+ subject,
19741974+ uri,
19751975+ atproto_oauth::scopes::SpaceAction::ReadSelf,
19761976+ Some(c),
19771977+ )
19781978+ .await
19791979+ }
19801980+ // Own repo across all collections, or any other member's repo: the
19811981+ // collection-independent whole-space `read` grant is required.
19821982+ _ => {
19831983+ assert_space_scope(
19841984+ state,
19851985+ subject,
19861986+ uri,
19871987+ atproto_oauth::scopes::SpaceAction::Read,
19881988+ None,
19891989+ )
19901990+ .await
19911991+ }
19921992+ }
19931993+}
19941994+19951995+/// Resolve the `collections` declared by the space type's declaration for the
19961996+/// `space_type` NSID, used to expand a bare `space:` grant's
19971997+/// omitted-`collection` default (spec line 413).
19981998+///
19991999+/// Resolution is delegated to the configured
20002000+/// [`SpaceDeclarationResolver`](crate::space::SpaceDeclarationResolver). It is
20012001+/// **fail-closed**: when no resolver is configured, the spaceType is the `*`
20022002+/// wildcard (no declaration to draw from), or resolution fails, this returns an
20032003+/// empty list — a bare grant then confers no write targets. Explicit
20042004+/// `collection=` grants are unaffected (they never consult the default).
20052005+async fn declared_collections(state: &HttpState, space_type: &str) -> Vec<String> {
20062006+ // `spaceType=*` has no declaration (spec line 413); skip resolution.
20072007+ if space_type == "*" {
20082008+ return Vec::new();
20092009+ }
20102010+ let Some(resolver) = state.space_declaration_resolver.as_ref() else {
20112011+ return Vec::new();
20122012+ };
20132013+ match resolver.resolve(space_type).await {
20142014+ Some(decl) => decl.collections,
20152015+ None => {
20162016+ tracing::warn!(
20172017+ space_type,
20182018+ "space-type declaration resolution failed; bare `space:` grant defaults to no write collections (fail-closed)"
20192019+ );
20202020+ Vec::new()
20212021+ }
20222022+ }
10742023}
1075202410762025fn now_secs() -> u64 {
···10782027 .duration_since(std::time::UNIX_EPOCH)
10792028 .map(|d| d.as_secs())
10802029 .unwrap_or(0)
20302030+}
20312031+20322032+/// Map a mint-authorization denial to its documented `getSpaceCredential`
20332033+/// XRPC error. User/app/not-authorized refusals are `403`; an invalid client
20342034+/// attestation is `400`.
20352035+fn mint_denial_to_xrpc(denial: crate::space::mint_authz::MintDenial) -> XrpcError {
20362036+ use crate::space::mint_authz::MintDenial;
20372037+ let status = match denial {
20382038+ MintDenial::InvalidClientAttestation { .. } => StatusCode::BAD_REQUEST,
20392039+ _ => StatusCode::FORBIDDEN,
20402040+ };
20412041+ tracing::debug!(
20422042+ error_name = denial.error_name(),
20432043+ reason = denial.reason(),
20442044+ "getSpaceCredential mint authorization denied"
20452045+ );
20462046+ XrpcError::new(status, denial.error_name(), denial.reason().to_string())
10812047}
1082204810832049// ---------------------------------------------------------------------------
10841084-// Inbound notify endpoints (§3.1).
20502050+// Inbound notify endpoints.
10852051// ---------------------------------------------------------------------------
1086205210871087-/// `POST /xrpc/com.atproto.space.notifyWrite` — receive a notify-write
10881088-/// payload from a remote owner PDS.
20532053+/// `POST /xrpc/com.atproto.space.notifyWrite` — receive a contentless
20542054+/// notify-write `{ space, repo, rev }` announcing that `repo` advanced to
20552055+/// `rev` within `space`.
10892056///
10901090-/// Authentication is structural: the embedded signed `Commit` must
10911091-/// verify against the owner's atproto signing key (resolved from the
10921092-/// owner's DID document). No bearer token is required — the signature
10931093-/// IS the auth, and the `(space, rev)` dedup keeps replays harmless.
20572057+/// Authentication is **service auth**: a bearer JWT signed by the writer's
20582058+/// `#atproto` key, with `iss == repo` and `aud == <space owner DID>`,
20592059+/// scoped to `lxm == com.atproto.space.notifyWrite`.
10942060///
10951095-/// The `?recipient=<did>` query parameter selects which local account's
10961096-/// per-actor store records the receipt. Typically this is the local
10971097-/// account holding the `SpaceCredential` for the named space.
20612061+/// Behavior implements the spec's two-hop fan-out (lines 343-351): members
20622062+/// notify the authority, which forwards to the endpoints registered for the
20632063+/// space. When this PDS hosts the space owner and the writer is a member, the
20642064+/// notification is forwarded to every registered recipient (registerNotify
20652065+/// subscribers + credential consumers). A lightweight receipt is recorded for
20662066+/// dedup + audit. For a non-owner host (e.g. a syncing service that also holds
20672067+/// a replica) the handler simply records the receipt — there is no fan-out
20682068+/// state.
10982069pub async fn notify_write(
10992070 State(state): State<HttpState>,
11001100- Query(q): Query<NotifyRecipientQuery>,
20712071+ parts: Parts,
11012072 body: axum::body::Bytes,
11022073) -> Result<StatusCode, XrpcError> {
11032074 let manager = account_manager(&state)?;
···11072078 .user_agent(crate::user_agent())
11082079 .build()
11092080 .unwrap_or_default();
11101110- crate::space::inbound::receive_write(
20812081+20822082+ // Decode first so we know the space/owner the service-auth `aud` must bind.
20832083+ let payload: crate::space::notify::NotifyWritePayload = serde_json::from_slice(body.as_ref())
20842084+ .map_err(|e| {
20852085+ XrpcError::new(
20862086+ StatusCode::BAD_REQUEST,
20872087+ "InvalidRequest",
20882088+ format!("decode notifyWrite payload: {e}"),
20892089+ )
20902090+ })?;
20912091+ let space = parse_space_uri(&payload.space)?;
20922092+20932093+ // Service-auth: signature over the writer's key, aud = owner DID,
20942094+ // lxm scoped to notifyWrite.
20952095+ let token = bearer_token(&parts)?;
20962096+ let claims = crate::space::service_auth::verify_service_auth(
11112097 &http,
20982098+ token,
11122099 plc_dir,
11131113- manager.data_dir(),
11141114- &q.recipient,
11151115- body.as_ref(),
21002100+ &space.space_did,
21012101+ crate::space::notify::NOTIFY_WRITE_NSID,
11162102 )
11172103 .await
11182104 .map_err(XrpcError::from)?;
11191119- Ok(StatusCode::OK)
11201120-}
21052105+ // The JWT issuer must match the claimed writer so a PDS can't deliver a
21062106+ // notification on someone else's behalf (reference `notifyWrite.ts`).
21072107+ if claims.iss != payload.repo {
21082108+ return Err(XrpcError::new(
21092109+ StatusCode::FORBIDDEN,
21102110+ "Forbidden",
21112111+ "notifyWrite iss does not match claimed writer",
21122112+ ));
21132113+ }
1121211411221122-/// `POST /xrpc/com.atproto.space.notifyMembership` — receive a
11231123-/// notify-membership payload from a remote owner PDS.
11241124-pub async fn notify_membership(
11251125- State(state): State<HttpState>,
11261126- Query(q): Query<NotifyRecipientQuery>,
11271127- body: axum::body::Bytes,
11281128-) -> Result<StatusCode, XrpcError> {
11291129- let manager = account_manager(&state)?;
11301130- let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname());
11311131- let http = reqwest::Client::builder()
11321132- .timeout(std::time::Duration::from_secs(10))
11331133- .user_agent(crate::user_agent())
11341134- .build()
11351135- .unwrap_or_default();
11361136- crate::space::inbound::receive_membership(
21152115+ // Owner-side fan-out (HOP 2): only the owner's PDS holds the member list +
21162116+ // recipient subscriptions. For a non-owner host this is a best-effort
21172117+ // no-op (the receipt below is still recorded).
21182118+ let owner_is_local = manager
21192119+ .lookup_handle(&space.space_did)
21202120+ .await
21212121+ .map_err(XrpcError::from)?
21222122+ .is_some();
21232123+ if owner_is_local {
21242124+ let is_member = space_service(&state)?
21252125+ .is_member(&space, &payload.repo)
21262126+ .await
21272127+ .map_err(XrpcError::from)?;
21282128+ if !is_member {
21292129+ return Err(XrpcError::new(
21302130+ StatusCode::FORBIDDEN,
21312131+ "Forbidden",
21322132+ "notifyWrite writer is not a member of the space",
21332133+ ));
21342134+ }
21352135+ // Owner signing key to mint per-recipient service-auth tokens for the
21362136+ // outbound fan-out. If it's unavailable we log and skip fan-out (the
21372137+ // receipt below is still recorded).
21382138+ match local_signing_key(manager, &space.space_did).await {
21392139+ Ok(owner_key) => {
21402140+ if let Err(e) = crate::space::notify::enqueue_writes(
21412141+ manager.pool(),
21422142+ manager.data_dir(),
21432143+ &space,
21442144+ &payload,
21452145+ &owner_key,
21462146+ )
21472147+ .await
21482148+ {
21492149+ tracing::warn!(
21502150+ error = ?e,
21512151+ space = %space,
21522152+ repo = %payload.repo,
21532153+ "notifyWrite fan-out enqueue failed; recipients may miss this revision"
21542154+ );
21552155+ }
21562156+ }
21572157+ Err(e) => {
21582158+ tracing::warn!(
21592159+ error = ?e,
21602160+ space = %space,
21612161+ "notifyWrite fan-out skipped: owner signing key unavailable"
21622162+ );
21632163+ }
21642164+ }
21652165+ }
21662166+21672167+ // Record a lightweight receipt (dedup + audit) pinned to the owner DID.
21682168+ crate::space::inbound::receive_write(
11372169 &http,
11382170 plc_dir,
11392171 manager.data_dir(),
11401140- &q.recipient,
21722172+ &space.space_did,
11412173 body.as_ref(),
11422174 )
11432175 .await
···11452177 Ok(StatusCode::OK)
11462178}
1147217911481148-/// Query params for the inbound notify-* endpoints.
11491149-#[derive(Debug, Deserialize)]
11501150-pub struct NotifyRecipientQuery {
11511151- /// DID of the local account this PDS hosts that should record the
11521152- /// receipt. The notifying peer learned this DID from the
11531153- /// `getSpaceCredential` flow.
11541154- pub recipient: String,
11551155-}
11561156-11572180// ---------------------------------------------------------------------------
11581158-// Export / import endpoints (§3.4).
21812181+// getBlob — permissioned blob fetch (com.atproto.space.getBlob).
11592182// ---------------------------------------------------------------------------
1160218311611161-/// Query params for `exportSpaces`.
21842184+/// Query params for `com.atproto.space.getBlob`.
11622185#[derive(Debug, Deserialize)]
11631163-pub struct ExportSpacesQuery {
11641164- /// Space URI (`ats://owner/type/key`).
21862186+pub struct GetSpaceBlobQuery {
21872187+ /// Space URI.
11652188 pub space: String,
11661166- /// DID whose record-set should be exported. When omitted the export
11671167- /// emits a member-list-only manifest (owner-side migration shape).
11681168- pub member: Option<String>,
21892189+ /// DID of the account whose repo holds the blob.
21902190+ pub repo: String,
21912191+ /// CID of the blob to fetch.
21922192+ pub cid: String,
11692193}
1170219411711171-/// `GET /xrpc/com.atproto.space.exportSpaces` — stream a CARv1 of the
11721172-/// requested `(member, space)` records + member list.
21952195+/// `GET /xrpc/com.atproto.space.getBlob`.
11732196///
11741174-/// Auth model: the caller must be the space owner or the named member.
11751175-/// This bounds the export to actors who are authorized to read that
11761176-/// data via the existing `getRecord` / `getMembers` paths.
11771177-pub async fn export_spaces(
21972197+/// Serves the full blob as originally uploaded from `repo`'s regular
21982198+/// blobstore, gated by the same auth as `getRecord` / `listRecords`
21992199+/// (space-credential-space-match OR OAuth/session). Distinct from the public
22002200+/// `com.atproto.sync.getBlob`, which has no permissioned gate. Response carries
22012201+/// the standard atproto blob security headers (`x-content-type-options:
22022202+/// nosniff`, `content-disposition: attachment`, restrictive
22032203+/// `content-security-policy`).
22042204+pub async fn get_blob(
11782205 State(state): State<HttpState>,
11792206 parts: Parts,
11801180- Query(q): Query<ExportSpacesQuery>,
22072207+ Query(q): Query<GetSpaceBlobQuery>,
11812208) -> Result<axum::response::Response, XrpcError> {
22092209+ use axum::body::Body;
22102210+ use axum::http::HeaderValue;
11822211 use axum::http::header;
11831183- use axum::response::IntoResponse;
1184221211851185- let caller = require_session_subject(&parts, &state).await?;
11861186- let uri = parse_space_uri(&q.space)?;
22132213+ let space = parse_space_uri(&q.space)?;
22142214+ // `repo` is required by the lexicon; ignore the auth-resolver default by
22152215+ // always passing the explicit repo param.
22162216+ let resolved = resolve_record_auth(&parts, &state, Some(q.repo.as_str())).await?;
22172217+ if let Some(subject) = &resolved.subject {
22182218+ assert_space_scope(
22192219+ &state,
22202220+ subject,
22212221+ &space,
22222222+ atproto_oauth::scopes::SpaceAction::Read,
22232223+ None,
22242224+ )
22252225+ .await?;
22262226+ }
22272227+ space_reader(&state)?
22282228+ .verify_read_auth(&space, &resolved.auth)
22292229+ .await
22302230+ .map_err(XrpcError::from)?;
22312231+22322232+ let manager = account_manager(&state)?;
22332233+ let pair = if let Some(backend) = state.public_realm_backend.as_ref() {
22342234+ backend
22352235+ .blob
22362236+ .get(&q.repo, &q.cid)
22372237+ .await
22382238+ .map_err(XrpcError::from)?
22392239+ } else {
22402240+ let store = SqlActorStore::open(manager.data_dir(), &q.repo)
22412241+ .await
22422242+ .map_err(XrpcError::from)?;
22432243+ crate::blob::get_blob(&store, &q.cid)
22442244+ .await
22452245+ .map_err(XrpcError::from)?
22462246+ };
22472247+ let (data, mime) = pair.ok_or_else(|| {
22482248+ XrpcError::new(
22492249+ StatusCode::NOT_FOUND,
22502250+ "BlobNotFound",
22512251+ format!("no blob {} for {}", q.cid, q.repo),
22522252+ )
22532253+ })?;
22542254+22552255+ let mut resp = axum::response::Response::new(Body::from(data));
22562256+ let headers = resp.headers_mut();
22572257+ headers.insert(
22582258+ header::CONTENT_TYPE,
22592259+ HeaderValue::from_str(&mime)
22602260+ .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
22612261+ );
22622262+ headers.insert(
22632263+ "x-content-type-options",
22642264+ HeaderValue::from_static("nosniff"),
22652265+ );
22662266+ headers.insert(
22672267+ header::CONTENT_DISPOSITION,
22682268+ HeaderValue::from_str(&format!("attachment; filename=\"{}\"", q.cid))
22692269+ .unwrap_or_else(|_| HeaderValue::from_static("attachment")),
22702270+ );
22712271+ headers.insert(
22722272+ header::CONTENT_SECURITY_POLICY,
22732273+ HeaderValue::from_static("default-src 'none'; sandbox"),
22742274+ );
22752275+ Ok(resp)
22762276+}
22772277+22782278+// ---------------------------------------------------------------------------
22792279+// listRepos — the writer set (com.atproto.space.listRepos).
22802280+// ---------------------------------------------------------------------------
22812281+22822282+/// Query params for `com.atproto.space.listRepos`.
22832283+#[derive(Debug, Deserialize)]
22842284+pub struct ListReposQuery {
22852285+ /// Space URI.
22862286+ pub space: String,
22872287+ /// Maximum number of repos to return (1..1000, default 100).
22882288+ pub limit: Option<u32>,
22892289+ /// Cursor (last `did` from the prior page).
22902290+ pub cursor: Option<String>,
22912291+}
22922292+22932293+/// One repo in `listRepos` — `{ did, rev }` per `com.atproto.space.listRepos#repo`.
22942294+///
22952295+/// Per the 0016 Permissioned Data draft (line 357), the writer set conveys each
22962296+/// repo together with its current `rev` so a syncer can resume per repo without
22972297+/// a separate probe.
22982298+#[derive(Debug, Serialize)]
22992299+pub struct RepoRef {
23002300+ /// DID of a repo that holds data in the space.
23012301+ pub did: String,
23022302+ /// The repo's current `rev` (the latest observed in this space's
23032303+ /// write-receipt log for that issuer).
23042304+ pub rev: String,
23052305+}
23062306+23072307+/// Output of `listRepos`.
23082308+#[derive(Debug, Serialize)]
23092309+pub struct ListReposResponse {
23102310+ /// Cursor for the next page, when more may remain.
23112311+ #[serde(skip_serializing_if = "Option::is_none")]
23122312+ pub cursor: Option<String>,
23132313+ /// Page of writer repos.
23142314+ pub repos: Vec<RepoRef>,
23152315+}
23162316+23172317+/// `GET /xrpc/com.atproto.space.listRepos`.
23182318+///
23192319+/// The writer set: distinct issuer DIDs observed in the owner's inbound
23202320+/// write-receipt log (`space_received_op`), each paired with its current `rev`
23212321+/// (`MAX(rev)` over that issuer's receipts), ordered by DID, paginated by
23222322+/// `did > cursor`. Output is `{ did, rev }` per writer (spec line 357).
23232323+/// `SpaceNotFound` when the space row is absent.
23242324+///
23252325+/// Auth is **space credential only** (spec XRPC table: `listRepos` is "space
23262326+/// credential"). An OAuth/session bearer is rejected with 401; the presented
23272327+/// credential is verified against the space authority's `#atproto_space` key.
23282328+pub async fn list_repos(
23292329+ State(state): State<HttpState>,
23302330+ parts: Parts,
23312331+ Query(q): Query<ListReposQuery>,
23322332+) -> Result<Json<ListReposResponse>, XrpcError> {
23332333+ let space = parse_space_uri(&q.space)?;
23342334+ // listRepos is space-credential-only (spec XRPC table line 483 + 394): an
23352335+ // OAuth/session token is rejected; only a verified credential is accepted.
23362336+ require_space_credential(&parts, &state, &space).await?;
11872337 let manager = account_manager(&state)?;
11882338 let _ = space_service(&state)?; // gate on Spaces being enabled
23392339+ let limit = q.limit.unwrap_or(100).clamp(1, 1000);
1189234011901190- // Authorize: caller must be owner OR the named member.
11911191- let caller_is_owner = caller == uri.owner_did;
11921192- let caller_is_named_member = q.member.as_deref() == Some(caller.as_str());
11931193- if !caller_is_owner && !caller_is_named_member {
23412341+ let store = SqlActorStore::open(manager.data_dir(), &space.space_did)
23422342+ .await
23432343+ .map_err(XrpcError::from)?;
23442344+23452345+ // SpaceNotFound when the owner's space row is absent.
23462346+ let space_exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM space WHERE uri = ? LIMIT 1")
23472347+ .bind(space.to_string())
23482348+ .fetch_optional(store.pool())
23492349+ .await
23502350+ .map_err(|e| {
23512351+ XrpcError::new(
23522352+ StatusCode::INTERNAL_SERVER_ERROR,
23532353+ "InternalError",
23542354+ format!("listRepos space lookup: {e}"),
23552355+ )
23562356+ })?;
23572357+ if space_exists.is_none() {
11942358 return Err(XrpcError::new(
11951195- StatusCode::FORBIDDEN,
11961196- "Forbidden",
11971197- "exportSpaces requires owner or named-member auth",
23592359+ StatusCode::NOT_FOUND,
23602360+ "SpaceNotFound",
23612361+ format!("space not found: {space}"),
11982362 ));
11992363 }
1200236412011201- // Buffer the CAR in memory. CARv1 export size for a space scales
12021202- // with record count — the simple buffered shape ships today; a
12031203- // future streaming bridge would use `axum::body::Body::from_stream`.
12041204- let mut car_bytes: Vec<u8> = Vec::new();
12051205- crate::space::export::export_to_writer(
12061206- manager.data_dir(),
12071207- &uri,
12081208- q.member.as_deref(),
12091209- &mut car_bytes,
23652365+ let cursor = q.cursor.clone().unwrap_or_default();
23662366+ let rows: Vec<(String, String)> = sqlx::query_as(
23672367+ "SELECT issuer_did, MAX(rev) FROM space_received_op
23682368+ WHERE space = ? AND issuer_did > ?
23692369+ GROUP BY issuer_did
23702370+ ORDER BY issuer_did ASC
23712371+ LIMIT ?",
12102372 )
23732373+ .bind(space.to_string())
23742374+ .bind(&cursor)
23752375+ .bind(limit as i64)
23762376+ .fetch_all(store.pool())
12112377 .await
12121212- .map_err(XrpcError::from)?;
23782378+ .map_err(|e| {
23792379+ XrpcError::new(
23802380+ StatusCode::INTERNAL_SERVER_ERROR,
23812381+ "InternalError",
23822382+ format!("listRepos query: {e}"),
23832383+ )
23842384+ })?;
1213238512141214- let mut response = (StatusCode::OK, car_bytes).into_response();
12151215- response.headers_mut().insert(
12161216- header::CONTENT_TYPE,
12171217- "application/vnd.ipld.car".parse().unwrap(),
12181218- );
12191219- Ok(response)
23862386+ let repos: Vec<RepoRef> = rows
23872387+ .into_iter()
23882388+ .map(|(did, rev)| RepoRef { did, rev })
23892389+ .collect();
23902390+ let next_cursor = if repos.len() as u32 == limit {
23912391+ repos.last().map(|r| r.did.clone())
23922392+ } else {
23932393+ None
23942394+ };
23952395+ Ok(Json(ListReposResponse {
23962396+ cursor: next_cursor,
23972397+ repos,
23982398+ }))
12202399}
1221240012221222-/// Query params for `importSpaces`.
24012401+// ---------------------------------------------------------------------------
24022402+// registerNotify — subscribe an endpoint to write notifications.
24032403+// ---------------------------------------------------------------------------
24042404+24052405+/// Inputs for `com.atproto.space.registerNotify`.
12232406#[derive(Debug, Deserialize)]
12241224-pub struct ImportSpacesQuery {
12251225- /// Optional override for the manifest-declared space URI.
12261226- pub space: Option<String>,
12271227- /// Optional override for the manifest-declared member DID.
12281228- pub member: Option<String>,
24072407+pub struct RegisterNotifyInput {
24082408+ /// Space URI.
24092409+ pub space: String,
24102410+ /// DID of a specific repo to subscribe to (repo host). Omit for whole-space.
24112411+ #[serde(default)]
24122412+ pub repo: Option<String>,
24132413+ /// Endpoint to which `notifyWrite` events should be delivered.
24142414+ pub endpoint: String,
12292415}
1230241612311231-/// Output of `importSpaces`.
24172417+/// Output of `registerNotify`.
12322418#[derive(Debug, Serialize)]
12331233-pub struct ImportSpacesResponse {
12341234- /// Space URI restored.
12351235- pub space: String,
12361236- /// Imported member DID, when the export carried records for one.
12371237- #[serde(rename = "memberDid", skip_serializing_if = "Option::is_none")]
12381238- pub member_did: Option<String>,
12391239- /// Number of records inserted.
12401240- #[serde(rename = "recordsInserted")]
12411241- pub records_inserted: usize,
12421242- /// Number of members inserted.
12431243- #[serde(rename = "membersInserted")]
12441244- pub members_inserted: usize,
24192419+pub struct RegisterNotifyResponse {
24202420+ /// When the registration expires (RFC 3339).
24212421+ #[serde(rename = "expiresAt")]
24222422+ pub expires_at: String,
12452423}
1246242412471247-/// `POST /xrpc/com.atproto.space.importSpaces` — restore a previously
12481248-/// exported CARv1 onto this PDS.
24252425+/// Registration window for `registerNotify` (24h).
24262426+const REGISTER_NOTIFY_TTL_SECS: i64 = 24 * 60 * 60;
24272427+24282428+/// `POST /xrpc/com.atproto.space.registerNotify`.
12492429///
12501250-/// Auth model: the caller must be the destination space owner OR the
12511251-/// destination member DID. Override query params let callers retarget
12521252-/// the import (useful after handle/DID migration); the override DID
12531253-/// must match the auth subject.
12541254-pub async fn import_spaces(
24302430+/// Authenticated with a **space credential** (`typ = space_credential`): the
24312431+/// presented JWT is verified against the space owner's `#atproto` signing key
24322432+/// and must bind to `space`. Persists a subscription keyed
24332433+/// `(space, repo-or-null, service)` with a 24h expiry and returns `expiresAt`.
24342434+pub async fn register_notify(
12552435 State(state): State<HttpState>,
12562436 parts: Parts,
12571257- Query(q): Query<ImportSpacesQuery>,
12581258- body: axum::body::Bytes,
12591259-) -> Result<Json<ImportSpacesResponse>, XrpcError> {
12601260- let caller = require_session_subject(&parts, &state).await?;
24372437+ Json(input): Json<RegisterNotifyInput>,
24382438+) -> Result<Json<RegisterNotifyResponse>, XrpcError> {
24392439+ let space = parse_space_uri(&input.space)?;
12612440 let manager = account_manager(&state)?;
12622441 let _ = space_service(&state)?; // gate on Spaces being enabled
1263244212641264- // Resolve target overrides up-front so we can authorize.
12651265- let target_space = match q.space.as_deref() {
12661266- Some(s) => Some(parse_space_uri(s)?),
12671267- None => None,
12681268- };
12691269- let target_member = q.member.as_deref();
12701270-12711271- // Authorize: when targeting a specific member DID, caller must equal
12721272- // that member. When targeting only a space, caller must be its
12731273- // owner. When neither override is provided, defer the auth check
12741274- // until we've decoded the manifest below (we pull the manifest's
12751275- // claimed (space, member) and re-check).
12761276- if let Some(m) = target_member
12771277- && m != caller
12781278- {
24432443+ // Require a space-credential bearer.
24442444+ let token = bearer_token(&parts)?;
24452445+ if classify(token) != Some(SpaceTokenKind::SpaceCredential) {
12792446 return Err(XrpcError::new(
12801280- StatusCode::FORBIDDEN,
12811281- "Forbidden",
12821282- "importSpaces member override must match the authenticated subject",
24472447+ StatusCode::UNAUTHORIZED,
24482448+ "AuthenticationRequired",
24492449+ "registerNotify requires a space credential",
12832450 ));
12842451 }
12851285- if let Some(ref s) = target_space
12861286- && target_member.is_none()
12871287- && s.owner_did != caller
12881288- {
24522452+24532453+ // SpaceNotFound when the owner's space row is absent.
24542454+ let owner_store = SqlActorStore::open(manager.data_dir(), &space.space_did)
24552455+ .await
24562456+ .map_err(XrpcError::from)?;
24572457+ let space_exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM space WHERE uri = ? LIMIT 1")
24582458+ .bind(space.to_string())
24592459+ .fetch_optional(owner_store.pool())
24602460+ .await
24612461+ .map_err(|e| {
24622462+ XrpcError::new(
24632463+ StatusCode::INTERNAL_SERVER_ERROR,
24642464+ "InternalError",
24652465+ format!("registerNotify space lookup: {e}"),
24662466+ )
24672467+ })?;
24682468+ if space_exists.is_none() {
12892469 return Err(XrpcError::new(
12901290- StatusCode::FORBIDDEN,
12911291- "Forbidden",
12921292- "importSpaces requires owner auth when no member override is set",
24702470+ StatusCode::NOT_FOUND,
24712471+ "SpaceNotFound",
24722472+ format!("space not found: {space}"),
12932473 ));
12942474 }
1295247512961296- let outcome = crate::space::export::import_from_reader(
12971297- manager.data_dir(),
12981298- std::io::Cursor::new(body.to_vec()),
12991299- target_space.as_ref(),
13001300- target_member,
24762476+ // Verify the space credential: signature over the authority's
24772477+ // #atproto_space key, bound to this space, not expired. The authority is
24782478+ // local to this host PDS, and per 0016 line 92 #atproto_space coincides
24792479+ // with the account's #atproto signing key (resolved via local_public_key).
24802480+ let owner_pub = crate::http::space_auth::local_public_key(manager, &space.space_did).await?;
24812481+ let credential = atproto_space::credential::verify_space_credential(
24822482+ token,
24832483+ &space.space_did,
24842484+ &space,
24852485+ &owner_pub,
24862486+ )
24872487+ .map_err(|e| {
24882488+ XrpcError::new(
24892489+ StatusCode::FORBIDDEN,
24902490+ "InvalidToken",
24912491+ format!("SpaceCredential verification: {e}"),
24922492+ )
24932493+ })?;
24942494+24952495+ // The credential's advisory `client_id` (the attested application)
24962496+ // identifies the subscribing service. When the credential carried no
24972497+ // attestation we key the subscription on the credential issuer (the space
24982498+ // authority) instead, so registration still succeeds.
24992499+ let service_did = credential
25002500+ .client_id
25012501+ .clone()
25022502+ .unwrap_or_else(|| credential.iss.clone());
25032503+ let expires_at =
25042504+ (chrono::Utc::now() + chrono::Duration::seconds(REGISTER_NOTIFY_TTL_SECS)).to_rfc3339();
25052505+ crate::space::notify::upsert_subscription(
25062506+ owner_store.pool(),
25072507+ &space,
25082508+ input.repo.as_deref(),
25092509+ &service_did,
25102510+ &input.endpoint,
25112511+ Some(&expires_at),
13012512 )
13022513 .await
13032514 .map_err(XrpcError::from)?;
1304251513051305- // Post-decode authorization: if no overrides were supplied, the
13061306- // manifest's claimed (space, member) must square with the caller.
13071307- if target_space.is_none() && target_member.is_none() {
13081308- let restored_space = SpaceUri::parse(&outcome.space).map_err(PdsError::Space)?;
13091309- let manifest_member = outcome.member_did.as_deref();
13101310- let caller_is_owner = caller == restored_space.owner_did;
13111311- let caller_is_named_member = manifest_member == Some(caller.as_str());
13121312- if !caller_is_owner && !caller_is_named_member {
13131313- return Err(XrpcError::new(
13141314- StatusCode::FORBIDDEN,
13151315- "Forbidden",
13161316- "importSpaces caller is neither owner nor the manifest's member",
13171317- ));
25162516+ Ok(Json(RegisterNotifyResponse { expires_at }))
25172517+}
25182518+25192519+// ---------------------------------------------------------------------------
25202520+// notifySpaceDeleted — space-deletion lifecycle notification.
25212521+// ---------------------------------------------------------------------------
25222522+25232523+/// Inputs for `com.atproto.space.notifySpaceDeleted`.
25242524+#[derive(Debug, Deserialize)]
25252525+pub struct NotifySpaceDeletedInput {
25262526+ /// Space URI of the deleted space.
25272527+ pub space: String,
25282528+}
25292529+25302530+/// `POST /xrpc/com.atproto.space.notifySpaceDeleted`.
25312531+///
25322532+/// Service-auth: the JWT `iss` must equal the space's `spaceDid` (the
25332533+/// authority) and `aud` the recipient (a repo host or syncing service hosted
25342534+/// here). Marks the recipient-side space row as deleted (`deleted_at`).
25352535+/// Best-effort: a no-op when the recipient is not local or the space row is
25362536+/// unknown.
25372537+///
25382538+/// This PDS acts as a **repo host** here, so it implements the repo-host
25392539+/// behavior of the 0016 draft (line 365): flag the member's repo as belonging
25402540+/// to a deleted space rather than erase it (the data is the user's own). The
25412541+/// **syncer** behavior of line 367 — "delete every copy of the space's data it
25422542+/// holds, both the repos it pulled and any derived state" — is a syncer-role
25432543+/// responsibility and does not apply to the PDS-as-repo-host; this handler
25442544+/// therefore tombstones rather than purges.
25452545+pub async fn notify_space_deleted(
25462546+ State(state): State<HttpState>,
25472547+ parts: Parts,
25482548+ Json(input): Json<NotifySpaceDeletedInput>,
25492549+) -> Result<StatusCode, XrpcError> {
25502550+ let space = parse_space_uri(&input.space)?;
25512551+ let manager = account_manager(&state)?;
25522552+ let plc_dir = state.plc_service.as_ref().map(|p| p.directory_hostname());
25532553+ let http = reqwest::Client::builder()
25542554+ .timeout(std::time::Duration::from_secs(10))
25552555+ .user_agent(crate::user_agent())
25562556+ .build()
25572557+ .unwrap_or_default();
25582558+25592559+ // Service-auth verification. `iss` must be the space authority; `aud` is
25602560+ // the recipient hosted here. We don't know the recipient ahead of time, so
25612561+ // we peek the unverified `aud`, then verify with that expected audience.
25622562+ let token = bearer_token(&parts)?;
25632563+ let recipient_did = peek_jwt_aud(token).ok_or_else(|| {
25642564+ XrpcError::new(
25652565+ StatusCode::BAD_REQUEST,
25662566+ "InvalidToken",
25672567+ "notifySpaceDeleted: missing aud claim",
25682568+ )
25692569+ })?;
25702570+ let claims = crate::space::service_auth::verify_service_auth(
25712571+ &http,
25722572+ token,
25732573+ plc_dir,
25742574+ &recipient_did,
25752575+ "com.atproto.space.notifySpaceDeleted",
25762576+ )
25772577+ .await
25782578+ .map_err(XrpcError::from)?;
25792579+ if claims.iss != space.space_did {
25802580+ return Err(XrpcError::new(
25812581+ StatusCode::UNAUTHORIZED,
25822582+ "UntrustedIss",
25832583+ "notifySpaceDeleted JWT issuer must be the space DID",
25842584+ ));
25852585+ }
25862586+25872587+ // aud must be a DID/handle; best-effort no-op otherwise.
25882588+ if !recipient_did.starts_with("did:") {
25892589+ return Ok(StatusCode::OK);
25902590+ }
25912591+ // Recipient must be hosted here; otherwise best-effort no-op.
25922592+ if manager
25932593+ .lookup_handle(&recipient_did)
25942594+ .await
25952595+ .map_err(XrpcError::from)?
25962596+ .is_none()
25972597+ {
25982598+ return Ok(StatusCode::OK);
25992599+ }
26002600+26012601+ // Mark the recipient-side space row deleted; no-op if unknown.
26022602+ let store = SqlActorStore::open(manager.data_dir(), &recipient_did)
26032603+ .await
26042604+ .map_err(XrpcError::from)?;
26052605+ let now = chrono::Utc::now().to_rfc3339();
26062606+ sqlx::query("UPDATE space SET deleted_at = ? WHERE uri = ? AND deleted_at IS NULL")
26072607+ .bind(&now)
26082608+ .bind(space.to_string())
26092609+ .execute(store.pool())
26102610+ .await
26112611+ .map_err(|e| {
26122612+ XrpcError::new(
26132613+ StatusCode::INTERNAL_SERVER_ERROR,
26142614+ "InternalError",
26152615+ format!("notifySpaceDeleted mark deleted: {e}"),
26162616+ )
26172617+ })?;
26182618+ Ok(StatusCode::OK)
26192619+}
26202620+26212621+/// Best-effort extraction of the `aud` claim from a JWT *without* signature
26222622+/// verification — used only to learn the expected audience before the full
26232623+/// service-auth verification.
26242624+fn peek_jwt_aud(token: &str) -> Option<String> {
26252625+ let payload_b64 = token.split('.').nth(1)?;
26262626+ let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
26272627+ .decode(payload_b64.as_bytes())
26282628+ .ok()?;
26292629+ let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
26302630+ Some(value.get("aud")?.as_str()?.to_string())
26312631+}
26322632+26332633+#[cfg(test)]
26342634+mod scope_gate_tests {
26352635+ use super::*;
26362636+ use crate::account::session::SessionClaims;
26372637+ use crate::http::auth::AuthSubject;
26382638+ use crate::oauth::token::OAuthClaims;
26392639+ use crate::space::declaration::{SpaceDeclaration, StubSpaceDeclarationResolver};
26402640+ use atproto_oauth::scopes::{SpaceAction, SpaceManageVerb};
26412641+ use std::collections::HashMap;
26422642+ use std::sync::Arc;
26432643+26442644+ fn space_uri() -> SpaceUri {
26452645+ parse_space_uri("ats://did:plc:owner/app.bsky.group/default").unwrap()
26462646+ }
26472647+26482648+ /// Minimal `HttpState` with no declaration resolver configured (the
26492649+ /// fail-closed default: bare grants confer no write targets).
26502650+ async fn test_state() -> HttpState {
26512651+ let tmp = tempfile::tempdir().unwrap();
26522652+ let dir = tmp.path().to_path_buf();
26532653+ let accounts = crate::account::AccountDirectory::open_memory()
26542654+ .await
26552655+ .unwrap();
26562656+ let reader = Arc::new(crate::repo::RepoReader::new(accounts, dir));
26572657+ HttpState::new(reader)
26582658+ }
26592659+26602660+ /// `HttpState` whose declaration resolver maps `app.bsky.group` to a
26612661+ /// declaration listing `app.bsky.feed.post` as its sole collection.
26622662+ async fn test_state_with_declaration() -> HttpState {
26632663+ let mut map = HashMap::new();
26642664+ map.insert(
26652665+ "app.bsky.group".to_string(),
26662666+ SpaceDeclaration {
26672667+ name: "Group".to_string(),
26682668+ key: "tid".to_string(),
26692669+ collections: vec!["app.bsky.feed.post".to_string()],
26702670+ },
26712671+ );
26722672+ let resolver = Arc::new(StubSpaceDeclarationResolver::new(map));
26732673+ test_state().await.with_space_declaration_resolver(resolver)
26742674+ }
26752675+26762676+ fn oauth_subject(scope: &str) -> AuthSubject {
26772677+ AuthSubject::OAuth(OAuthClaims {
26782678+ sub: "did:plc:member".to_string(),
26792679+ iss: "did:web:pds".to_string(),
26802680+ aud: "did:web:pds".to_string(),
26812681+ client_id: "https://app.example/cm".to_string(),
26822682+ scope: scope.to_string(),
26832683+ cnf: None,
26842684+ iat: 0,
26852685+ exp: u64::MAX,
26862686+ jti: "jti".to_string(),
26872687+ })
26882688+ }
26892689+26902690+ fn session_subject() -> AuthSubject {
26912691+ AuthSubject::AppPassword(SessionClaims {
26922692+ sub: "did:plc:member".to_string(),
26932693+ iss: "did:web:pds".to_string(),
26942694+ apw: "apw".to_string(),
26952695+ privileged: true,
26962696+ iat: 0,
26972697+ exp: u64::MAX,
26982698+ jti: "jti".to_string(),
26992699+ })
27002700+ }
27012701+27022702+ #[tokio::test]
27032703+ async fn oauth_read_scope_matching_space_allows_read() {
27042704+ let state = test_state().await;
27052705+ let subject =
27062706+ oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read");
27072707+ assert!(
27082708+ assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None)
27092709+ .await
27102710+ .is_ok()
27112711+ );
27122712+ }
27132713+27142714+ #[tokio::test]
27152715+ async fn oauth_without_space_scope_denied_403() {
27162716+ let state = test_state().await;
27172717+ let subject = oauth_subject("atproto");
27182718+ let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None)
27192719+ .await
27202720+ .unwrap_err();
27212721+ assert_eq!(err.status, StatusCode::FORBIDDEN);
27222722+ assert_eq!(err.name, "InvalidToken");
27232723+ }
27242724+27252725+ #[tokio::test]
27262726+ async fn oauth_manage_scope_does_not_imply_read() {
27272727+ // A grant with only `manage` and no record `action` confers no record
27282728+ // read (manage and action are orthogonal axes per the 0016 spec).
27292729+ let state = test_state().await;
27302730+ let subject = oauth_subject(
27312731+ "space:app.bsky.group?did=did:plc:owner&skey=default&action=create&manage=update",
27322732+ );
27332733+ let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None)
27342734+ .await
27352735+ .unwrap_err();
27362736+ assert_eq!(err.status, StatusCode::FORBIDDEN);
27372737+ }
27382738+27392739+ #[test]
27402740+ fn oauth_manage_verb_gated_per_verb() {
27412741+ // `manage=update` authorizes the update verb but not create/delete.
27422742+ let subject =
27432743+ oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&manage=update");
27442744+ assert!(assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Update).is_ok());
27452745+ let err = assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Create).unwrap_err();
27462746+ assert_eq!(err.status, StatusCode::FORBIDDEN);
27472747+ assert_eq!(err.name, "InvalidToken");
27482748+ }
27492749+27502750+ #[test]
27512751+ fn oauth_bare_grant_confers_no_manage() {
27522752+ // A bare record-access grant must not authorize any management op.
27532753+ let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default");
27542754+ for verb in SpaceManageVerb::ALL {
27552755+ let err = assert_space_manage(&subject, &space_uri(), verb).unwrap_err();
27562756+ assert_eq!(err.status, StatusCode::FORBIDDEN);
13182757 }
13192758 }
1320275913211321- Ok(Json(ImportSpacesResponse {
13221322- space: outcome.space,
13231323- member_did: outcome.member_did,
13241324- records_inserted: outcome.records_inserted,
13251325- members_inserted: outcome.members_inserted,
13261326- }))
27602760+ #[tokio::test]
27612761+ async fn oauth_read_self_own_repo_collection_constrained() {
27622762+ // read_self on the holder's own repo is collection-constrained.
27632763+ let state = test_state().await;
27642764+ let subject = oauth_subject(
27652765+ "space:app.bsky.group?did=did:plc:owner&skey=default&action=read_self&collection=app.bsky.feed.post",
27662766+ );
27672767+ // sub() == "did:plc:member"; reading own repo + covered collection.
27682768+ assert!(
27692769+ assert_space_record_read(
27702770+ &state,
27712771+ &subject,
27722772+ &space_uri(),
27732773+ "did:plc:member",
27742774+ Some("app.bsky.feed.post"),
27752775+ )
27762776+ .await
27772777+ .is_ok()
27782778+ );
27792779+ // Uncovered collection denied.
27802780+ let err = assert_space_record_read(
27812781+ &state,
27822782+ &subject,
27832783+ &space_uri(),
27842784+ "did:plc:member",
27852785+ Some("app.bsky.feed.like"),
27862786+ )
27872787+ .await
27882788+ .unwrap_err();
27892789+ assert_eq!(err.status, StatusCode::FORBIDDEN);
27902790+ // Reading ANOTHER member's repo requires whole-space read → denied.
27912791+ let err = assert_space_record_read(
27922792+ &state,
27932793+ &subject,
27942794+ &space_uri(),
27952795+ "did:plc:other",
27962796+ Some("app.bsky.feed.post"),
27972797+ )
27982798+ .await
27992799+ .unwrap_err();
28002800+ assert_eq!(err.status, StatusCode::FORBIDDEN);
28012801+ }
28022802+28032803+ #[tokio::test]
28042804+ async fn oauth_read_grant_reads_any_repo() {
28052805+ // A whole-space `read` grant reads any member's repo, any collection.
28062806+ let state = test_state().await;
28072807+ let subject =
28082808+ oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read");
28092809+ assert!(
28102810+ assert_space_record_read(
28112811+ &state,
28122812+ &subject,
28132813+ &space_uri(),
28142814+ "did:plc:other",
28152815+ Some("any.collection"),
28162816+ )
28172817+ .await
28182818+ .is_ok()
28192819+ );
28202820+ // And cross-collection (collection=None) own-repo listing.
28212821+ assert!(
28222822+ assert_space_record_read(&state, &subject, &space_uri(), "did:plc:member", None)
28232823+ .await
28242824+ .is_ok()
28252825+ );
28262826+ }
28272827+28282828+ #[tokio::test]
28292829+ async fn oauth_wildcard_type_and_did_allows_any_space() {
28302830+ let state = test_state().await;
28312831+ let subject = oauth_subject("space:*?action=read");
28322832+ assert!(
28332833+ assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None)
28342834+ .await
28352835+ .is_ok()
28362836+ );
28372837+ }
28382838+28392839+ #[tokio::test]
28402840+ async fn oauth_tuple_mismatch_denied() {
28412841+ // Scope is for a different owner DID — tuple gate fails.
28422842+ let state = test_state().await;
28432843+ let subject =
28442844+ oauth_subject("space:app.bsky.group?did=did:plc:other&skey=default&action=read");
28452845+ let err = assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None)
28462846+ .await
28472847+ .unwrap_err();
28482848+ assert_eq!(err.status, StatusCode::FORBIDDEN);
28492849+ }
28502850+28512851+ #[tokio::test]
28522852+ async fn oauth_create_requires_covered_collection() {
28532853+ // `create` action but collection list does not cover the target.
28542854+ let state = test_state().await;
28552855+ let subject = oauth_subject(
28562856+ "space:app.bsky.group?did=did:plc:owner&skey=default&collection=app.bsky.feed.post&action=create",
28572857+ );
28582858+ // Covered collection → allowed.
28592859+ assert!(
28602860+ assert_space_scope(
28612861+ &state,
28622862+ &subject,
28632863+ &space_uri(),
28642864+ SpaceAction::Create,
28652865+ Some("app.bsky.feed.post"),
28662866+ )
28672867+ .await
28682868+ .is_ok()
28692869+ );
28702870+ // Uncovered collection → denied.
28712871+ let err = assert_space_scope(
28722872+ &state,
28732873+ &subject,
28742874+ &space_uri(),
28752875+ SpaceAction::Create,
28762876+ Some("app.bsky.feed.like"),
28772877+ )
28782878+ .await
28792879+ .unwrap_err();
28802880+ assert_eq!(err.status, StatusCode::FORBIDDEN);
28812881+ }
28822882+28832883+ #[tokio::test]
28842884+ async fn read_scope_does_not_grant_write() {
28852885+ let state = test_state().await;
28862886+ let subject =
28872887+ oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default&action=read");
28882888+ let err = assert_space_scope(
28892889+ &state,
28902890+ &subject,
28912891+ &space_uri(),
28922892+ SpaceAction::Create,
28932893+ Some("app.bsky.feed.post"),
28942894+ )
28952895+ .await
28962896+ .unwrap_err();
28972897+ assert_eq!(err.status, StatusCode::FORBIDDEN);
28982898+ }
28992899+29002900+ #[test]
29012901+ fn app_password_session_skips_scope_gate() {
29022902+ // Non-OAuth subjects are authorized at the session layer and skip the
29032903+ // `space:` scope check entirely. `assert_space_manage` is sync; the
29042904+ // record-scope gate is covered by the async tests above.
29052905+ let subject = session_subject();
29062906+ assert!(assert_space_manage(&subject, &space_uri(), SpaceManageVerb::Update).is_ok());
29072907+ }
29082908+29092909+ #[tokio::test]
29102910+ async fn app_password_session_skips_record_scope_gate() {
29112911+ // Non-OAuth subjects skip the `space:` record-scope check entirely
29122912+ // (only OAuth grants carry `space:` scopes per the 0016 spec, lines
29132913+ // 369-419).
29142914+ let state = test_state().await;
29152915+ let subject = session_subject();
29162916+ assert!(
29172917+ assert_space_scope(&state, &subject, &space_uri(), SpaceAction::Read, None)
29182918+ .await
29192919+ .is_ok()
29202920+ );
29212921+ }
29222922+29232923+ #[tokio::test]
29242924+ async fn read_opt_none_skips_gate() {
29252925+ // SpaceCredential auth (None subject) always passes the read gate.
29262926+ let state = test_state().await;
29272927+ assert!(
29282928+ assert_space_read_opt(&state, &None, &space_uri())
29292929+ .await
29302930+ .is_ok()
29312931+ );
29322932+ }
29332933+29342934+ #[tokio::test]
29352935+ async fn bare_grant_defaults_to_declared_collections() {
29362936+ // A bare `space:app.bsky.group` grant (omits `collection` and `action`)
29372937+ // must default its write targets to the declaration's `collections`
29382938+ // (spec line 413). With a resolver mapping the type to a declaration
29392939+ // listing `app.bsky.feed.post`, a create on that collection is allowed.
29402940+ let state = test_state_with_declaration().await;
29412941+ let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default");
29422942+ assert!(
29432943+ assert_space_scope(
29442944+ &state,
29452945+ &subject,
29462946+ &space_uri(),
29472947+ SpaceAction::Create,
29482948+ Some("app.bsky.feed.post"),
29492949+ )
29502950+ .await
29512951+ .is_ok()
29522952+ );
29532953+ // A collection NOT in the declaration is not conferred by the default.
29542954+ let err = assert_space_scope(
29552955+ &state,
29562956+ &subject,
29572957+ &space_uri(),
29582958+ SpaceAction::Create,
29592959+ Some("app.bsky.feed.like"),
29602960+ )
29612961+ .await
29622962+ .unwrap_err();
29632963+ assert_eq!(err.status, StatusCode::FORBIDDEN);
29642964+ }
29652965+29662966+ #[tokio::test]
29672967+ async fn bare_grant_without_resolver_confers_no_write_targets() {
29682968+ // Fail-closed: with no declaration resolver configured, a bare grant's
29692969+ // omitted-`collection` default resolves to empty, so no write target is
29702970+ // conferred (the pre-F4 behavior, now explicit and documented).
29712971+ let state = test_state().await;
29722972+ let subject = oauth_subject("space:app.bsky.group?did=did:plc:owner&skey=default");
29732973+ let err = assert_space_scope(
29742974+ &state,
29752975+ &subject,
29762976+ &space_uri(),
29772977+ SpaceAction::Create,
29782978+ Some("app.bsky.feed.post"),
29792979+ )
29802980+ .await
29812981+ .unwrap_err();
29822982+ assert_eq!(err.status, StatusCode::FORBIDDEN);
29832983+ }
13272984}
+24-14
crates/atproto-pds/src/http/state.rs
···88use crate::repo::{RepoReader, RepoWriter};
99use crate::security::{JtiReplayGuard, SlidingWindowLimiter};
1010use crate::sequencer::EventBus;
1111-use crate::space::{SpaceReader, SpaceService, SpaceSync, SpaceWriter};
1111+use crate::space::{SpaceDeclarationResolver, SpaceReader, SpaceService, SpaceSync, SpaceWriter};
1212use atproto_identity::key::KeyData;
1313use atproto_identity::traits::DnsResolver;
1414use std::sync::Arc;
···4343 pub space_reader: Option<Arc<SpaceReader>>,
4444 /// Spaces sync (state + oplog) reader.
4545 pub space_sync: Option<Arc<SpaceSync>>,
4646+ /// Resolver for space-type declarations (NSID → declared `collections`),
4747+ /// used to expand a bare `space:` grant's omitted-`collection` default
4848+ /// (spec line 413). `None` disables the default (bare grants confer no
4949+ /// write targets); typically a TTL-cached network resolver.
5050+ pub space_declaration_resolver: Option<Arc<dyn SpaceDeclarationResolver>>,
4651 /// PLC genesis service (None disables PLC-managed DID creation).
4752 pub plc_service: Option<Arc<PlcService>>,
4853 /// JWT-jti replay guard (always populated; in-memory by default).
···9398 /// `/oauth/jwks` so consumers verifying older tokens see them.
9499 pub pds_extra_signing_keys: Vec<Arc<KeyData>>,
95100 /// SpaceCredential TTL in seconds. Default
9696- /// `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS` (3h);
101101+ /// `atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS` (7200 / 2h);
97102 /// operators tighten/loosen via `PDS_SPACE_CREDENTIAL_TTL_SECONDS`.
98103 pub space_credential_ttl_secs: u64,
99104 /// Allowed handle suffix domains. Empty means
100105 /// any handle is accepted (back-compat). Set via
101106 /// `PDS_SERVICE_HANDLE_DOMAINS`.
102107 pub service_handle_domains: Vec<String>,
103103- /// Whether to send a notification email on space membership changes
104104- /// Set via `PDS_NOTIFY_MEMBERSHIP_EMAIL`.
105105- pub notify_membership_email: bool,
106108 /// Crawler hostnames notified by `requestCrawl`.
107109 /// Comma-separated `PDS_CRAWLERS`.
108110 pub crawlers: Vec<String>,
···131133 space_writer: None,
132134 space_reader: None,
133135 space_sync: None,
136136+ space_declaration_resolver: None,
134137 plc_service: None,
135138 jti_guard: JtiReplayGuard::new(100_000),
136139 rate_limiter: SlidingWindowLimiter::new(300, Duration::from_secs(60), 100_000),
···146149 pds_extra_signing_keys: Vec::new(),
147150 space_credential_ttl_secs: atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS,
148151 service_handle_domains: Vec::new(),
149149- notify_membership_email: false,
150152 crawlers: Vec::new(),
151153 bsky_app_view_did: None,
152154 bsky_app_view_url: None,
···174176 space_writer: None,
175177 space_reader: None,
176178 space_sync: None,
179179+ space_declaration_resolver: None,
177180 plc_service: None,
178181 jti_guard: JtiReplayGuard::new(100_000),
179182 rate_limiter: SlidingWindowLimiter::new(300, Duration::from_secs(60), 100_000),
···189192 pds_extra_signing_keys: Vec::new(),
190193 space_credential_ttl_secs: atproto_space::credential::SPACE_CREDENTIAL_TTL_SECS,
191194 service_handle_domains: Vec::new(),
192192- notify_membership_email: false,
193195 crawlers: Vec::new(),
194196 bsky_app_view_did: None,
195197 bsky_app_view_url: None,
···268270 self
269271 }
270272273273+ /// Attach a space-type declaration resolver. When set, a bare `space:`
274274+ /// grant's omitted-`collection` default expands to the declaration's
275275+ /// `collections` (spec line 413). Typically a
276276+ /// [`CachingSpaceDeclarationResolver`](crate::space::CachingSpaceDeclarationResolver)
277277+ /// wrapping a
278278+ /// [`NetworkSpaceDeclarationResolver`](crate::space::NetworkSpaceDeclarationResolver).
279279+ #[must_use]
280280+ pub fn with_space_declaration_resolver(
281281+ mut self,
282282+ resolver: Arc<dyn SpaceDeclarationResolver>,
283283+ ) -> Self {
284284+ self.space_declaration_resolver = Some(resolver);
285285+ self
286286+ }
287287+271288 /// Attach moderation-service forwarding configuration. When both `did` and `url` are set, `createReport` mints a
272289 /// service-auth token (`aud=did`, `lxm=com.atproto.moderation.createReport`)
273290 /// and POSTs the report payload to `<url>/xrpc/...`. Without these
···330347 #[must_use]
331348 pub fn with_service_handle_domains(mut self, domains: Vec<String>) -> Self {
332349 self.service_handle_domains = domains;
333333- self
334334- }
335335-336336- /// Enable membership-change email notifications.
337337- #[must_use]
338338- pub fn with_notify_membership_email(mut self, enabled: bool) -> Self {
339339- self.notify_membership_email = enabled;
340350 self
341351 }
342352
+70-35
crates/atproto-pds/src/notifier.rs
···11-//! Spaces notifier — `notifyWrite` / `notifyMembership` outbound delivery.
11+//! Spaces notifier — `notifyWrite` outbound delivery.
22//!
33-//! Spaces
44-//! writes don't fan out via the public firehose; instead the owner's PDS
55-//! POSTs the signed `notifyWrite` (records) and `notifyMembership` (member
66-//! list) payloads to each consumer service that holds a current
77-//! `SpaceCredential`. The set of recipient services lives in the per-actor
88-//! `space_credential_recipient` table; delivery attempts (with retry +
99-//! exponential backoff) live in the shared `notify_attempt` DLQ.
33+//! Spaces writes don't fan out via the public firehose; instead the owner's
44+//! PDS POSTs the contentless `notifyWrite` payload to each consumer service
55+//! that holds a current `SpaceCredential`. The set of recipient services lives
66+//! in the per-actor `space_credential_recipient` table; delivery attempts
77+//! (with retry + exponential backoff) live in the shared `notify_attempt` DLQ.
108//!
119//! This module ships:
1210//!
···56545755/// Append a notify-attempt row.
5856///
5959-/// `payload_cbor` is the dag-cbor-encoded request body the consumer will see;
6060-/// `nsid` distinguishes `com.atproto.space.notifyWrite` vs
6161-/// `com.atproto.space.notifyMembership` for the worker's POST.
5757+/// `payload` is the request body the consumer will see (JSON for the
5858+/// contentless notifyWrite); `nsid` selects the worker's POST URL;
5959+/// `content_type` is the request `Content-Type`; `auth_token`, when present,
6060+/// is delivered as a `Bearer` service-auth header.
6261pub async fn enqueue_notification(
6362 pool: &SqlitePool,
6463 target_service_did: &str,
6564 target_endpoint: &str,
6666- payload_cbor: Vec<u8>,
6565+ payload: Vec<u8>,
6766 nsid: &str,
6767+ content_type: &str,
6868+ auth_token: Option<&str>,
6869) -> PdsResult<String> {
6970 let id = format!("n-{}-{}", Utc::now().timestamp_millis(), random_suffix(8));
7071 let now = Utc::now().to_rfc3339();
7172 sqlx::query(
7273 "INSERT INTO notify_attempt
7374 (id, target_service_did, target_endpoint, payload_cbor, nsid,
7474- attempt_count, next_attempt_at, state)
7575- VALUES (?, ?, ?, ?, ?, 0, ?, 'pending')",
7575+ content_type, auth_token, attempt_count, next_attempt_at, state)
7676+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 'pending')",
7677 )
7778 .bind(&id)
7879 .bind(target_service_did)
7980 .bind(target_endpoint)
8080- .bind(&payload_cbor)
8181+ .bind(&payload)
8182 .bind(nsid)
8383+ .bind(content_type)
8484+ .bind(auth_token)
8285 .bind(&now)
8386 .execute(pool)
8487 .await
···97100 pub target_service_did: String,
98101 /// HTTPS endpoint base of the receiving service.
99102 pub target_endpoint: String,
100100- /// DAG-CBOR-encoded request body.
103103+ /// Request body bytes (DAG-CBOR or JSON depending on `content_type`).
101104 pub payload_cbor: Vec<u8>,
102105 /// XRPC NSID — appended to the endpoint to compose the POST URL.
103106 pub nsid: String,
107107+ /// Request `Content-Type` header.
108108+ pub content_type: String,
109109+ /// Optional `Bearer` service-auth token.
110110+ pub auth_token: Option<String>,
104111 /// How many times we've already tried this row.
105112 pub attempt_count: u32,
106113}
107114115115+/// Raw row shape returned by the `due_now` query (id, service_did, endpoint,
116116+/// payload, nsid, content_type, auth_token, attempt_count).
117117+type DueRow = (
118118+ String,
119119+ String,
120120+ String,
121121+ Vec<u8>,
122122+ String,
123123+ String,
124124+ Option<String>,
125125+ i64,
126126+);
127127+108128/// Read all `pending` rows whose `next_attempt_at <= now`. Caller is
109129/// responsible for marking each row as delivered/failed/retry after the POST.
110130pub async fn due_now(pool: &SqlitePool, limit: u32) -> PdsResult<Vec<DueAttempt>> {
111131 let now = Utc::now().to_rfc3339();
112132 let limit = limit.clamp(1, 1000);
113113- let rows: Vec<(String, String, String, Vec<u8>, String, i64)> = sqlx::query_as(
114114- "SELECT id, target_service_did, target_endpoint, payload_cbor, nsid, attempt_count
115115- FROM notify_attempt
116116- WHERE state = 'pending' AND next_attempt_at <= ?
117117- ORDER BY next_attempt_at ASC
118118- LIMIT ?",
133133+ let rows: Vec<DueRow> = sqlx::query_as(
134134+ "SELECT id, target_service_did, target_endpoint, payload_cbor, nsid,
135135+ content_type, auth_token, attempt_count
136136+ FROM notify_attempt
137137+ WHERE state = 'pending' AND next_attempt_at <= ?
138138+ ORDER BY next_attempt_at ASC
139139+ LIMIT ?",
119140 )
120141 .bind(&now)
121142 .bind(limit as i64)
···126147 })?;
127148 Ok(rows
128149 .into_iter()
129129- .map(|(id, did, endpoint, payload, nsid, attempts)| DueAttempt {
130130- id,
131131- target_service_did: did,
132132- target_endpoint: endpoint,
133133- payload_cbor: payload,
134134- nsid,
135135- attempt_count: attempts as u32,
136136- })
150150+ .map(
151151+ |(id, did, endpoint, payload, nsid, content_type, auth_token, attempts)| DueAttempt {
152152+ id,
153153+ target_service_did: did,
154154+ target_endpoint: endpoint,
155155+ payload_cbor: payload,
156156+ nsid,
157157+ content_type,
158158+ auth_token,
159159+ attempt_count: attempts as u32,
160160+ },
161161+ )
137162 .collect())
138163}
139164···250275 let mut delivered = 0u32;
251276 for attempt in due {
252277 let endpoint = format!("{}/xrpc/{}", attempt.target_endpoint, attempt.nsid);
253253- let result = self
278278+ let mut req = self
254279 .client
255280 .post(&endpoint)
256256- .header(reqwest::header::CONTENT_TYPE, "application/cbor")
257257- .body(attempt.payload_cbor.clone())
258258- .send()
259259- .await;
281281+ .header(reqwest::header::CONTENT_TYPE, attempt.content_type.clone())
282282+ .body(attempt.payload_cbor.clone());
283283+ if let Some(token) = attempt.auth_token.as_deref() {
284284+ req = req.bearer_auth(token);
285285+ }
286286+ let result = req.send().await;
260287 match result {
261288 Ok(resp) if resp.status().is_success() => {
262289 mark_delivered(pool, &attempt.id).await?;
···320347 "https://appview.example",
321348 b"payload-bytes".to_vec(),
322349 "com.atproto.space.notifyWrite",
350350+ "application/json",
351351+ None,
323352 )
324353 .await
325354 .unwrap();
···339368 "https://x.example",
340369 b"data".to_vec(),
341370 "com.atproto.space.notifyWrite",
371371+ "application/json",
372372+ None,
342373 )
343374 .await
344375 .unwrap();
···356387 "https://x.example",
357388 b"data".to_vec(),
358389 "com.atproto.space.notifyWrite",
390390+ "application/json",
391391+ None,
359392 )
360393 .await
361394 .unwrap();
···378411 "https://x.example",
379412 b"data".to_vec(),
380413 "com.atproto.space.notifyWrite",
414414+ "application/json",
415415+ None,
381416 )
382417 .await
383418 .unwrap();
+444-42
crates/atproto-pds/src/oauth/consent.rs
···77//! endpoint with `approve=true|false`.
88//!
99//! Hand-rolled HTML (no Askama dep) — same approach as the admin dashboard.
1010-//! the page now ships **friendly scope
1111-//! descriptions** so users see "Allow `app.example` to read your
1212-//! `app.bsky.group/default` records" instead of opaque scope strings like
1313-//! `space:read:app.bsky.group/default`. The `describe_scope` helper is
1414-//! the single source of truth — a future Askama refactor (deferred polish)
1515-//! drops it into a template directly.
1010+//! The page ships **friendly scope descriptions** so users see readable text
1111+//! instead of opaque scope strings. Space scopes follow the 0016 spec grammar
1212+//! (`space:<spaceType>[?did&skey&collection&action&manage]`): the space type
1313+//! renders its declaration `name` resolved from its `com.atproto.lexicon.schema`
1414+//! record (NSID fallback; spec line 434), the owner DID renders its
1515+//! bidirectionally-verified handle (DID fallback), and a prominent warning is
1616+//! shown when an app requests access to every space on the network
1717+//! (`type=* && did=*`). The `describe_scope` helper is the single source of
1818+//! truth — a future Askama refactor (deferred polish) drops it into a
1919+//! template directly.
16201721use crate::http::errors::XrpcError;
1822use crate::http::state::HttpState;
2323+use atproto_oauth::scopes::{Scope, SpaceCollection, SpaceDid, SpacePermission, SpaceType};
1924use axum::extract::{Query, State};
2025use axum::http::StatusCode;
2126use axum::response::{Html, IntoResponse, Response};
2227use serde::Deserialize;
2828+use std::collections::BTreeMap;
23292430/// Query params accepted by the consent page.
2531#[derive(Debug, Deserialize)]
···5157 )
5258 })?;
53595454- let html = render_consent(&q.request_uri, &request.client_id, &request.scope);
6060+ // Best-effort: resolve the space-owner DIDs named in any `space:` scope to
6161+ // their bidirectionally-verified handles, so the consent screen can render
6262+ // a human-readable owner instead of an opaque DID. Failures fall back to
6363+ // the raw DID.
6464+ let handles = resolve_space_owner_handles(&state, &request.scope).await;
6565+6666+ // Best-effort: resolve the space-type NSIDs named in any `space:` scope to
6767+ // their declaration `name` (spec line 434). Failures fall back to the NSID.
6868+ let type_names = resolve_space_type_names(&state, &request.scope).await;
6969+7070+ let html = render_consent(
7171+ &q.request_uri,
7272+ &request.client_id,
7373+ &request.scope,
7474+ &handles,
7575+ &type_names,
7676+ );
5577 Ok(Html(html).into_response())
5678}
57795858-fn render_consent(request_uri: &str, client_id: &str, scope: &str) -> String {
8080+/// Collect the distinct, concrete owner DIDs referenced by `space:` scopes in
8181+/// `scope`, resolve each to a bidirectionally-verified handle, and return the
8282+/// DID→handle map. DIDs that fail verification are simply omitted (callers
8383+/// fall back to the raw DID).
8484+async fn resolve_space_owner_handles(state: &HttpState, scope: &str) -> BTreeMap<String, String> {
8585+ let mut dids: Vec<String> = Vec::new();
8686+ for token in scope.split_whitespace() {
8787+ if let Ok(Scope::Space(perm)) = Scope::parse(token)
8888+ && let SpaceDid::Did(did) = &perm.did
8989+ && !dids.contains(did)
9090+ {
9191+ dids.push(did.clone());
9292+ }
9393+ }
9494+9595+ let mut out = BTreeMap::new();
9696+ for did in dids {
9797+ if let Some(handle) = verify_bidirectional_handle(state, &did).await {
9898+ out.insert(did, handle);
9999+ }
100100+ }
101101+ out
102102+}
103103+104104+/// Collect the distinct, concrete space-type NSIDs referenced by `space:`
105105+/// scopes in `scope`, resolve each to its declaration `name` (spec line 434),
106106+/// and return the NSID→name map. NSIDs that fail to resolve are omitted
107107+/// (callers fall back to the raw NSID).
108108+async fn resolve_space_type_names(state: &HttpState, scope: &str) -> BTreeMap<String, String> {
109109+ let mut nsids: Vec<String> = Vec::new();
110110+ for token in scope.split_whitespace() {
111111+ if let Ok(Scope::Space(perm)) = Scope::parse(token)
112112+ && let SpaceType::Nsid(nsid) = &perm.space_type
113113+ && !nsids.contains(nsid)
114114+ {
115115+ nsids.push(nsid.clone());
116116+ }
117117+ }
118118+119119+ let mut out = BTreeMap::new();
120120+ for nsid in nsids {
121121+ if let Some(name) = resolve_space_declaration_name(state, &nsid).await {
122122+ out.insert(nsid, name);
123123+ }
124124+ }
125125+ out
126126+}
127127+128128+/// Resolve a space-type NSID to its declaration `name` for the consent screen
129129+/// (spec lines 126, 434), via the shared
130130+/// [`SpaceDeclarationResolver`](crate::space::SpaceDeclarationResolver)
131131+/// configured on [`HttpState`].
132132+///
133133+/// This delegates to the same NSID → `com.atproto.lexicon.schema` resolution
134134+/// path (with shared TTL caching) used by the OAuth `space:` scope gate, so the
135135+/// consent UI and the gate never diverge. Returns `None` when no resolver is
136136+/// configured, resolution fails, or the declaration has an empty `name`
137137+/// (callers fall back to the raw NSID).
138138+async fn resolve_space_declaration_name(state: &HttpState, nsid: &str) -> Option<String> {
139139+ let declaration = state
140140+ .space_declaration_resolver
141141+ .as_ref()?
142142+ .resolve(nsid)
143143+ .await?;
144144+ (!declaration.name.is_empty()).then_some(declaration.name)
145145+}
146146+147147+/// Resolve `did` to its handle and verify the binding bidirectionally:
148148+/// resolve the DID document, take its first `at://` `alsoKnownAs`, re-resolve
149149+/// that handle, and require the result to equal `did`. Returns the handle on
150150+/// success, `None` on any failure (network, missing handle, mismatch, or no
151151+/// DNS resolver configured).
152152+async fn verify_bidirectional_handle(state: &HttpState, did: &str) -> Option<String> {
153153+ let dns_resolver = state.dns_resolver.clone()?;
154154+ let plc_hostname = state
155155+ .plc_service
156156+ .as_ref()
157157+ .map(|p| p.directory_hostname().to_string())?;
158158+ let http_client = reqwest::Client::builder()
159159+ .user_agent(crate::user_agent())
160160+ .timeout(std::time::Duration::from_secs(5))
161161+ .build()
162162+ .ok()?;
163163+164164+ let resolver = atproto_identity::resolve::InnerIdentityResolver {
165165+ dns_resolver,
166166+ http_client: http_client.clone(),
167167+ plc_hostname,
168168+ };
169169+170170+ // DID document → first at:// handle.
171171+ let document = resolver.resolve(did).await.ok()?;
172172+ let handle = document
173173+ .also_known_as
174174+ .iter()
175175+ .find_map(|aka| aka.strip_prefix("at://"))?
176176+ .to_string();
177177+178178+ // Re-resolve the handle and require it to round-trip back to `did`.
179179+ let resolved =
180180+ atproto_identity::resolve::resolve_handle(&http_client, dns_resolver_ref(state)?, &handle)
181181+ .await
182182+ .ok()?;
183183+ if resolved == did {
184184+ Some(handle)
185185+ } else {
186186+ tracing::debug!(
187187+ did,
188188+ handle,
189189+ resolved,
190190+ "consent: bidirectional handle verification mismatch; rendering DID"
191191+ );
192192+ None
193193+ }
194194+}
195195+196196+/// Borrow the configured DNS resolver, if any.
197197+fn dns_resolver_ref(state: &HttpState) -> Option<&dyn atproto_identity::traits::DnsResolver> {
198198+ state.dns_resolver.as_deref()
199199+}
200200+201201+fn render_consent(
202202+ request_uri: &str,
203203+ client_id: &str,
204204+ scope: &str,
205205+ handles: &BTreeMap<String, String>,
206206+ type_names: &BTreeMap<String, String>,
207207+) -> String {
59208 let scopes_list: String = scope
60209 .split_whitespace()
61210 .map(|s| {
62211 let raw = html_escape(s);
6363- let description = describe_scope(s);
212212+ let description = describe_scope(s, handles, type_names);
64213 format!(
65214 "<li><code>{raw}</code><div class=\"scope-desc\">{}</div></li>",
66215 html_escape(&description),
···68217 })
69218 .collect();
70219220220+ // Loud warning when any space scope grants access to *every* space on the
221221+ // network (`type=* && did=*`) — the prominent-warning requirement of spec
222222+ // lines 437-438.
223223+ let universal_warning = if has_universal_space_scope(scope) {
224224+ r#"<div class="space-warning"><strong>Warning:</strong> this application is requesting access to <b>every space on the network</b>. This is an extremely broad permission. Only grant it to applications you deeply trust.</div>"#
225225+ } else {
226226+ ""
227227+ };
228228+71229 format!(
72230 r#"<!doctype html>
73231<html lang="en">
···93251 .scopes ul {{ margin: 0.3em 0 0 1.2em; padding: 0; }}
94252 .scopes li {{ margin-bottom: 0.4em; }}
95253 .scope-desc {{ color: #555; font-size: 0.85em; margin-left: 0.2em; }}
254254+ .space-warning {{
255255+ background: #fff4f4; border: 1px solid #e0b4b4; color: #7a1f1f;
256256+ border-radius: 6px; padding: 0.8em 1em; margin: 1em 0; font-size: 0.9em;
257257+ }}
96258 button {{
97259 padding: 0.6em 1.2em; border: 0; border-radius: 4px;
98260 font-size: 1em; cursor: pointer; margin-right: 0.5em;
···106268<body>
107269 <h1>Authorize {client_id_safe}</h1>
108270 <p>Sign in to grant access to your account.</p>
271271+272272+ {universal_warning}
109273110274 <div class="scopes">
111275 Requested scopes:
···176340 client_id_safe = html_escape(client_id),
177341 request_uri_safe = html_escape(request_uri),
178342 scopes_list = scopes_list,
343343+ universal_warning = universal_warning,
179344 )
180345}
181346347347+/// `true` when any `space:` scope in `scope` grants access to every space on
348348+/// the network — i.e. its `type` is `*` **and** its `did` is `*`. This is the
349349+/// broad-grant condition that requires a prominent consent warning (spec lines
350350+/// 437-438).
351351+fn has_universal_space_scope(scope: &str) -> bool {
352352+ scope.split_whitespace().any(|token| {
353353+ matches!(
354354+ Scope::parse(token),
355355+ Ok(Scope::Space(perm))
356356+ if perm.space_type == SpaceType::All && perm.did == SpaceDid::All
357357+ )
358358+ })
359359+}
360360+182361/// Convert a raw OAuth scope token into a human-readable description for the
183362/// consent page. Recognizes:
184363///
185364/// - `atproto` → "core atproto session — sign in to your account"
186365/// - `transition:generic` → "all atproto records (legacy transition scope)"
187366/// - `transition:chat.bsky` → "Bluesky chat records (legacy transition scope)"
188188-/// - `space:read:<type>/<key>` → "read your Spaces records under <type>/<key>"
189189-/// - `space:write:<type>/<key>` → "create and update your Spaces records under <type>/<key>"
190190-/// - `space:admin:<type>/<key>` → "manage members of your Spaces under <type>/<key>"
367367+/// - `space:<type>[?did&skey&collection&action]` → a description built from the
368368+/// real space-scope grammar via [`describe_space_scope`].
191369///
192370/// Unknown scopes fall back to a generic "request access to scope <s>"
193371/// string so the user still sees something readable.
194194-pub fn describe_scope(scope: &str) -> String {
372372+///
373373+/// `handles` maps space-owner DIDs to their bidirectionally-verified handles;
374374+/// space scopes render the handle when present, the DID otherwise.
375375+/// `type_names` maps space-type NSIDs to their resolved declaration names
376376+/// (spec line 434); absent entries fall back to the raw NSID.
377377+pub fn describe_scope(
378378+ scope: &str,
379379+ handles: &BTreeMap<String, String>,
380380+ type_names: &BTreeMap<String, String>,
381381+) -> String {
195382 if scope == "atproto" {
196383 return "core atproto session — sign in to your account".to_string();
197384 }
···202389 other => format!("legacy transition scope `{other}`"),
203390 };
204391 }
205205- if let Some(rest) = scope.strip_prefix("space:") {
206206- // Format: space:<verb>:<type>/<key> e.g. space:read:app.bsky.group/default
207207- let mut parts = rest.splitn(2, ':');
208208- let verb = parts.next().unwrap_or("");
209209- let target = parts.next().unwrap_or("");
210210- let target_friendly = if target.is_empty() {
211211- "all your Spaces"
212212- } else {
213213- target
214214- };
215215- return match verb {
216216- "read" => format!("read your Spaces records under `{target_friendly}`"),
217217- "write" => format!("create and update your Spaces records under `{target_friendly}`"),
218218- "admin" => format!("manage members of your Spaces under `{target_friendly}`"),
219219- other => format!("`{other}` Spaces access under `{target_friendly}`"),
392392+ if scope == "space" || scope.starts_with("space:") || scope.starts_with("space?") {
393393+ return match Scope::parse(scope) {
394394+ Ok(Scope::Space(perm)) => describe_space_scope(&perm, handles, type_names),
395395+ // A `space:` prefix that fails to parse is a malformed scope; show
396396+ // it verbatim rather than a misleading description.
397397+ _ => format!("malformed Spaces scope `{scope}`"),
220398 };
221399 }
222400 format!("request access to scope `{scope}`")
223401}
224402403403+/// Build a human-readable description of a parsed `space:` permission, using
404404+/// the real grammar (`type`, `did`, `skey`, `collection`, `action`).
405405+///
406406+/// - The space *type* renders its declaration name when resolvable, falling
407407+/// back to the raw NSID (or "any space type" for the `*` wildcard).
408408+/// - The *owner* renders its verified handle (from `handles`) when available,
409409+/// the raw DID otherwise, or "any owner" for `*`.
410410+/// - The *actions* render as a friendly verb list (read / create / update /
411411+/// delete / manage).
412412+pub fn describe_space_scope(
413413+ perm: &SpacePermission,
414414+ handles: &BTreeMap<String, String>,
415415+ type_names: &BTreeMap<String, String>,
416416+) -> String {
417417+ let type_label = match &perm.space_type {
418418+ SpaceType::All => "any space type".to_string(),
419419+ SpaceType::Nsid(nsid) => space_type_declaration_name(nsid, type_names),
420420+ };
421421+422422+ let owner_label = match &perm.did {
423423+ SpaceDid::All => "any owner".to_string(),
424424+ SpaceDid::Did(did) => handles
425425+ .get(did)
426426+ .map(|h| format!("@{h}"))
427427+ .unwrap_or_else(|| did.clone()),
428428+ };
429429+430430+ // Record action verbs in canonical order (BTreeSet iteration is sorted by
431431+ // the SpaceAction Ord: read_self, read, create, update, delete).
432432+ let actions: Vec<&str> = perm.action.iter().map(|a| a.as_str()).collect();
433433+ let actions_label = if actions.is_empty() {
434434+ "no".to_string()
435435+ } else {
436436+ actions.join(", ")
437437+ };
438438+439439+ // Collections constrain write and read_self actions. `Default` defers to the
440440+ // declaration's collections (not enumerable here); an explicit empty list
441441+ // means no write targets.
442442+ let collections_label = match &perm.collection {
443443+ atproto_oauth::scopes::SpaceCollections::Default => {
444444+ Some("the space type's declared collections".to_string())
445445+ }
446446+ atproto_oauth::scopes::SpaceCollections::Explicit(set) if set.is_empty() => None,
447447+ atproto_oauth::scopes::SpaceCollections::Explicit(set) => {
448448+ let names: Vec<String> = set
449449+ .iter()
450450+ .map(|c| match c {
451451+ SpaceCollection::All => "any collection".to_string(),
452452+ SpaceCollection::Nsid(nsid) => nsid.clone(),
453453+ })
454454+ .collect();
455455+ Some(names.join(", "))
456456+ }
457457+ };
458458+459459+ let mut out = format!("{actions_label} access to {type_label} spaces owned by {owner_label}");
460460+ match &perm.skey {
461461+ atproto_oauth::scopes::SpaceSkey::Key(skey) => {
462462+ out.push_str(&format!(" (space key `{skey}`)"));
463463+ }
464464+ atproto_oauth::scopes::SpaceSkey::All => {}
465465+ }
466466+ if let Some(cols) = collections_label {
467467+ out.push_str(&format!(", collections: {cols}"));
468468+ }
469469+ // Space-management verbs are a separate axis; surface them prominently.
470470+ if !perm.manage.is_empty() {
471471+ let verbs: Vec<&str> = perm.manage.iter().map(|m| m.as_str()).collect();
472472+ out.push_str(&format!("; manage the spaces ({})", verbs.join(", ")));
473473+ }
474474+ out
475475+}
476476+477477+/// Render a space-type NSID's declaration `name` (spec line 434), falling back
478478+/// to the raw NSID when the declaration could not be resolved.
479479+///
480480+/// `type_names` is the NSID→declaration-name map produced by
481481+/// [`resolve_space_type_names`]; absent entries fall back to the NSID.
482482+fn space_type_declaration_name(nsid: &str, type_names: &BTreeMap<String, String>) -> String {
483483+ type_names
484484+ .get(nsid)
485485+ .cloned()
486486+ .unwrap_or_else(|| nsid.to_string())
487487+}
488488+225489fn html_escape(s: &str) -> String {
226490 let mut out = String::with_capacity(s.len());
227491 for c in s.chars() {
···241505mod tests {
242506 use super::*;
243507508508+ fn no_handles() -> BTreeMap<String, String> {
509509+ BTreeMap::new()
510510+ }
511511+512512+ fn no_names() -> BTreeMap<String, String> {
513513+ BTreeMap::new()
514514+ }
515515+244516 #[test]
245517 fn render_includes_client_id_and_scopes() {
246518 let html = render_consent(
247519 "urn:ietf:params:oauth:request_uri:abcd",
248520 "https://app.example/client-metadata.json",
249521 "atproto transition:generic",
522522+ &no_handles(),
523523+ &no_names(),
250524 );
251525 assert!(html.contains("https://app.example/client-metadata.json"));
252526 assert!(html.contains("atproto"));
···258532259533 #[test]
260534 fn render_escapes_client_id_html() {
261261- let html = render_consent("uri", "https://evil/<script>", "atproto");
535535+ let html = render_consent(
536536+ "uri",
537537+ "https://evil/<script>",
538538+ "atproto",
539539+ &no_handles(),
540540+ &no_names(),
541541+ );
262542 // The injected `<script>` substring must show up escaped in the
263543 // attacker-controlled positions (page title + body header). The page
264544 // legitimately contains its own `<script>` block for the form submit
···272552273553 #[test]
274554 fn render_handles_empty_scope() {
275275- let html = render_consent("uri", "client", "");
555555+ let html = render_consent("uri", "client", "", &no_handles(), &no_names());
276556 assert!(html.contains("Requested scopes"));
277557 }
278558279559 #[test]
280560 fn describe_scope_atproto_core() {
281281- let d = describe_scope("atproto");
561561+ let d = describe_scope("atproto", &no_handles(), &no_names());
282562 assert!(d.contains("core atproto session"));
283563 }
284564285565 #[test]
286566 fn describe_scope_transition_generic() {
287287- let d = describe_scope("transition:generic");
567567+ let d = describe_scope("transition:generic", &no_handles(), &no_names());
288568 assert!(d.contains("all atproto records"));
289569 }
290570291571 #[test]
292292- fn describe_scope_space_read() {
293293- let d = describe_scope("space:read:app.bsky.group/default");
294294- assert!(d.contains("read your Spaces records"));
295295- assert!(d.contains("app.bsky.group/default"));
572572+ fn describe_scope_space_read_only() {
573573+ // `action=read` on a concrete type; no did/skey → "any owner".
574574+ let d = describe_scope(
575575+ "space:app.bsky.group?action=read",
576576+ &no_handles(),
577577+ &no_names(),
578578+ );
579579+ assert!(d.contains("read access to"), "got: {d}");
580580+ assert!(d.contains("app.bsky.group"), "got: {d}");
581581+ assert!(d.contains("any owner"), "got: {d}");
582582+ }
583583+584584+ #[test]
585585+ fn describe_scope_space_default_actions() {
586586+ // Bare `space:<type>` defaults to {read, create, update, delete} — no
587587+ // manage (manage is a separate, none-by-default parameter).
588588+ let d = describe_scope("space:app.bsky.group", &no_handles(), &no_names());
589589+ assert!(d.contains("read"), "got: {d}");
590590+ assert!(d.contains("create"), "got: {d}");
591591+ assert!(
592592+ !d.contains("manage"),
593593+ "default grant confers no manage, got: {d}"
594594+ );
595595+ assert!(d.contains("app.bsky.group"), "got: {d}");
596596+ }
597597+598598+ #[test]
599599+ fn describe_scope_space_manage_surfaced() {
600600+ let d = describe_scope(
601601+ "space:app.bsky.group?manage=update&manage=delete",
602602+ &no_handles(),
603603+ &no_names(),
604604+ );
605605+ assert!(d.contains("manage the spaces"), "got: {d}");
606606+ assert!(d.contains("update"), "got: {d}");
607607+ assert!(d.contains("delete"), "got: {d}");
608608+ }
609609+610610+ #[test]
611611+ fn describe_scope_space_read_self() {
612612+ let d = describe_scope(
613613+ "space:app.bsky.group?action=read_self&collection=app.bsky.feed.post",
614614+ &no_handles(),
615615+ &no_names(),
616616+ );
617617+ assert!(d.contains("read_self"), "got: {d}");
618618+ assert!(d.contains("app.bsky.feed.post"), "got: {d}");
619619+ }
620620+621621+ #[test]
622622+ fn describe_scope_space_renders_declaration_name() {
623623+ // A resolved declaration name replaces the raw NSID (spec line 434).
624624+ let mut names = BTreeMap::new();
625625+ names.insert("app.bsky.group".to_string(), "Bluesky Group".to_string());
626626+ let d = describe_scope("space:app.bsky.group?action=read", &no_handles(), &names);
627627+ assert!(d.contains("Bluesky Group"), "got: {d}");
628628+ assert!(
629629+ !d.contains("app.bsky.group"),
630630+ "should prefer name, got: {d}"
631631+ );
632632+ }
633633+634634+ #[test]
635635+ fn describe_scope_space_renders_handle_when_known() {
636636+ let mut handles = BTreeMap::new();
637637+ handles.insert("did:plc:abc".to_string(), "alice.example".to_string());
638638+ let d = describe_scope(
639639+ "space:app.bsky.group?did=did:plc:abc&action=read",
640640+ &handles,
641641+ &no_names(),
642642+ );
643643+ assert!(d.contains("@alice.example"), "got: {d}");
644644+ assert!(!d.contains("did:plc:abc"), "should prefer handle, got: {d}");
645645+ }
646646+647647+ #[test]
648648+ fn describe_scope_space_falls_back_to_did() {
649649+ let d = describe_scope(
650650+ "space:app.bsky.group?did=did:plc:xyz&action=read",
651651+ &no_handles(),
652652+ &no_names(),
653653+ );
654654+ assert!(d.contains("did:plc:xyz"), "got: {d}");
296655 }
297656298657 #[test]
299299- fn describe_scope_space_write() {
300300- let d = describe_scope("space:write:app.bsky.group/default");
301301- assert!(d.contains("create and update"));
658658+ fn describe_scope_space_skey_and_collection() {
659659+ let d = describe_scope(
660660+ "space:app.bsky.group?skey=team&collection=app.bsky.feed.post&action=create",
661661+ &no_handles(),
662662+ &no_names(),
663663+ );
664664+ assert!(d.contains("team"), "got: {d}");
665665+ assert!(d.contains("app.bsky.feed.post"), "got: {d}");
666666+ assert!(d.contains("create"), "got: {d}");
667667+ }
668668+669669+ #[test]
670670+ fn describe_scope_space_malformed() {
671671+ // A `space:` prefix with no positional type is invalid.
672672+ let d = describe_scope("space:", &no_handles(), &no_names());
673673+ assert!(d.contains("malformed"), "got: {d}");
302674 }
303675304676 #[test]
305677 fn describe_scope_unknown_falls_back() {
306306- let d = describe_scope("custom:something:weird");
678678+ let d = describe_scope("custom:something:weird", &no_handles(), &no_names());
307679 assert!(d.contains("custom:something:weird"));
308680 }
309681310682 #[test]
683683+ fn universal_warning_triggers_on_type_and_did_wildcard() {
684684+ // `space:*` defaults did=* skey=* → universal.
685685+ assert!(has_universal_space_scope("space:*"));
686686+ let html = render_consent("uri", "client", "space:*", &no_handles(), &no_names());
687687+ assert!(html.contains("every space on the network"));
688688+ assert!(html.contains("space-warning"));
689689+ }
690690+691691+ #[test]
692692+ fn universal_warning_absent_when_did_anchored() {
693693+ // A concrete did anchors the scope → not universal.
694694+ assert!(!has_universal_space_scope("space:*?did=did:plc:abc"));
695695+ let html = render_consent(
696696+ "uri",
697697+ "client",
698698+ "space:*?did=did:plc:abc",
699699+ &no_handles(),
700700+ &no_names(),
701701+ );
702702+ assert!(!html.contains("every space on the network"));
703703+ }
704704+705705+ #[test]
706706+ fn universal_warning_absent_for_concrete_type() {
707707+ assert!(!has_universal_space_scope("space:app.bsky.group"));
708708+ }
709709+710710+ #[test]
311711 fn render_includes_friendly_descriptions() {
312712 let html = render_consent(
313713 "uri",
314714 "https://app.example/cm",
315315- "atproto space:read:app.bsky.group/default",
715715+ "atproto space:app.bsky.group?action=read",
716716+ &no_handles(),
717717+ &no_names(),
316718 );
317719 assert!(html.contains("core atproto session"));
318318- assert!(html.contains("read your Spaces records"));
720720+ assert!(html.contains("read access to"));
319721 }
320722}
+70-2
crates/atproto-pds/src/plc.rs
···164164 endpoint: self.config.service_endpoint.clone(),
165165 },
166166 );
167167+ // 0016 §"Space authority" (lines 87-92): the authority DID MUST expose
168168+ // a `#atproto_space_host` service. Per line 92 it MAY resolve to the
169169+ // same endpoint as `#atproto_pds`; reuse this PDS's endpoint.
170170+ services.insert(
171171+ "atproto_space_host".to_string(),
172172+ ServiceEndpoint {
173173+ service_type: "AtprotoPersonalDataServer".to_string(),
174174+ endpoint: self.config.service_endpoint.clone(),
175175+ },
176176+ );
167177 let handle_uri = if handle.starts_with("at://") {
168178 handle.to_string()
169179 } else {
···178188 if let Some(extra) = self.config.external_rotation_key.as_ref() {
179189 builder = builder.add_rotation_key(extra.clone());
180190 }
191191+ // 0016 §"Space authority" (lines 87-92): the authority DID MUST expose
192192+ // a `#atproto_space` verification method carrying the credential-signing
193193+ // key. Per line 92 it MAY coincide with `#atproto`; reuse the account's
194194+ // atproto signing key.
181195 let (did, signed_op, _builder_keys) = builder
182182- .add_verification_method("atproto".to_string(), signing_pub)
196196+ .add_verification_method("atproto".to_string(), signing_pub.clone())
197197+ .add_verification_method("atproto_space".to_string(), signing_pub)
183198 .add_also_known_as(handle_uri)
184199 .add_service("atproto_pds".to_string(), services["atproto_pds"].clone())
200200+ .add_service(
201201+ "atproto_space_host".to_string(),
202202+ services["atproto_space_host"].clone(),
203203+ )
185204 .build()
186205 .map_err(|e| PdsError::PlcRotationKey {
187206 reason: format!("build PLC op: {e}"),
···257276 format!("at://{handle}")
258277 };
259278 let svc = ServiceEndpoint {
279279+ service_type: "AtprotoPersonalDataServer".to_string(),
280280+ endpoint: self.config.service_endpoint.clone(),
281281+ };
282282+ // 0016 §"Space authority" (lines 87-92): expose `#atproto_space_host`
283283+ // alongside `#atproto_pds`, reusing this PDS's endpoint (line 92 MAY).
284284+ let space_host_svc = ServiceEndpoint {
260285 service_type: "AtprotoPersonalDataServer".to_string(),
261286 endpoint: self.config.service_endpoint.clone(),
262287 };
263288264289 let (did, op, _) = DidBuilder::new()
265290 .add_rotation_key(rotation_priv.clone())
266266- .add_verification_method("atproto".to_string(), signing_pub)
291291+ .add_verification_method("atproto".to_string(), signing_pub.clone())
292292+ .add_verification_method("atproto_space".to_string(), signing_pub)
267293 .add_also_known_as(handle_uri)
268294 .add_service("atproto_pds".to_string(), svc)
295295+ .add_service("atproto_space_host".to_string(), space_host_svc)
269296 .build()
270297 .map_err(|e| PdsError::PlcRotationKey {
271298 reason: format!("build PLC op: {e}"),
···331358 let services = &json["services"]["atproto_pds"];
332359 assert_eq!(services["type"], "AtprotoPersonalDataServer");
333360 assert_eq!(services["endpoint"], "https://pds.example.com");
361361+ }
362362+363363+ #[tokio::test(flavor = "multi_thread")]
364364+ async fn genesis_dry_run_exposes_atproto_space_verification_method() {
365365+ // 0016 §"Space authority" (lines 87-92): the authority DID MUST expose
366366+ // an `#atproto_space` verification method. Per line 92 it MAY coincide
367367+ // in value with `#atproto`; this PDS reuses the atproto signing key, so
368368+ // the two entries carry the same did:key.
369369+ let svc = fresh_service();
370370+ let (_did, op, _, _) = svc.genesis_dry_run("alice.example").await.unwrap();
371371+ let json = serde_json::to_value(&op).unwrap();
372372+ let vms = &json["verificationMethods"];
373373+ let atproto = vms["atproto"]
374374+ .as_str()
375375+ .expect("#atproto verification method present");
376376+ let atproto_space = vms["atproto_space"]
377377+ .as_str()
378378+ .expect("#atproto_space verification method present");
379379+ assert!(atproto.starts_with("did:key:"));
380380+ assert_eq!(
381381+ atproto, atproto_space,
382382+ "#atproto_space coincides with #atproto per line 92"
383383+ );
384384+ }
385385+386386+ #[tokio::test(flavor = "multi_thread")]
387387+ async fn genesis_dry_run_exposes_atproto_space_host_service() {
388388+ // 0016 §"Space authority" (lines 87-92): the authority DID MUST expose
389389+ // an `#atproto_space_host` service. Per line 92 it MAY resolve to the
390390+ // same endpoint as `#atproto_pds`.
391391+ let svc = fresh_service();
392392+ let (_did, op, _, _) = svc.genesis_dry_run("alice.example").await.unwrap();
393393+ let json = serde_json::to_value(&op).unwrap();
394394+ let host = &json["services"]["atproto_space_host"];
395395+ assert_eq!(host["type"], "AtprotoPersonalDataServer");
396396+ assert_eq!(host["endpoint"], "https://pds.example.com");
397397+ // Coincides with the #atproto_pds endpoint.
398398+ assert_eq!(
399399+ host["endpoint"],
400400+ json["services"]["atproto_pds"]["endpoint"]
401401+ );
334402 }
335403}
+17-93
crates/atproto-pds/src/realm.rs
···11-//! Realm enum — distinguishes the public repo realm from permissioned-data spaces.
22-//!
33-//! The PDS handles two realms:
44-//!
55-//! - **Public**: the user's standard atproto repo (MST, signed commits, firehose).
66-//! - **Space(SpaceUri)**: a permissioned-data space — separate storage, separate
77-//! commit primitive (SetHash + signed HMAC), separate sync (oplog + setHash),
88-//! separate notification path (`notifyWrite` push, not firehose).
11+//! SetHash dispatch for the PDS Spaces subsystem.
92//!
1010-//! Many subsystems (storage, auth, takedown, observability) need to dispatch on
1111-//! realm. This enum is the canonical discriminator.
1212-1313-use atproto_space::SpaceUri;
1414-1515-/// Discriminator for which realm a request, write, or read targets.
1616-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1717-pub enum Realm {
1818- /// The public atproto repo realm.
1919- Public,
2020- /// A permissioned-data space realm, identified by its `ats://` URI.
2121- Space(SpaceUri),
2222-}
2323-2424-impl Realm {
2525- /// Returns `true` if this is the public realm.
2626- #[must_use]
2727- pub fn is_public(&self) -> bool {
2828- matches!(self, Realm::Public)
2929- }
3030-3131- /// Returns `Some(SpaceUri)` if this is a space realm.
3232- #[must_use]
3333- pub fn as_space(&self) -> Option<&SpaceUri> {
3434- match self {
3535- Realm::Space(uri) => Some(uri),
3636- Realm::Public => None,
3737- }
3838- }
3939-}
4040-4141-impl std::fmt::Display for Realm {
4242- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4343- match self {
4444- Realm::Public => f.write_str("public"),
4545- Realm::Space(uri) => write!(f, "{}", uri),
4646- }
4747- }
4848-}
33+//! Binds the SetHash algorithm used for all permissioned-data Spaces
44+//! commitments in one place, and exposes its operator-facing name.
495506// ---------------------------------------------------------------------------
517// SetHash dispatch
528// ---------------------------------------------------------------------------
539//
5410// The PDS instantiates `SpaceRepo<S, PdsSetHash>` and `SpaceMembers<S,
5555-// PdsSetHash>` everywhere, so swapping the SetHash algorithm is a single
5656-// Cargo-feature decision. Two PDS deployments running different builds
5757-// **cannot interop on Spaces** (digests are not comparable across impls);
5858-// federations must align this flag.
1111+// PdsSetHash>` everywhere, binding the SetHash algorithm in one place.
59126013/// The SetHash impl this build of the PDS uses for all Spaces commitments.
6114///
6262-/// Aliases either `XorSha256SetHash` (default — placeholder per the upstream
6363-/// Spaces Design Spec) or `EcmhSetHash` over secp256k1 (when the `ecmh`
6464-/// Cargo feature is enabled — the production target).
6565-#[cfg(not(feature = "ecmh"))]
6666-pub type PdsSetHash = atproto_space::set_hash::XorSha256SetHash;
6767-6868-/// The SetHash impl this build of the PDS uses for all Spaces commitments
6969-/// (ECMH variant — secp256k1 multiset hash).
7070-#[cfg(feature = "ecmh")]
7171-pub type PdsSetHash = atproto_space::set_hash_ecmh::EcmhSetHash;
7272-7373-/// Constant identifying the SetHash impl in use. Surfaced in `_health` so
7474-/// operators + federation peers can compare.
7575-#[cfg(not(feature = "ecmh"))]
7676-pub const SET_HASH_NAME: &str = "xor-sha256";
1515+/// Aliases [`atproto_space::LtHash`] — the **production** primitive per
1616+/// [0016 Permissioned Data] (§ "Commit digest", lines 263-282).
1717+///
1818+/// `LtHash` persists a 2048-byte lattice **state** (via
1919+/// [`SetHash::state_bytes`](atproto_space::set_hash::SetHash::state_bytes));
2020+/// its 32-byte commitment (`sha256(state)`) is the `hash` field of a signed
2121+/// [`Commit`](atproto_space::Commit).
2222+///
2323+/// [0016 Permissioned Data]: https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md
2424+pub type PdsSetHash = atproto_space::set_hash::LtHash;
77257826/// Constant identifying the SetHash impl in use. Surfaced in `_health` so
7927/// operators + federation peers can compare.
8080-#[cfg(feature = "ecmh")]
8181-pub const SET_HASH_NAME: &str = "ecmh-secp256k1";
2828+pub const SET_HASH_NAME: &str = "lthash";
82298330#[cfg(test)]
8431mod tests {
8532 use super::*;
8633 use atproto_space::set_hash::SetHash;
8787- use atproto_space::{SpaceKey, SpaceType};
8888-8989- #[test]
9090- fn realm_public_is_public() {
9191- assert!(Realm::Public.is_public());
9292- assert!(Realm::Public.as_space().is_none());
9393- }
9494-9595- #[test]
9696- fn realm_space_is_not_public() {
9797- let space = SpaceUri::new(
9898- "did:plc:owner".to_string(),
9999- SpaceType::new("app.bsky.group").unwrap(),
100100- SpaceKey::new("default").unwrap(),
101101- );
102102- let realm = Realm::Space(space.clone());
103103- assert!(!realm.is_public());
104104- assert_eq!(realm.as_space(), Some(&space));
105105- }
1063410735 #[test]
10836 fn pds_set_hash_can_be_constructed() {
···11341 }
1144211543 #[test]
116116- fn set_hash_name_matches_feature() {
117117- if cfg!(feature = "ecmh") {
118118- assert_eq!(SET_HASH_NAME, "ecmh-secp256k1");
119119- } else {
120120- assert_eq!(SET_HASH_NAME, "xor-sha256");
121121- }
4444+ fn set_hash_name_is_lthash() {
4545+ assert_eq!(SET_HASH_NAME, "lthash");
12246 }
12347}
+2-2
crates/atproto-pds/src/security.rs
···11//! Production-hardening primitives: JTI replay guard, sliding-window
22//! rate limiter, and supporting plumbing.
33//!
44-//! Every JWT-verify step in OAuth / DPoP / MemberGrant /
55-//! SpaceCredential / service-auth checks the token's `jti` against a replay
44+//! Every JWT-verify step in OAuth / DPoP / delegation-token /
55+//! client-attestation / service-auth checks the token's `jti` against a replay
66//! filter; every authenticated XRPC call passes through a rate limiter.
77//!
88//! Two backends per primitive:
+450
crates/atproto-pds/src/space/config.rs
···11+//! Simplespace configuration model.
22+//!
33+//! Persists and surfaces the `com.atproto.simplespace.defs#spaceConfig`
44+//! shape: a `mintPolicy` (how the authority decides whether to authorize a
55+//! requesting *user*), an `appAccess` open union (how it decides whether to
66+//! authorize a requesting *app*), and an optional `managingApp` service
77+//! identifier.
88+//!
99+//! Two serializations are provided:
1010+//! - **Storage form** — compact JSON stored in the `space.app_access` column
1111+//! (`{"type":"open"}` / `{"type":"allowList","allowed":[...]}`), plus the
1212+//! scalar `space.mint_policy` and `space.managing_app` columns.
1313+//! - **Wire form** — the lexicon open-union shape returned by
1414+//! `com.atproto.space.getSpace`, with `$type` discriminators referencing
1515+//! `com.atproto.simplespace.defs#spaceConfig` / `#open` / `#allowList`.
1616+1717+use crate::actor_store::sql::{SqlActorStore, actor_db_path};
1818+use crate::errors::PdsError;
1919+use atproto_space::types::SpaceUri;
2020+use serde::{Deserialize, Serialize};
2121+use sqlx::SqlitePool;
2222+use std::path::Path;
2323+2424+/// `$type` of the space-config union variant in `getSpace` output.
2525+pub const SPACE_CONFIG_TYPE: &str = "com.atproto.simplespace.defs#spaceConfig";
2626+/// `$type` of the `#open` app-access union variant.
2727+pub const APP_ACCESS_OPEN_TYPE: &str = "com.atproto.simplespace.defs#open";
2828+/// `$type` of the `#allowList` app-access union variant.
2929+pub const APP_ACCESS_ALLOW_LIST_TYPE: &str = "com.atproto.simplespace.defs#allowList";
3030+3131+/// How the authority decides whether to authorize a requesting user.
3232+///
3333+/// Serializes to the lexicon `knownValues` strings
3434+/// (`public` | `member-list` | `managing-app`). `member-list` is the default.
3535+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
3636+pub enum MintPolicy {
3737+ /// Authorize anyone.
3838+ #[serde(rename = "public")]
3939+ Public,
4040+ /// Consult the member list (default).
4141+ #[default]
4242+ #[serde(rename = "member-list")]
4343+ MemberList,
4444+ /// Ask the `managingApp` via `checkUserAccess`.
4545+ #[serde(rename = "managing-app")]
4646+ ManagingApp,
4747+}
4848+4949+impl MintPolicy {
5050+ /// The `knownValues` string form persisted in the `mint_policy` column.
5151+ #[must_use]
5252+ pub fn as_str(self) -> &'static str {
5353+ match self {
5454+ Self::Public => "public",
5555+ Self::MemberList => "member-list",
5656+ Self::ManagingApp => "managing-app",
5757+ }
5858+ }
5959+6060+ /// Parse the `knownValues` string form. Unknown values are rejected.
6161+ pub fn from_str_value(value: &str) -> Result<Self, PdsError> {
6262+ match value {
6363+ "public" => Ok(Self::Public),
6464+ "member-list" => Ok(Self::MemberList),
6565+ "managing-app" => Ok(Self::ManagingApp),
6666+ other => Err(PdsError::Storage {
6767+ reason: format!("invalid mintPolicy {other}"),
6868+ }),
6969+ }
7070+ }
7171+}
7272+7373+/// How the authority decides whether to authorize a requesting app.
7474+///
7575+/// Mirrors the `com.atproto.simplespace.defs` open union of `#open` /
7676+/// `#allowList`.
7777+#[derive(Debug, Clone, PartialEq, Eq, Default)]
7878+pub enum AppAccess {
7979+ /// `#open` — any app may access the space.
8080+ #[default]
8181+ Open,
8282+ /// `#allowList` — only the named OAuth client IDs may access the space.
8383+ AllowList {
8484+ /// Permitted OAuth client IDs.
8585+ allowed: Vec<String>,
8686+ },
8787+}
8888+8989+/// Internal compact storage representation of [`AppAccess`], tagged on a
9090+/// plain `type` field (no `$type`) so the value is self-describing inside
9191+/// the `space.app_access` JSON column.
9292+#[derive(Debug, Serialize, Deserialize)]
9393+#[serde(tag = "type")]
9494+enum AppAccessStored {
9595+ #[serde(rename = "open")]
9696+ Open,
9797+ #[serde(rename = "allowList")]
9898+ AllowList { allowed: Vec<String> },
9999+}
100100+101101+impl AppAccess {
102102+ /// Serialize to the compact JSON storage form for the `app_access`
103103+ /// column.
104104+ pub fn to_storage_json(&self) -> Result<String, PdsError> {
105105+ let stored = match self {
106106+ Self::Open => AppAccessStored::Open,
107107+ Self::AllowList { allowed } => AppAccessStored::AllowList {
108108+ allowed: allowed.clone(),
109109+ },
110110+ };
111111+ serde_json::to_string(&stored).map_err(|e| PdsError::Storage {
112112+ reason: format!("serialize appAccess: {e}"),
113113+ })
114114+ }
115115+116116+ /// Parse the compact JSON storage form from the `app_access` column.
117117+ pub fn from_storage_json(json: &str) -> Result<Self, PdsError> {
118118+ let stored: AppAccessStored =
119119+ serde_json::from_str(json).map_err(|e| PdsError::Storage {
120120+ reason: format!("parse appAccess {json}: {e}"),
121121+ })?;
122122+ Ok(match stored {
123123+ AppAccessStored::Open => Self::Open,
124124+ AppAccessStored::AllowList { allowed } => Self::AllowList { allowed },
125125+ })
126126+ }
127127+128128+ /// Build from a `getSpace`/`updateSpace` wire-form union value (tagged on
129129+ /// `$type` with the `#open` / `#allowList` refs).
130130+ pub fn from_wire(value: &serde_json::Value) -> Result<Self, PdsError> {
131131+ let ty = value
132132+ .get("$type")
133133+ .and_then(serde_json::Value::as_str)
134134+ .ok_or_else(|| PdsError::Storage {
135135+ reason: "appAccess union missing $type".to_string(),
136136+ })?;
137137+ match ty {
138138+ APP_ACCESS_OPEN_TYPE => Ok(Self::Open),
139139+ APP_ACCESS_ALLOW_LIST_TYPE => {
140140+ let allowed = value
141141+ .get("allowed")
142142+ .and_then(serde_json::Value::as_array)
143143+ .ok_or_else(|| PdsError::Storage {
144144+ reason: "appAccess #allowList missing allowed[]".to_string(),
145145+ })?
146146+ .iter()
147147+ .map(|v| {
148148+ v.as_str()
149149+ .map(str::to_string)
150150+ .ok_or_else(|| PdsError::Storage {
151151+ reason: "appAccess #allowList allowed[] entry not a string"
152152+ .to_string(),
153153+ })
154154+ })
155155+ .collect::<Result<Vec<_>, _>>()?;
156156+ Ok(Self::AllowList { allowed })
157157+ }
158158+ other => Err(PdsError::Storage {
159159+ reason: format!("unknown appAccess $type {other}"),
160160+ }),
161161+ }
162162+ }
163163+164164+ /// Render the lexicon wire-form union value (with `$type`).
165165+ #[must_use]
166166+ pub fn to_wire(&self) -> serde_json::Value {
167167+ match self {
168168+ Self::Open => serde_json::json!({ "$type": APP_ACCESS_OPEN_TYPE }),
169169+ Self::AllowList { allowed } => serde_json::json!({
170170+ "$type": APP_ACCESS_ALLOW_LIST_TYPE,
171171+ "allowed": allowed,
172172+ }),
173173+ }
174174+ }
175175+}
176176+177177+/// In-memory model of a space's simplespace configuration.
178178+#[derive(Debug, Clone, PartialEq, Eq, Default)]
179179+pub struct SpaceConfig {
180180+ /// User-authorization policy.
181181+ pub mint_policy: MintPolicy,
182182+ /// App-authorization policy.
183183+ pub app_access: AppAccess,
184184+ /// Optional managing-app service identifier.
185185+ pub managing_app: Option<String>,
186186+}
187187+188188+impl SpaceConfig {
189189+ /// Reconstruct from the three persisted columns.
190190+ pub fn from_columns(
191191+ mint_policy: &str,
192192+ app_access: &str,
193193+ managing_app: Option<String>,
194194+ ) -> Result<Self, PdsError> {
195195+ Ok(Self {
196196+ mint_policy: MintPolicy::from_str_value(mint_policy)?,
197197+ app_access: AppAccess::from_storage_json(app_access)?,
198198+ managing_app,
199199+ })
200200+ }
201201+202202+ /// Parse the `config` ref carried on a `createSpace` input. Missing fields
203203+ /// fall back to the host defaults (`member-list` / `#open` / no managing
204204+ /// app). The `appAccess` union is in wire form (`$type`-tagged).
205205+ pub fn from_create_input(value: &serde_json::Value) -> Result<Self, PdsError> {
206206+ let mint_policy = match value.get("mintPolicy").and_then(serde_json::Value::as_str) {
207207+ Some(s) => MintPolicy::from_str_value(s)?,
208208+ None => MintPolicy::default(),
209209+ };
210210+ let app_access = match value.get("appAccess") {
211211+ Some(v) if !v.is_null() => AppAccess::from_wire(v)?,
212212+ _ => AppAccess::default(),
213213+ };
214214+ let managing_app = value
215215+ .get("managingApp")
216216+ .and_then(serde_json::Value::as_str)
217217+ .filter(|s| !s.is_empty())
218218+ .map(str::to_string);
219219+ Ok(Self {
220220+ mint_policy,
221221+ app_access,
222222+ managing_app,
223223+ })
224224+ }
225225+226226+ /// Render the `getSpace` wire-form `config` union value (with the
227227+ /// `#spaceConfig` `$type` discriminator).
228228+ #[must_use]
229229+ pub fn to_wire(&self) -> serde_json::Value {
230230+ let mut obj = serde_json::json!({
231231+ "$type": SPACE_CONFIG_TYPE,
232232+ "mintPolicy": self.mint_policy.as_str(),
233233+ "appAccess": self.app_access.to_wire(),
234234+ });
235235+ if let Some(ref app) = self.managing_app {
236236+ obj["managingApp"] = serde_json::Value::String(app.clone());
237237+ }
238238+ obj
239239+ }
240240+}
241241+242242+/// A field-level patch applied by `updateSpace`. Each `Option::None` leaves
243243+/// the corresponding column unchanged; `managing_app == Some("")` clears the
244244+/// column to `NULL`.
245245+#[derive(Debug, Default)]
246246+pub struct SpaceConfigPatch {
247247+ /// New mint policy, if provided.
248248+ pub mint_policy: Option<MintPolicy>,
249249+ /// New app-access policy, if provided (replaces wholesale).
250250+ pub app_access: Option<AppAccess>,
251251+ /// New managing-app identifier. `Some("")` clears to `NULL`; `Some(id)`
252252+ /// sets; `None` leaves unchanged.
253253+ pub managing_app: Option<String>,
254254+}
255255+256256+impl SpaceConfigPatch {
257257+ /// Parse an `updateSpace` input object into a patch. The `space` field is
258258+ /// handled by the caller; only the config fields are read here.
259259+ pub fn from_update_input(value: &serde_json::Value) -> Result<Self, PdsError> {
260260+ let mint_policy = match value.get("mintPolicy").and_then(serde_json::Value::as_str) {
261261+ Some(s) => Some(MintPolicy::from_str_value(s)?),
262262+ None => None,
263263+ };
264264+ let app_access = match value.get("appAccess") {
265265+ Some(v) if !v.is_null() => Some(AppAccess::from_wire(v)?),
266266+ _ => None,
267267+ };
268268+ let managing_app = value
269269+ .get("managingApp")
270270+ .and_then(serde_json::Value::as_str)
271271+ .map(str::to_string);
272272+ Ok(Self {
273273+ mint_policy,
274274+ app_access,
275275+ managing_app,
276276+ })
277277+ }
278278+}
279279+280280+/// Fail with `SpaceNotFound` when the space is tombstoned
281281+/// (`deleted_at IS NOT NULL`) in the given store. A missing row is treated as
282282+/// *not deleted* here so callers that operate on spaces seeded lazily
283283+/// (notify-inbound, cross-PDS reads) keep working; the tombstone gate only
284284+/// fires once a row exists and carries a `deleted_at`. The query is a no-op
285285+/// (returns `Ok`) when the `space` row is absent.
286286+///
287287+/// # Errors
288288+/// Returns [`PdsError::SpaceNotFound`] when the space is tombstoned, or
289289+/// [`PdsError::Storage`] on a query failure.
290290+pub async fn ensure_not_deleted(pool: &SqlitePool, uri: &SpaceUri) -> Result<(), PdsError> {
291291+ let deleted_at: Option<Option<String>> =
292292+ sqlx::query_scalar("SELECT deleted_at FROM space WHERE uri = ?")
293293+ .bind(uri.to_string())
294294+ .fetch_optional(pool)
295295+ .await
296296+ .map_err(|e| PdsError::Storage {
297297+ reason: format!("ensure_not_deleted: {e}"),
298298+ })?;
299299+ if matches!(deleted_at, Some(Some(_))) {
300300+ return Err(PdsError::SpaceNotFound {
301301+ uri: uri.to_string(),
302302+ });
303303+ }
304304+ Ok(())
305305+}
306306+307307+/// Fail with `SpaceNotFound` when the space identified by `space` is
308308+/// tombstoned in the space-authority's per-actor store.
309309+///
310310+/// The space row (and its `deleted_at` tombstone) is owned by
311311+/// `space.space_did`. This opens that authority's store *only when it already
312312+/// exists locally*; for cross-PDS spaces whose authority lives elsewhere the
313313+/// check is skipped (the remote authority enforces deletion on its side, and
314314+/// `getSpaceCredential` minting is gated separately).
315315+///
316316+/// # Errors
317317+/// Returns [`PdsError::SpaceNotFound`] when the authority's row is tombstoned,
318318+/// or [`PdsError::Storage`] on a query failure.
319319+pub async fn ensure_space_live(data_dir: &Path, space: &SpaceUri) -> Result<(), PdsError> {
320320+ // Skip when the authority's store does not exist locally.
321321+ if !actor_db_path(data_dir, &space.space_did).exists() {
322322+ return Ok(());
323323+ }
324324+ let store = SqlActorStore::open(data_dir, &space.space_did).await?;
325325+ ensure_not_deleted(store.pool(), space).await
326326+}
327327+328328+#[cfg(test)]
329329+mod tests {
330330+ use super::*;
331331+332332+ #[test]
333333+ fn app_access_storage_round_trip() {
334334+ let open = AppAccess::Open;
335335+ let json = open.to_storage_json().unwrap();
336336+ assert_eq!(json, r#"{"type":"open"}"#);
337337+ assert_eq!(AppAccess::from_storage_json(&json).unwrap(), open);
338338+339339+ let list = AppAccess::AllowList {
340340+ allowed: vec!["https://app.example/client".to_string()],
341341+ };
342342+ let json = list.to_storage_json().unwrap();
343343+ assert_eq!(AppAccess::from_storage_json(&json).unwrap(), list);
344344+ }
345345+346346+ #[test]
347347+ fn app_access_default_matches_migration_default() {
348348+ assert_eq!(
349349+ AppAccess::from_storage_json(r#"{"type":"open"}"#).unwrap(),
350350+ AppAccess::Open
351351+ );
352352+ }
353353+354354+ #[test]
355355+ fn app_access_wire_round_trip() {
356356+ let list = AppAccess::AllowList {
357357+ allowed: vec!["c1".to_string(), "c2".to_string()],
358358+ };
359359+ let wire = list.to_wire();
360360+ assert_eq!(wire["$type"], APP_ACCESS_ALLOW_LIST_TYPE);
361361+ assert_eq!(AppAccess::from_wire(&wire).unwrap(), list);
362362+363363+ let open = AppAccess::Open;
364364+ assert_eq!(AppAccess::from_wire(&open.to_wire()).unwrap(), open);
365365+ }
366366+367367+ #[test]
368368+ fn mint_policy_round_trip() {
369369+ for p in [
370370+ MintPolicy::Public,
371371+ MintPolicy::MemberList,
372372+ MintPolicy::ManagingApp,
373373+ ] {
374374+ assert_eq!(MintPolicy::from_str_value(p.as_str()).unwrap(), p);
375375+ }
376376+ assert!(MintPolicy::from_str_value("bogus").is_err());
377377+ }
378378+379379+ #[test]
380380+ fn config_default_is_member_list_open() {
381381+ let cfg = SpaceConfig::default();
382382+ assert_eq!(cfg.mint_policy, MintPolicy::MemberList);
383383+ assert_eq!(cfg.app_access, AppAccess::Open);
384384+ assert!(cfg.managing_app.is_none());
385385+ }
386386+387387+ #[test]
388388+ fn config_to_wire_carries_type() {
389389+ let cfg = SpaceConfig {
390390+ mint_policy: MintPolicy::Public,
391391+ app_access: AppAccess::Open,
392392+ managing_app: Some("did:web:example.com#forum".to_string()),
393393+ };
394394+ let wire = cfg.to_wire();
395395+ assert_eq!(wire["$type"], SPACE_CONFIG_TYPE);
396396+ assert_eq!(wire["mintPolicy"], "public");
397397+ assert_eq!(wire["managingApp"], "did:web:example.com#forum");
398398+ assert_eq!(wire["appAccess"]["$type"], APP_ACCESS_OPEN_TYPE);
399399+ }
400400+401401+ #[test]
402402+ fn config_from_create_input_defaults() {
403403+ let cfg = SpaceConfig::from_create_input(&serde_json::json!({})).unwrap();
404404+ assert_eq!(cfg, SpaceConfig::default());
405405+ }
406406+407407+ #[test]
408408+ fn config_from_create_input_full() {
409409+ let cfg = SpaceConfig::from_create_input(&serde_json::json!({
410410+ "mintPolicy": "managing-app",
411411+ "appAccess": { "$type": APP_ACCESS_ALLOW_LIST_TYPE, "allowed": ["c1"] },
412412+ "managingApp": "did:web:m.example#svc",
413413+ }))
414414+ .unwrap();
415415+ assert_eq!(cfg.mint_policy, MintPolicy::ManagingApp);
416416+ assert_eq!(
417417+ cfg.app_access,
418418+ AppAccess::AllowList {
419419+ allowed: vec!["c1".to_string()]
420420+ }
421421+ );
422422+ assert_eq!(cfg.managing_app.as_deref(), Some("did:web:m.example#svc"));
423423+ }
424424+425425+ #[test]
426426+ fn create_input_empty_managing_app_is_none() {
427427+ let cfg =
428428+ SpaceConfig::from_create_input(&serde_json::json!({ "managingApp": "" })).unwrap();
429429+ assert!(cfg.managing_app.is_none());
430430+ }
431431+432432+ #[test]
433433+ fn update_patch_partial() {
434434+ let patch = SpaceConfigPatch::from_update_input(&serde_json::json!({
435435+ "space": "ignored",
436436+ "mintPolicy": "public",
437437+ }))
438438+ .unwrap();
439439+ assert_eq!(patch.mint_policy, Some(MintPolicy::Public));
440440+ assert!(patch.app_access.is_none());
441441+ assert!(patch.managing_app.is_none());
442442+ }
443443+444444+ #[test]
445445+ fn update_patch_clear_managing_app() {
446446+ let patch =
447447+ SpaceConfigPatch::from_update_input(&serde_json::json!({ "managingApp": "" })).unwrap();
448448+ assert_eq!(patch.managing_app.as_deref(), Some(""));
449449+ }
450450+}
+391
crates/atproto-pds/src/space/declaration.rs
···11+//! Space-type **declaration** resolution — maps a space-type NSID to the
22+//! `com.atproto.lexicon.schema` record that publishes it and extracts the
33+//! `defs.main` space declaration (spec lines 100-130, 407-413).
44+//!
55+//! A bare `space:<concreteType>` OAuth grant (one that omits `collection`)
66+//! defaults its write targets to the **declared** `collections` of the space
77+//! type (spec line 413). To honor that default the PDS must resolve the
88+//! declaration at request time. The resolution path mirrors AT Protocol
99+//! lexicon resolution:
1010+//!
1111+//! 1. NSID → `_lexicon.<name>.<reversed-authority>` DNS TXT → authority DID.
1212+//! 2. Authority DID → DID document → `AtprotoPersonalDataServer` endpoint.
1313+//! 3. `com.atproto.repo.getRecord` of `com.atproto.lexicon.schema` keyed by the
1414+//! NSID → parse `defs.main` as a [`SpaceSchema`].
1515+//!
1616+//! Resolution is **fail-closed**: any failure yields `None`, which the scope
1717+//! gate treats as an empty collection set (a bare grant then confers no write
1818+//! targets). Results are cached with a short TTL via
1919+//! [`CachingSpaceDeclarationResolver`] so a hot path does not re-resolve on
2020+//! every request.
2121+//!
2222+//! ## Operational caveat
2323+//!
2424+//! [`NetworkSpaceDeclarationResolver`] requires a configured DNS resolver and
2525+//! PLC directory hostname (the same prerequisites as handle resolution). When
2626+//! the PDS runs without a DNS resolver, declaration resolution is disabled and
2727+//! bare `space:` grants confer no write targets until the operator configures
2828+//! one. The resolver is expressed as a trait so it can be exercised in unit
2929+//! tests with a stub (see [`StubSpaceDeclarationResolver`]).
3030+3131+use atproto_lexicon::validation::schema::SchemaDef;
3232+use atproto_lexicon::validation::schema_file::SchemaFile;
3333+use std::collections::HashMap;
3434+use std::sync::Arc;
3535+use std::sync::Mutex;
3636+use std::time::{Duration, Instant};
3737+3838+/// A resolved space-type declaration (`defs.main` with `"type": "space"`).
3939+#[derive(Debug, Clone, PartialEq, Eq)]
4040+pub struct SpaceDeclaration {
4141+ /// Human-readable name for the space type (consent-screen label).
4242+ pub name: String,
4343+ /// Recommended space-key (`skey`) type for spaces of this type.
4444+ pub key: String,
4545+ /// Declared record-collection NSIDs — the default `collection` set for a
4646+ /// bare `space:` grant of this type (spec line 413).
4747+ pub collections: Vec<String>,
4848+}
4949+5050+/// Resolves a space-type NSID to its [`SpaceDeclaration`].
5151+///
5252+/// Implementations are fail-closed: a `None` result is treated by the scope
5353+/// gate as "no declared collections", so a bare grant confers no write
5454+/// targets. The trait exists so the resolver can be swapped for a stub in
5555+/// tests where full network resolution is infeasible.
5656+#[async_trait::async_trait]
5757+pub trait SpaceDeclarationResolver: Send + Sync {
5858+ /// Resolve `nsid` to its declaration, or `None` on any failure.
5959+ async fn resolve(&self, nsid: &str) -> Option<SpaceDeclaration>;
6060+}
6161+6262+/// Network-backed resolver following the AT Protocol lexicon-resolution path.
6363+///
6464+/// Holds the same dependencies as handle resolution (a DNS resolver, the PLC
6565+/// directory hostname, and an HTTP client). Construct one only when a DNS
6666+/// resolver is available; otherwise leave declaration resolution unconfigured.
6767+pub struct NetworkSpaceDeclarationResolver {
6868+ /// DNS resolver for the `_lexicon.` authority TXT lookup.
6969+ dns_resolver: Arc<dyn atproto_identity::traits::DnsResolver>,
7070+ /// PLC directory hostname for `did:plc` document resolution.
7171+ plc_hostname: String,
7272+ /// HTTP client for DID-document and record retrieval.
7373+ http_client: reqwest::Client,
7474+}
7575+7676+impl NetworkSpaceDeclarationResolver {
7777+ /// Construct a network resolver from its dependencies.
7878+ #[must_use]
7979+ pub fn new(
8080+ dns_resolver: Arc<dyn atproto_identity::traits::DnsResolver>,
8181+ plc_hostname: String,
8282+ http_client: reqwest::Client,
8383+ ) -> Self {
8484+ Self {
8585+ dns_resolver,
8686+ plc_hostname,
8787+ http_client,
8888+ }
8989+ }
9090+}
9191+9292+#[async_trait::async_trait]
9393+impl SpaceDeclarationResolver for NetworkSpaceDeclarationResolver {
9494+ async fn resolve(&self, nsid: &str) -> Option<SpaceDeclaration> {
9595+ let dns_name = nsid_to_lexicon_dns_name(nsid)?;
9696+ let did = lexicon_authority_did(self.dns_resolver.as_ref(), &dns_name).await?;
9797+9898+ let resolver = atproto_identity::resolve::InnerIdentityResolver {
9999+ dns_resolver: self.dns_resolver.clone(),
100100+ http_client: self.http_client.clone(),
101101+ plc_hostname: self.plc_hostname.clone(),
102102+ };
103103+ let document = resolver.resolve(&did).await.ok()?;
104104+ let pds = document
105105+ .service
106106+ .iter()
107107+ .find(|s| s.r#type == "AtprotoPersonalDataServer")
108108+ .map(|s| s.service_endpoint.clone())?;
109109+110110+ let url = format!(
111111+ "{}/xrpc/com.atproto.repo.getRecord",
112112+ pds.trim_end_matches('/')
113113+ );
114114+ let resp = self
115115+ .http_client
116116+ .get(&url)
117117+ .query(&[
118118+ ("repo", did.as_str()),
119119+ ("collection", "com.atproto.lexicon.schema"),
120120+ ("rkey", nsid),
121121+ ])
122122+ .send()
123123+ .await
124124+ .ok()?;
125125+ if !resp.status().is_success() {
126126+ return None;
127127+ }
128128+ let body: serde_json::Value = resp.json().await.ok()?;
129129+ // The lexicon doc lives under `value`; parse it and read `defs.main`.
130130+ let value = body.get("value")?;
131131+ parse_space_declaration(value)
132132+ }
133133+}
134134+135135+/// Parse a `com.atproto.lexicon.schema` record `value` into a
136136+/// [`SpaceDeclaration`] when its `defs.main` is a `space` declaration.
137137+///
138138+/// Returns `None` when the value is not a valid lexicon document or its main
139139+/// definition is not a space declaration.
140140+fn parse_space_declaration(value: &serde_json::Value) -> Option<SpaceDeclaration> {
141141+ let file = SchemaFile::from_value(value.clone()).ok()?;
142142+ match file.main()? {
143143+ SchemaDef::Space(space) => Some(SpaceDeclaration {
144144+ name: space.name.clone(),
145145+ key: space.key.clone(),
146146+ collections: space.collections.clone(),
147147+ }),
148148+ _ => None,
149149+ }
150150+}
151151+152152+/// Convert a lexicon NSID to its DNS authority name
153153+/// (`_lexicon.<name>.<reversed-authority>`), per the AT Protocol lexicon
154154+/// resolution scheme. Returns `None` for NSIDs with fewer than three segments.
155155+fn nsid_to_lexicon_dns_name(nsid: &str) -> Option<String> {
156156+ let parts: Vec<&str> = nsid.split('.').collect();
157157+ if parts.len() < 3 {
158158+ return None;
159159+ }
160160+ let name_idx = parts.len() - 2;
161161+ let mut dns_parts = vec!["_lexicon".to_string(), parts[name_idx].to_string()];
162162+ for i in (0..name_idx).rev() {
163163+ dns_parts.push(parts[i].to_string());
164164+ }
165165+ Some(dns_parts.join("."))
166166+}
167167+168168+/// Look up the single authority DID published in the `_lexicon.` TXT record(s)
169169+/// for `dns_name`. Returns `None` when no record, no `did=`/`did:` prefix, or
170170+/// more than one distinct DID is found.
171171+async fn lexicon_authority_did(
172172+ dns_resolver: &dyn atproto_identity::traits::DnsResolver,
173173+ dns_name: &str,
174174+) -> Option<String> {
175175+ let records = dns_resolver.resolve_txt(dns_name).await.ok()?;
176176+ let mut dids: Vec<String> = records
177177+ .iter()
178178+ .filter_map(|r| {
179179+ r.strip_prefix("did=")
180180+ .map(|d| d.to_string())
181181+ .or_else(|| r.starts_with("did:").then(|| r.to_string()))
182182+ })
183183+ .collect();
184184+ dids.dedup();
185185+ if dids.len() == 1 { dids.pop() } else { None }
186186+}
187187+188188+/// One cache slot: the resolution result (positive or fail-closed `None`) and
189189+/// when it was stored.
190190+#[derive(Clone)]
191191+struct CacheEntry {
192192+ stored_at: Instant,
193193+ result: Option<SpaceDeclaration>,
194194+}
195195+196196+/// TTL cache wrapper over any [`SpaceDeclarationResolver`].
197197+///
198198+/// Caches both successful resolutions and fail-closed `None` results (negative
199199+/// caching) for `ttl`, so a hot authorization path does not re-resolve — or
200200+/// re-fail — on every request. The cache is unbounded; the space-type NSID
201201+/// keyspace is small and operator-controlled in practice.
202202+pub struct CachingSpaceDeclarationResolver {
203203+ inner: Arc<dyn SpaceDeclarationResolver>,
204204+ ttl: Duration,
205205+ cache: Mutex<HashMap<String, CacheEntry>>,
206206+}
207207+208208+impl CachingSpaceDeclarationResolver {
209209+ /// Wrap `inner` with a TTL cache.
210210+ #[must_use]
211211+ pub fn new(inner: Arc<dyn SpaceDeclarationResolver>, ttl: Duration) -> Self {
212212+ Self {
213213+ inner,
214214+ ttl,
215215+ cache: Mutex::new(HashMap::new()),
216216+ }
217217+ }
218218+219219+ /// Return a cached, non-expired result for `nsid`, if any.
220220+ fn cached(&self, nsid: &str) -> Option<Option<SpaceDeclaration>> {
221221+ let guard = self.cache.lock().ok()?;
222222+ let entry = guard.get(nsid)?;
223223+ if entry.stored_at.elapsed() < self.ttl {
224224+ Some(entry.result.clone())
225225+ } else {
226226+ None
227227+ }
228228+ }
229229+230230+ /// Store a resolution result for `nsid`.
231231+ fn store(&self, nsid: &str, result: Option<SpaceDeclaration>) {
232232+ if let Ok(mut guard) = self.cache.lock() {
233233+ guard.insert(
234234+ nsid.to_string(),
235235+ CacheEntry {
236236+ stored_at: Instant::now(),
237237+ result,
238238+ },
239239+ );
240240+ }
241241+ }
242242+}
243243+244244+#[async_trait::async_trait]
245245+impl SpaceDeclarationResolver for CachingSpaceDeclarationResolver {
246246+ async fn resolve(&self, nsid: &str) -> Option<SpaceDeclaration> {
247247+ if let Some(hit) = self.cached(nsid) {
248248+ return hit;
249249+ }
250250+ let result = self.inner.resolve(nsid).await;
251251+ self.store(nsid, result.clone());
252252+ result
253253+ }
254254+}
255255+256256+/// In-memory stub resolver for tests. Maps NSID → [`SpaceDeclaration`]
257257+/// directly, with no network access.
258258+#[cfg(test)]
259259+pub struct StubSpaceDeclarationResolver {
260260+ map: HashMap<String, SpaceDeclaration>,
261261+}
262262+263263+#[cfg(test)]
264264+impl StubSpaceDeclarationResolver {
265265+ /// Build a stub from an NSID → declaration map.
266266+ pub fn new(map: HashMap<String, SpaceDeclaration>) -> Self {
267267+ Self { map }
268268+ }
269269+}
270270+271271+#[cfg(test)]
272272+#[async_trait::async_trait]
273273+impl SpaceDeclarationResolver for StubSpaceDeclarationResolver {
274274+ async fn resolve(&self, nsid: &str) -> Option<SpaceDeclaration> {
275275+ self.map.get(nsid).cloned()
276276+ }
277277+}
278278+279279+#[cfg(test)]
280280+mod tests {
281281+ use super::*;
282282+ use std::sync::atomic::{AtomicUsize, Ordering};
283283+284284+ #[test]
285285+ fn nsid_to_lexicon_dns_name_reverses_authority() {
286286+ assert_eq!(
287287+ nsid_to_lexicon_dns_name("com.atmoboards.forum").as_deref(),
288288+ Some("_lexicon.atmoboards.com")
289289+ );
290290+ assert_eq!(
291291+ nsid_to_lexicon_dns_name("app.bsky.feed.post").as_deref(),
292292+ Some("_lexicon.feed.bsky.app")
293293+ );
294294+ assert_eq!(nsid_to_lexicon_dns_name("too.short").as_deref(), None);
295295+ }
296296+297297+ #[test]
298298+ fn parse_space_declaration_reads_collections() {
299299+ let value = serde_json::json!({
300300+ "lexicon": 1,
301301+ "id": "com.atmoboards.forum",
302302+ "defs": {
303303+ "main": {
304304+ "type": "space",
305305+ "key": "any",
306306+ "name": "AtmoBoards Forum",
307307+ "collections": ["com.atmoboards.thread", "com.atmoboards.reply"]
308308+ }
309309+ }
310310+ });
311311+ let decl = parse_space_declaration(&value).expect("space declaration");
312312+ assert_eq!(decl.name, "AtmoBoards Forum");
313313+ assert_eq!(decl.key, "any");
314314+ assert_eq!(
315315+ decl.collections,
316316+ vec![
317317+ "com.atmoboards.thread".to_string(),
318318+ "com.atmoboards.reply".to_string()
319319+ ]
320320+ );
321321+ }
322322+323323+ #[test]
324324+ fn parse_space_declaration_rejects_non_space_main() {
325325+ let value = serde_json::json!({
326326+ "lexicon": 1,
327327+ "id": "com.example.thing",
328328+ "defs": { "main": { "type": "record", "key": "tid", "record": {"type": "object"} } }
329329+ });
330330+ assert!(parse_space_declaration(&value).is_none());
331331+ }
332332+333333+ /// Counts inner resolutions so the cache's hit/miss behavior is observable.
334334+ struct CountingResolver {
335335+ calls: AtomicUsize,
336336+ decl: SpaceDeclaration,
337337+ }
338338+339339+ #[async_trait::async_trait]
340340+ impl SpaceDeclarationResolver for CountingResolver {
341341+ async fn resolve(&self, _nsid: &str) -> Option<SpaceDeclaration> {
342342+ self.calls.fetch_add(1, Ordering::SeqCst);
343343+ Some(self.decl.clone())
344344+ }
345345+ }
346346+347347+ #[tokio::test]
348348+ async fn caching_resolver_serves_within_ttl_and_refetches_after() {
349349+ let inner = Arc::new(CountingResolver {
350350+ calls: AtomicUsize::new(0),
351351+ decl: SpaceDeclaration {
352352+ name: "Forum".to_string(),
353353+ key: "any".to_string(),
354354+ collections: vec!["com.atmoboards.thread".to_string()],
355355+ },
356356+ });
357357+ let resolver =
358358+ CachingSpaceDeclarationResolver::new(inner.clone(), Duration::from_millis(40));
359359+360360+ // First call resolves; second is served from cache.
361361+ let a = resolver.resolve("com.atmoboards.forum").await;
362362+ let b = resolver.resolve("com.atmoboards.forum").await;
363363+ assert_eq!(a, b);
364364+ assert_eq!(inner.calls.load(Ordering::SeqCst), 1);
365365+366366+ // After the TTL elapses the entry expires and we re-resolve.
367367+ tokio::time::sleep(Duration::from_millis(60)).await;
368368+ let _ = resolver.resolve("com.atmoboards.forum").await;
369369+ assert_eq!(inner.calls.load(Ordering::SeqCst), 2);
370370+ }
371371+372372+ #[tokio::test]
373373+ async fn stub_resolver_returns_declared_collections() {
374374+ let mut map = HashMap::new();
375375+ map.insert(
376376+ "com.atmoboards.forum".to_string(),
377377+ SpaceDeclaration {
378378+ name: "Forum".to_string(),
379379+ key: "any".to_string(),
380380+ collections: vec![
381381+ "com.atmoboards.thread".to_string(),
382382+ "com.atmoboards.reply".to_string(),
383383+ ],
384384+ },
385385+ );
386386+ let resolver = StubSpaceDeclarationResolver::new(map);
387387+ let decl = resolver.resolve("com.atmoboards.forum").await.unwrap();
388388+ assert_eq!(decl.collections.len(), 2);
389389+ assert!(resolver.resolve("com.unknown.type").await.is_none());
390390+ }
391391+}
+45-216
crates/atproto-pds/src/space/inbound.rs
···11-//! Inbound `notifyWrite` / `notifyMembership` receipt + verification.
11+//! Inbound `notifyWrite` receipt.
22//!
33-//! When a remote peer pushes a `notifyWrite` or `notifyMembership` POST
44-//! to this PDS, we:
33+//! `notifyWrite` is contentless `{ space, repo, rev }` (`application/json`)
44+//! authenticated by service auth at the HTTP layer; this module simply records
55+//! a lightweight receipt for dedup + audit. There is no commit to verify in the
66+//! payload — consumers PULL the actual ops via `listRepoOps`.
57//!
66-//! 1. Decode the DAG-CBOR body into the payload shape
77-//! (`crate::space::notify::NotifyWritePayload` / `NotifyMembershipPayload`).
88-//! 2. Resolve the issuer's atproto signing key from their DID document
99-//! via `atproto-identity::plc::query` or `atproto-identity::web::query`.
1010-//! 3. Verify the embedded `Commit` (HMAC + ECDSA) using
1111-//! `atproto_space::commit::verify_commit`.
1212-//! 4. INSERT-OR-IGNORE into the local per-actor `space_received_op`
1313-//! table — the row is keyed on `(space, rev, nsid)` so re-delivery of
1414-//! the same op is idempotent.
88+//! The 0016 Permissioned Data draft has no membership-notification flow, so
99+//! there is no inbound member-commit verification here.
1510//!
1616-//! Verification failure → 401 / 403; replay (already-seen `(space, rev)`)
1717-//! → 200 (idempotent ack).
1818-//!
1919-//! Today the module ships the security-critical verification + audit
2020-//! trail; "apply received op into a local read-only mirror" is a
2121-//! future enhancement that hooks the same dispatch path.
1111+//! Replay (already-seen `(space, rev)`) → 200 (idempotent ack).
22122313use crate::actor_store::sql::SqlActorStore;
2414use crate::errors::{PdsError, PdsResult};
2525-use crate::space::notify::{NotifyMembershipPayload, NotifyWritePayload};
2626-use atproto_identity::key::{KeyData, identify_key};
2727-use atproto_identity::model::VerificationMethod;
2828-use atproto_space::commit::{CommitScope, SpaceContext, verify_commit};
1515+use crate::space::notify::NotifyWritePayload;
2916use atproto_space::types::SpaceUri;
30173131-/// Decode + verify + persist an inbound `notifyWrite`. Returns Ok(()) on
3232-/// the happy path including the dedup-already-seen path.
1818+/// Decode + persist an inbound contentless `notifyWrite` `{ space, repo, rev }`.
1919+///
2020+/// The payload carries no commit (the write itself is not replicated through
2121+/// the notification — consumers PULL ops via `listRepoOps`), so there is
2222+/// nothing cryptographic to verify here. Authentication is enforced by the
2323+/// service-auth bearer at the HTTP handler before this is called. The receipt
2424+/// is keyed `(space, rev, nsid)` so re-delivery is idempotent.
3325pub async fn receive_write(
3434- http: &reqwest::Client,
3535- plc_directory_hostname: Option<&str>,
2626+ _http: &reqwest::Client,
2727+ _plc_directory_hostname: Option<&str>,
3628 data_dir: &std::path::Path,
3729 recipient_did: &str,
3830 body: &[u8],
3931) -> PdsResult<()> {
4032 let payload: NotifyWritePayload =
4141- atproto_dasl::from_slice(body).map_err(|e| PdsError::Storage {
3333+ serde_json::from_slice(body).map_err(|e| PdsError::Storage {
4234 reason: format!("decode notifyWrite payload: {e}"),
4335 })?;
4436 let space = SpaceUri::parse(&payload.space).map_err(PdsError::Space)?;
4545- verify_with_owner_key(
4646- http,
4747- plc_directory_hostname,
4848- &space.owner_did,
4949- &SpaceContext {
5050- space_did: space.owner_did.clone(),
5151- space_type: space.space_type.to_string(),
5252- space_key: space.space_key.to_string(),
5353- user_did: payload.member.clone(),
5454- scope: CommitScope::Records,
5555- rev: payload.commit.rev.clone(),
5656- },
5757- &payload.commit,
5858- )
5959- .await?;
6037 persist_receipt(
6138 data_dir,
6239 recipient_did,
6340 &space,
6464- &payload.commit.rev,
4141+ &payload.rev,
6542 "notifyWrite",
6666- &payload.member,
6767- &payload.commit.set_hash,
4343+ &payload.repo,
4444+ &[],
6845 )
6946 .await
7047}
71487272-/// Decode + verify + persist an inbound `notifyMembership`.
7373-pub async fn receive_membership(
7474- http: &reqwest::Client,
7575- plc_directory_hostname: Option<&str>,
7676- data_dir: &std::path::Path,
7777- recipient_did: &str,
7878- body: &[u8],
7979-) -> PdsResult<()> {
8080- let payload: NotifyMembershipPayload =
8181- atproto_dasl::from_slice(body).map_err(|e| PdsError::Storage {
8282- reason: format!("decode notifyMembership payload: {e}"),
8383- })?;
8484- let space = SpaceUri::parse(&payload.space).map_err(PdsError::Space)?;
8585- verify_with_owner_key(
8686- http,
8787- plc_directory_hostname,
8888- &space.owner_did,
8989- &SpaceContext {
9090- space_did: space.owner_did.clone(),
9191- space_type: space.space_type.to_string(),
9292- space_key: space.space_key.to_string(),
9393- user_did: space.owner_did.clone(),
9494- scope: CommitScope::Members,
9595- rev: payload.commit.rev.clone(),
9696- },
9797- &payload.commit,
9898- )
9999- .await?;
100100- persist_receipt(
101101- data_dir,
102102- recipient_did,
103103- &space,
104104- &payload.commit.rev,
105105- "notifyMembership",
106106- &payload.member,
107107- &payload.commit.set_hash,
108108- )
109109- .await
110110-}
111111-112112-/// Resolve the owner's atproto signing key from their DID document and
113113-/// run [`verify_commit`] against the supplied context + commit.
114114-async fn verify_with_owner_key(
115115- http: &reqwest::Client,
116116- plc_directory_hostname: Option<&str>,
117117- owner_did: &str,
118118- context: &SpaceContext,
119119- commit: &atproto_space::commit::Commit,
120120-) -> PdsResult<()> {
121121- let key = atproto_signing_key(http, owner_did, plc_directory_hostname)
122122- .await
123123- .map_err(|e| PdsError::Storage {
124124- reason: format!("resolve owner signing key: {e}"),
125125- })?;
126126- verify_commit(context, commit, &key).map_err(PdsError::Space)?;
127127- Ok(())
128128-}
129129-130130-/// Resolve a DID's atproto signing key (the `#atproto` Multikey
131131-/// verification method) via the configured DID method.
132132-async fn atproto_signing_key(
133133- http: &reqwest::Client,
134134- did: &str,
135135- plc_directory_hostname: Option<&str>,
136136-) -> anyhow::Result<KeyData> {
137137- use atproto_identity::plc::query as plc_query;
138138- use atproto_identity::web::query as web_query;
139139- let document = if did.starts_with("did:plc:") {
140140- let host = plc_directory_hostname.unwrap_or("plc.directory");
141141- plc_query(http, host, did).await?
142142- } else if did.starts_with("did:web:") {
143143- web_query(http, did).await?
144144- } else {
145145- anyhow::bail!("unsupported DID method for inbound notify verification: {did}");
146146- };
147147- for method in &document.verification_method {
148148- if let VerificationMethod::Multikey {
149149- id,
150150- public_key_multibase,
151151- ..
152152- } = method
153153- && id.ends_with("#atproto")
154154- {
155155- let did_key = if public_key_multibase.starts_with("did:key:") {
156156- public_key_multibase.clone()
157157- } else {
158158- format!("did:key:{}", public_key_multibase)
159159- };
160160- return Ok(identify_key(&did_key)?);
161161- }
162162- }
163163- anyhow::bail!("DID document for {did} has no #atproto Multikey verification method")
164164-}
165165-16649async fn persist_receipt(
16750 data_dir: &std::path::Path,
16851 recipient_did: &str,
···19679#[cfg(test)]
19780mod tests {
19881 use super::*;
199199- use crate::space::notify::NotifyOp;
200200- use atproto_identity::key::{KeyType, generate_key, to_public};
201201- use atproto_space::commit::create_commit;
202202- use atproto_space::set_hash::SetHash;
20382 use atproto_space::types::{SpaceKey, SpaceType};
2048320584 fn test_space() -> SpaceUri {
···21089 )
21190 }
21291213213- /// Round-trip: build a real signed commit, decode + verify it.
214214- /// Skips the DID-doc fetch by calling `verify_commit` directly.
215215- #[tokio::test(flavor = "multi_thread")]
216216- async fn verify_commit_round_trip() {
217217- let owner_priv = generate_key(KeyType::P256Private).unwrap();
218218- let owner_pub = to_public(&owner_priv).unwrap();
219219- let space = test_space();
220220- let mut sh = atproto_space::set_hash::XorSha256SetHash::empty();
221221- sh.add(b"hello");
222222- let context = SpaceContext {
223223- space_did: space.owner_did.clone(),
224224- space_type: space.space_type.to_string(),
225225- space_key: space.space_key.to_string(),
226226- user_did: "did:plc:alice".to_string(),
227227- scope: CommitScope::Records,
228228- rev: "3kmev1".to_string(),
229229- };
230230- let commit = create_commit(&sh, &context, &owner_priv).unwrap();
231231- verify_commit(&context, &commit, &owner_pub).unwrap();
232232- }
233233-234234- /// A tampered commit body must fail verification.
235235- #[tokio::test(flavor = "multi_thread")]
236236- async fn tampered_commit_rejected() {
237237- let owner_priv = generate_key(KeyType::P256Private).unwrap();
238238- let owner_pub = to_public(&owner_priv).unwrap();
239239- let space = test_space();
240240- let mut sh = atproto_space::set_hash::XorSha256SetHash::empty();
241241- sh.add(b"orig");
242242- let context = SpaceContext {
243243- space_did: space.owner_did.clone(),
244244- space_type: space.space_type.to_string(),
245245- space_key: space.space_key.to_string(),
246246- user_did: "did:plc:alice".to_string(),
247247- scope: CommitScope::Records,
248248- rev: "3kmev1".to_string(),
249249- };
250250- let mut commit = create_commit(&sh, &context, &owner_priv).unwrap();
251251- // Tamper with the set_hash so the HMAC tag no longer matches.
252252- commit.set_hash[0] ^= 0xFF;
253253- let result = verify_commit(&context, &commit, &owner_pub);
254254- assert!(result.is_err());
255255- }
256256-25792 /// `persist_receipt` is idempotent on `(space, rev, nsid)`.
25893 #[tokio::test(flavor = "multi_thread")]
25994 async fn persist_is_idempotent() {
···282117 assert_eq!(count.0, 1);
283118 }
284119285285- /// Serialize a NotifyWritePayload + decode through receive_write's body
286286- /// codec to confirm the wire format round-trips.
120120+ /// The contentless `notifyWrite` payload round-trips through JSON and is
121121+ /// persisted as a receipt keyed `(space, rev, nsid)`.
287122 #[tokio::test(flavor = "multi_thread")]
288288- async fn payload_codec_round_trip() {
289289- let owner_priv = generate_key(KeyType::P256Private).unwrap();
123123+ async fn notify_write_receipt_round_trip() {
124124+ let tmp = tempfile::TempDir::new().unwrap();
125125+ let dir = tmp.path().to_path_buf();
290126 let space = test_space();
291291- let mut sh = atproto_space::set_hash::XorSha256SetHash::empty();
292292- sh.add(b"k");
293293- let context = SpaceContext {
294294- space_did: space.owner_did.clone(),
295295- space_type: space.space_type.to_string(),
296296- space_key: space.space_key.to_string(),
297297- user_did: "did:plc:alice".to_string(),
298298- scope: CommitScope::Records,
299299- rev: "3kmev1".to_string(),
300300- };
301301- let commit = create_commit(&sh, &context, &owner_priv).unwrap();
302127 let payload = NotifyWritePayload {
303128 space: space.to_string(),
304304- member: "did:plc:alice".to_string(),
305305- commit: commit.clone(),
306306- ops: vec![NotifyOp {
307307- action: "create".to_string(),
308308- collection: "c".to_string(),
309309- rkey: "k".to_string(),
310310- cid: Some("bafy...".to_string()),
311311- value: Some(b"v".to_vec()),
312312- }],
129129+ repo: "did:plc:alice".to_string(),
130130+ rev: "3kmev1".to_string(),
313131 };
314314- let body = atproto_dasl::to_vec(&payload).unwrap();
315315- let decoded: NotifyWritePayload = atproto_dasl::from_slice(&body).unwrap();
316316- assert_eq!(decoded.space, payload.space);
317317- assert_eq!(decoded.member, payload.member);
318318- assert_eq!(decoded.commit.rev, commit.rev);
132132+ let body = serde_json::to_vec(&payload).unwrap();
133133+ let http = reqwest::Client::new();
134134+ receive_write(&http, None, &dir, "did:plc:peer", &body)
135135+ .await
136136+ .unwrap();
137137+138138+ let store = SqlActorStore::open(&dir, "did:plc:peer").await.unwrap();
139139+ let row: (String, String, String) =
140140+ sqlx::query_as("SELECT rev, nsid, issuer_did FROM space_received_op WHERE space = ?")
141141+ .bind(space.to_string())
142142+ .fetch_one(store.pool())
143143+ .await
144144+ .unwrap();
145145+ assert_eq!(row.0, "3kmev1");
146146+ assert_eq!(row.1, "notifyWrite");
147147+ assert_eq!(row.2, "did:plc:alice");
319148 }
320149}
+643
crates/atproto-pds/src/space/mint_authz.rs
···11+//! Mint-time authorization for `com.atproto.space.getSpaceCredential`.
22+//!
33+//! A space credential is minted only when **both** axes of the space's
44+//! `com.atproto.simplespace.defs#spaceConfig` authorize the request:
55+//!
66+//! - **USER axis** ([`MintPolicy`]): `public` authorizes anyone; `member-list`
77+//! (the default) requires the requesting member DID to be in the space's
88+//! member list; `managing-app` delegates the decision to the configured
99+//! `managingApp` via [`com.atproto.simplespace.checkUserAccess`].
1010+//! - **APP axis** ([`AppAccess`]): `#open` authorizes any app; `#allowList`
1111+//! requires the *attested* OAuth `client_id` to be in the `allowed` list.
1212+//!
1313+//! The attested `client_id` is established by verifying the optional
1414+//! `clientAttestation` JWT presented on the request: its `iss`/`sub` is the
1515+//! client-metadata URL, which is fetched, its JWKS resolved, and the key
1616+//! selected by `kid` to verify the JWT signature. When `appAccess` is
1717+//! `#allowList` and no valid attestation is presented the mint is refused with
1818+//! [`MintDenial::AppNotAuthorized`]; under `#open` an attestation is optional.
1919+//!
2020+//! The pure decision helpers ([`user_axis_local`] and [`app_axis`]) carry the
2121+//! decision matrix and are unit-tested directly; the async helpers
2222+//! ([`verify_client_attestation`], [`check_user_access`]) perform the network
2323+//! I/O for the managing-app and attestation paths.
2424+2525+use crate::space::config::{AppAccess, MintPolicy};
2626+use atproto_identity::key::{KeyData, validate as identity_validate};
2727+use atproto_oauth::jwk::WrappedJsonWebKeySet;
2828+use atproto_space::types::SpaceUri;
2929+use base64::Engine as _;
3030+use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
3131+use serde::Deserialize;
3232+use std::time::{SystemTime, UNIX_EPOCH};
3333+3434+/// Why a credential mint was refused. Each variant maps to one of the
3535+/// documented `getSpaceCredential` lexicon errors.
3636+#[derive(Debug, Clone, PartialEq, Eq)]
3737+pub enum MintDenial {
3838+ /// `UserNotAuthorized` — refused on the basis of the requesting user
3939+ /// (`member-list` miss, or `managing-app` returned `authorized=false`).
4040+ UserNotAuthorized {
4141+ /// Human-readable reason for diagnostics.
4242+ reason: String,
4343+ },
4444+ /// `AppNotAuthorized` — refused on the basis of the requesting app
4545+ /// (`#allowList` miss, or no/invalid attestation under `#allowList`).
4646+ AppNotAuthorized {
4747+ /// Human-readable reason for diagnostics.
4848+ reason: String,
4949+ },
5050+ /// `InvalidClientAttestation` — a `clientAttestation` was presented but
5151+ /// could not be verified.
5252+ InvalidClientAttestation {
5353+ /// Human-readable reason for diagnostics.
5454+ reason: String,
5555+ },
5656+ /// `NotAuthorized` — refused for a reason not attributable to a single
5757+ /// axis (e.g. the managing-app could not be reached).
5858+ NotAuthorized {
5959+ /// Human-readable reason for diagnostics.
6060+ reason: String,
6161+ },
6262+}
6363+6464+impl MintDenial {
6565+ /// The lexicon error name to surface to clients.
6666+ #[must_use]
6767+ pub fn error_name(&self) -> &'static str {
6868+ match self {
6969+ Self::UserNotAuthorized { .. } => "UserNotAuthorized",
7070+ Self::AppNotAuthorized { .. } => "AppNotAuthorized",
7171+ Self::InvalidClientAttestation { .. } => "InvalidClientAttestation",
7272+ Self::NotAuthorized { .. } => "NotAuthorized",
7373+ }
7474+ }
7575+7676+ /// The diagnostic reason carried alongside the error name.
7777+ #[must_use]
7878+ pub fn reason(&self) -> &str {
7979+ match self {
8080+ Self::UserNotAuthorized { reason }
8181+ | Self::AppNotAuthorized { reason }
8282+ | Self::InvalidClientAttestation { reason }
8383+ | Self::NotAuthorized { reason } => reason,
8484+ }
8585+ }
8686+}
8787+8888+/// USER-axis decision for the locally-evaluable policies (`public` and
8989+/// `member-list`). The `managing-app` policy is *not* decided here — it
9090+/// requires a network call ([`check_user_access`]) and is signalled by an
9191+/// `Ok(None)` return so the caller knows to consult the managing app.
9292+///
9393+/// - `public` → authorized (returns `Ok(Some(()))`).
9494+/// - `member-list` → authorized iff `is_member`, else
9595+/// [`MintDenial::UserNotAuthorized`].
9696+/// - `managing-app` → `Ok(None)` (defer to [`check_user_access`]).
9797+///
9898+/// # Errors
9999+/// Returns [`MintDenial::UserNotAuthorized`] when a `member-list` space does
100100+/// not contain the requesting member.
101101+pub fn user_axis_local(policy: MintPolicy, is_member: bool) -> Result<Option<()>, MintDenial> {
102102+ match policy {
103103+ MintPolicy::Public => Ok(Some(())),
104104+ MintPolicy::MemberList => {
105105+ if is_member {
106106+ Ok(Some(()))
107107+ } else {
108108+ Err(MintDenial::UserNotAuthorized {
109109+ reason: "requesting member is not in the space member list".to_string(),
110110+ })
111111+ }
112112+ }
113113+ MintPolicy::ManagingApp => Ok(None),
114114+ }
115115+}
116116+117117+/// APP-axis decision. `attested_client_id` is the *verified* client_id from a
118118+/// validated client attestation, or `None` when no valid attestation was
119119+/// presented.
120120+///
121121+/// - `#open` → authorized regardless of attestation.
122122+/// - `#allowList` → authorized iff a verified `client_id` was presented and is
123123+/// in `allowed`; otherwise [`MintDenial::AppNotAuthorized`].
124124+///
125125+/// # Errors
126126+/// Returns [`MintDenial::AppNotAuthorized`] when an `#allowList` space is not
127127+/// satisfied by the attested client_id.
128128+pub fn app_axis(access: &AppAccess, attested_client_id: Option<&str>) -> Result<(), MintDenial> {
129129+ match access {
130130+ AppAccess::Open => Ok(()),
131131+ AppAccess::AllowList { allowed } => match attested_client_id {
132132+ Some(client_id) if allowed.iter().any(|c| c == client_id) => Ok(()),
133133+ Some(client_id) => Err(MintDenial::AppNotAuthorized {
134134+ reason: format!("attested client_id {client_id} is not on the allow list"),
135135+ }),
136136+ None => Err(MintDenial::AppNotAuthorized {
137137+ reason: "space gates on app identity (#allowList) but no valid client \
138138+ attestation was presented"
139139+ .to_string(),
140140+ }),
141141+ },
142142+ }
143143+}
144144+145145+/// `typ` header value a client attestation MUST carry (spec line 184).
146146+pub const TYP_CLIENT_ATTESTATION: &str = "atproto-client-attestation+jwt";
147147+148148+/// Header of a client-attestation JWT (spec lines 183-187). We read `typ`
149149+/// (to reject token-type confusion) and `kid` (to select the verifying key
150150+/// from the resolved JWKS).
151151+#[derive(Debug, Deserialize)]
152152+struct AttestationHeader {
153153+ /// Token type — MUST be `atproto-client-attestation+jwt`.
154154+ #[serde(default)]
155155+ typ: Option<String>,
156156+ /// Key id used to select the JWK from the client's JWKS.
157157+ kid: Option<String>,
158158+}
159159+160160+/// Payload of a client-attestation JWT (spec lines 188-195). Per the spec,
161161+/// `iss == sub == client_id` (the client-metadata URL) and `aud` identifies
162162+/// the space host. `iat`/`exp`/`jti` provide freshness and replay protection.
163163+#[derive(Debug, Deserialize)]
164164+struct AttestationPayload {
165165+ /// Issuer — the OAuth client_id (a client-metadata URL).
166166+ iss: String,
167167+ /// Subject — must equal `iss`.
168168+ sub: String,
169169+ /// Audience — the space host identifier
170170+ /// (`<spaceDid>#atproto_space_host`).
171171+ #[serde(default)]
172172+ aud: Option<String>,
173173+ /// Issued-at (epoch seconds).
174174+ #[serde(default)]
175175+ iat: Option<u64>,
176176+ /// Expiry (epoch seconds).
177177+ #[serde(default)]
178178+ exp: Option<u64>,
179179+ /// Random nonce for replay protection.
180180+ #[serde(default)]
181181+ jti: Option<String>,
182182+}
183183+184184+/// Minimal view of an OAuth client-metadata document — we only need the
185185+/// inline `jwks` or the `jwks_uri` to locate verifying keys.
186186+#[derive(Deserialize)]
187187+struct ClientMetadata {
188188+ #[serde(default)]
189189+ jwks: Option<WrappedJsonWebKeySet>,
190190+ #[serde(default)]
191191+ jwks_uri: Option<String>,
192192+}
193193+194194+/// The audience a client attestation (and delegation token) must target: the
195195+/// space host service fragment derived from the space DID
196196+/// (`<spaceDid>#atproto_space_host`, spec line 166/191).
197197+#[must_use]
198198+pub fn space_host_audience(space_did: &str) -> String {
199199+ atproto_space::credential::space_host_audience(space_did)
200200+}
201201+202202+/// Maximum accepted lifetime of a client attestation. The spec calls the
203203+/// attestation "short-lived" (example `exp = iat + 60`); we reject any
204204+/// attestation whose stated lifetime exceeds five minutes to bound the replay
205205+/// window even before the `jti` guard records it.
206206+const MAX_ATTESTATION_LIFETIME_SECS: u64 = 300;
207207+208208+fn now_secs() -> u64 {
209209+ SystemTime::now()
210210+ .duration_since(UNIX_EPOCH)
211211+ .unwrap_or_default()
212212+ .as_secs()
213213+}
214214+215215+/// Verify a client-attestation compact JWT and return the verified
216216+/// `client_id` (its `iss`/`sub`).
217217+///
218218+/// Checks the header `typ` is `atproto-client-attestation+jwt`, that
219219+/// `iss == sub == client_id` (an HTTPS client-metadata URL), that `aud`
220220+/// targets this space host, that `iat`/`exp` are present and the attestation
221221+/// is unexpired and not over-long-lived, and that `jti` is present and unseen
222222+/// (consumed via `jti_guard` for replay protection). It then resolves the
223223+/// issuer's client-metadata document → JWKS → key by `kid` and verifies the
224224+/// JWS signature.
225225+///
226226+/// # Errors
227227+/// Returns [`MintDenial::InvalidClientAttestation`] for any malformed,
228228+/// unresolvable, mis-targeted, replayed, or signature-invalid attestation.
229229+pub async fn verify_client_attestation(
230230+ http: &reqwest::Client,
231231+ jti_guard: &crate::security::JtiReplayGuard,
232232+ attestation: &str,
233233+ space: &SpaceUri,
234234+) -> Result<String, MintDenial> {
235235+ let invalid = |reason: String| MintDenial::InvalidClientAttestation { reason };
236236+237237+ let parts: Vec<&str> = attestation.split('.').collect();
238238+ if parts.len() != 3 {
239239+ return Err(invalid("attestation is not a compact JWS".to_string()));
240240+ }
241241+ let header_bytes = B64URL
242242+ .decode(parts[0].as_bytes())
243243+ .map_err(|e| invalid(format!("header not base64url: {e}")))?;
244244+ let header: AttestationHeader = serde_json::from_slice(&header_bytes)
245245+ .map_err(|e| invalid(format!("header not JSON: {e}")))?;
246246+ let payload_bytes = B64URL
247247+ .decode(parts[1].as_bytes())
248248+ .map_err(|e| invalid(format!("payload not base64url: {e}")))?;
249249+ let payload: AttestationPayload = serde_json::from_slice(&payload_bytes)
250250+ .map_err(|e| invalid(format!("payload not JSON: {e}")))?;
251251+252252+ // typ header MUST be the client-attestation token type.
253253+ match header.typ.as_deref() {
254254+ Some(TYP_CLIENT_ATTESTATION) => {}
255255+ Some(other) => {
256256+ return Err(invalid(format!(
257257+ "header typ {other} is not {TYP_CLIENT_ATTESTATION}"
258258+ )));
259259+ }
260260+ None => return Err(invalid("attestation header missing typ".to_string())),
261261+ }
262262+263263+ // iss == sub == client_id, both an HTTPS client-metadata URL.
264264+ if payload.iss != payload.sub {
265265+ return Err(invalid("iss != sub".to_string()));
266266+ }
267267+ let client_id = payload.iss.clone();
268268+ if !client_id.starts_with("https://") {
269269+ return Err(invalid(format!(
270270+ "client_id is not an https client-metadata URL: {client_id}"
271271+ )));
272272+ }
273273+274274+ // aud must target this space host.
275275+ let expected_aud = space_host_audience(&space.space_did);
276276+ match payload.aud.as_deref() {
277277+ Some(aud) if aud == expected_aud => {}
278278+ Some(aud) => {
279279+ return Err(invalid(format!(
280280+ "aud {aud} does not target space host {expected_aud}"
281281+ )));
282282+ }
283283+ None => return Err(invalid("attestation missing aud".to_string())),
284284+ }
285285+286286+ // iat and exp are required; the attestation must be unexpired and bounded
287287+ // to a short lifetime (spec lines 192-193).
288288+ let now = now_secs();
289289+ let iat = payload
290290+ .iat
291291+ .ok_or_else(|| invalid("attestation missing iat".to_string()))?;
292292+ let exp = payload
293293+ .exp
294294+ .ok_or_else(|| invalid("attestation missing exp".to_string()))?;
295295+ if exp <= now {
296296+ return Err(invalid("attestation expired".to_string()));
297297+ }
298298+ if exp <= iat || exp - iat > MAX_ATTESTATION_LIFETIME_SECS {
299299+ return Err(invalid(format!(
300300+ "attestation lifetime exceeds {MAX_ATTESTATION_LIFETIME_SECS}s"
301301+ )));
302302+ }
303303+304304+ // jti is required and single-use (spec line 194). Consume it before the
305305+ // signature is checked so a replayed nonce is rejected regardless.
306306+ let jti = payload
307307+ .jti
308308+ .as_deref()
309309+ .ok_or_else(|| invalid("attestation missing jti".to_string()))?;
310310+ let ttl = std::time::Duration::from_secs(exp.saturating_sub(now));
311311+ jti_guard
312312+ .check_and_insert(jti, ttl)
313313+ .await
314314+ .map_err(|_| invalid("attestation jti already used (replay)".to_string()))?;
315315+316316+ // Resolve the client-metadata document.
317317+ let metadata: ClientMetadata = http
318318+ .get(&client_id)
319319+ .header("Accept", "application/json")
320320+ .send()
321321+ .await
322322+ .map_err(|e| invalid(format!("fetch client-metadata {client_id}: {e}")))?
323323+ .error_for_status()
324324+ .map_err(|e| invalid(format!("client-metadata {client_id} status: {e}")))?
325325+ .json()
326326+ .await
327327+ .map_err(|e| invalid(format!("parse client-metadata {client_id}: {e}")))?;
328328+329329+ // Resolve the JWKS (inline `jwks` wins; else fetch `jwks_uri`).
330330+ let jwks: WrappedJsonWebKeySet = match (metadata.jwks, metadata.jwks_uri) {
331331+ (Some(jwks), _) => jwks,
332332+ (None, Some(uri)) => http
333333+ .get(&uri)
334334+ .header("Accept", "application/json")
335335+ .send()
336336+ .await
337337+ .map_err(|e| invalid(format!("fetch jwks_uri {uri}: {e}")))?
338338+ .error_for_status()
339339+ .map_err(|e| invalid(format!("jwks_uri {uri} status: {e}")))?
340340+ .json()
341341+ .await
342342+ .map_err(|e| invalid(format!("parse jwks_uri {uri}: {e}")))?,
343343+ (None, None) => {
344344+ return Err(invalid(
345345+ "client-metadata has neither jwks nor jwks_uri".to_string(),
346346+ ));
347347+ }
348348+ };
349349+350350+ // Select the verifying key by kid (or the sole key when no kid is given).
351351+ let wrapped = match header.kid.as_deref() {
352352+ Some(kid) => jwks
353353+ .keys
354354+ .iter()
355355+ .find(|k| k.kid.as_deref() == Some(kid))
356356+ .ok_or_else(|| invalid(format!("no JWK with kid {kid}")))?,
357357+ None if jwks.keys.len() == 1 => &jwks.keys[0],
358358+ None => {
359359+ return Err(invalid(
360360+ "attestation header has no kid and JWKS has multiple keys".to_string(),
361361+ ));
362362+ }
363363+ };
364364+ let verifying_key: KeyData = wrapped
365365+ .try_into()
366366+ .map_err(|e| invalid(format!("JWK is not a usable verifying key: {e}")))?;
367367+368368+ // Verify the JWS signature over `header.payload`.
369369+ let signing_input = format!("{}.{}", parts[0], parts[1]);
370370+ let sig = B64URL
371371+ .decode(parts[2].as_bytes())
372372+ .map_err(|e| invalid(format!("signature not base64url: {e}")))?;
373373+ identity_validate(&verifying_key, &sig, signing_input.as_bytes())
374374+ .map_err(|e| invalid(format!("attestation signature invalid: {e}")))?;
375375+376376+ Ok(client_id)
377377+}
378378+379379+/// Output of `com.atproto.simplespace.checkUserAccess`.
380380+#[derive(Debug, Deserialize)]
381381+struct CheckUserAccessOutput {
382382+ authorized: bool,
383383+}
384384+385385+/// Ask the space's `managingApp` whether to authorize the requesting user, by
386386+/// calling `com.atproto.simplespace.checkUserAccess` on the managing-app
387387+/// service with a service-auth bearer issued by the space authority
388388+/// (`iss=owner_did`, `aud=managing_app`).
389389+///
390390+/// The `service_endpoint` is the base URL of the managing-app service (already
391391+/// resolved by the caller from the `managingApp` service identifier);
392392+/// `managing_app` is the service identifier used as the JWT audience.
393393+///
394394+/// # Errors
395395+/// Returns [`MintDenial::UserNotAuthorized`] when the managing app answers
396396+/// `authorized=false`, or [`MintDenial::NotAuthorized`] when the call could
397397+/// not be completed (network/sign failure) — the latter is not attributable
398398+/// to a single axis.
399399+#[allow(clippy::too_many_arguments)]
400400+pub async fn check_user_access(
401401+ http: &reqwest::Client,
402402+ service_endpoint: &str,
403403+ managing_app: &str,
404404+ owner_signing_key: &KeyData,
405405+ owner_did: &str,
406406+ space: &SpaceUri,
407407+ user_did: &str,
408408+ attested_client_id: Option<&str>,
409409+) -> Result<(), MintDenial> {
410410+ let token = mint_check_user_access_service_auth(owner_did, managing_app, owner_signing_key)
411411+ .map_err(|reason| MintDenial::NotAuthorized { reason })?;
412412+413413+ let endpoint = format!(
414414+ "{base}/xrpc/com.atproto.simplespace.checkUserAccess",
415415+ base = service_endpoint.trim_end_matches('/')
416416+ );
417417+ let mut query: Vec<(&str, String)> =
418418+ vec![("space", space.to_string()), ("user", user_did.to_string())];
419419+ if let Some(client_id) = attested_client_id {
420420+ query.push(("clientId", client_id.to_string()));
421421+ }
422422+423423+ let response = http
424424+ .get(&endpoint)
425425+ .query(&query)
426426+ .bearer_auth(&token)
427427+ .header("Accept", "application/json")
428428+ .send()
429429+ .await
430430+ .map_err(|e| MintDenial::NotAuthorized {
431431+ reason: format!("checkUserAccess request to {endpoint} failed: {e}"),
432432+ })?
433433+ .error_for_status()
434434+ .map_err(|e| MintDenial::NotAuthorized {
435435+ reason: format!("checkUserAccess {endpoint} returned error status: {e}"),
436436+ })?;
437437+438438+ let output: CheckUserAccessOutput =
439439+ response
440440+ .json()
441441+ .await
442442+ .map_err(|e| MintDenial::NotAuthorized {
443443+ reason: format!("parse checkUserAccess response: {e}"),
444444+ })?;
445445+446446+ if output.authorized {
447447+ Ok(())
448448+ } else {
449449+ Err(MintDenial::UserNotAuthorized {
450450+ reason: "managing app refused (checkUserAccess authorized=false)".to_string(),
451451+ })
452452+ }
453453+}
454454+455455+/// Service-auth JWT header.
456456+#[derive(serde::Serialize)]
457457+struct ServiceAuthHeader {
458458+ alg: String,
459459+ typ: &'static str,
460460+}
461461+462462+/// Service-auth JWT claims, scoped to the `checkUserAccess` lexicon method.
463463+#[derive(serde::Serialize)]
464464+struct ServiceAuthClaims<'a> {
465465+ iss: &'a str,
466466+ aud: &'a str,
467467+ lxm: &'a str,
468468+ iat: u64,
469469+ exp: u64,
470470+ jti: String,
471471+}
472472+473473+/// Mint a 60-second `at+jwt` service-auth token signed by the owner's signing
474474+/// key, scoped to `com.atproto.simplespace.checkUserAccess`.
475475+fn mint_check_user_access_service_auth(
476476+ owner_did: &str,
477477+ managing_app: &str,
478478+ signing_key: &KeyData,
479479+) -> Result<String, String> {
480480+ let iat = now_secs();
481481+ let exp = iat + 60;
482482+ let jti = {
483483+ let mut bytes = [0u8; 16];
484484+ use rand::RngExt;
485485+ rand::rng().fill(&mut bytes);
486486+ B64URL.encode(bytes)
487487+ };
488488+ let header = ServiceAuthHeader {
489489+ alg: atproto_identity::key::jws_alg(signing_key).to_string(),
490490+ typ: "at+jwt",
491491+ };
492492+ let payload = ServiceAuthClaims {
493493+ iss: owner_did,
494494+ aud: managing_app,
495495+ lxm: "com.atproto.simplespace.checkUserAccess",
496496+ iat,
497497+ exp,
498498+ jti,
499499+ };
500500+ let header_bytes =
501501+ serde_json::to_vec(&header).map_err(|e| format!("serialize service-auth header: {e}"))?;
502502+ let payload_bytes =
503503+ serde_json::to_vec(&payload).map_err(|e| format!("serialize service-auth payload: {e}"))?;
504504+ let signing_input = format!(
505505+ "{}.{}",
506506+ B64URL.encode(&header_bytes),
507507+ B64URL.encode(&payload_bytes)
508508+ );
509509+ let sig = atproto_identity::key::sign(signing_key, signing_input.as_bytes())
510510+ .map_err(|e| format!("sign service-auth: {e}"))?;
511511+ Ok(format!("{}.{}", signing_input, B64URL.encode(&sig)))
512512+}
513513+514514+#[cfg(test)]
515515+mod tests {
516516+ use super::*;
517517+518518+ fn allow_list(ids: &[&str]) -> AppAccess {
519519+ AppAccess::AllowList {
520520+ allowed: ids.iter().map(|s| s.to_string()).collect(),
521521+ }
522522+ }
523523+524524+ // --- USER axis ---------------------------------------------------------
525525+526526+ #[test]
527527+ fn user_axis_public_authorizes_anyone() {
528528+ assert_eq!(user_axis_local(MintPolicy::Public, false), Ok(Some(())));
529529+ assert_eq!(user_axis_local(MintPolicy::Public, true), Ok(Some(())));
530530+ }
531531+532532+ #[test]
533533+ fn user_axis_member_list_allows_member() {
534534+ assert_eq!(user_axis_local(MintPolicy::MemberList, true), Ok(Some(())));
535535+ }
536536+537537+ #[test]
538538+ fn user_axis_member_list_denies_non_member() {
539539+ let r = user_axis_local(MintPolicy::MemberList, false);
540540+ assert!(matches!(r, Err(MintDenial::UserNotAuthorized { .. })));
541541+ assert_eq!(r.unwrap_err().error_name(), "UserNotAuthorized");
542542+ }
543543+544544+ #[test]
545545+ fn user_axis_managing_app_defers() {
546546+ assert_eq!(user_axis_local(MintPolicy::ManagingApp, false), Ok(None));
547547+ assert_eq!(user_axis_local(MintPolicy::ManagingApp, true), Ok(None));
548548+ }
549549+550550+ // --- APP axis ----------------------------------------------------------
551551+552552+ #[test]
553553+ fn app_axis_open_allows_with_or_without_attestation() {
554554+ assert_eq!(app_axis(&AppAccess::Open, None), Ok(()));
555555+ assert_eq!(
556556+ app_axis(&AppAccess::Open, Some("https://app.example")),
557557+ Ok(())
558558+ );
559559+ }
560560+561561+ #[test]
562562+ fn app_axis_allow_list_allows_listed_client() {
563563+ let access = allow_list(&["https://a.example", "https://b.example"]);
564564+ assert_eq!(app_axis(&access, Some("https://b.example")), Ok(()));
565565+ }
566566+567567+ #[test]
568568+ fn app_axis_allow_list_denies_unlisted_client() {
569569+ let access = allow_list(&["https://a.example"]);
570570+ let r = app_axis(&access, Some("https://evil.example"));
571571+ assert!(matches!(r, Err(MintDenial::AppNotAuthorized { .. })));
572572+ assert_eq!(r.unwrap_err().error_name(), "AppNotAuthorized");
573573+ }
574574+575575+ #[test]
576576+ fn app_axis_allow_list_denies_when_no_attestation() {
577577+ let access = allow_list(&["https://a.example"]);
578578+ let r = app_axis(&access, None);
579579+ assert!(matches!(r, Err(MintDenial::AppNotAuthorized { .. })));
580580+ }
581581+582582+ // --- combined matrix ---------------------------------------------------
583583+584584+ #[test]
585585+ fn matrix_public_open_allows() {
586586+ assert!(user_axis_local(MintPolicy::Public, false).is_ok());
587587+ assert!(app_axis(&AppAccess::Open, None).is_ok());
588588+ }
589589+590590+ #[test]
591591+ fn matrix_member_list_allow_list_requires_both() {
592592+ // member + listed app -> ok on both axes
593593+ assert!(user_axis_local(MintPolicy::MemberList, true).is_ok());
594594+ let access = allow_list(&["https://app.example"]);
595595+ assert!(app_axis(&access, Some("https://app.example")).is_ok());
596596+597597+ // non-member fails user axis even with a listed app
598598+ assert!(user_axis_local(MintPolicy::MemberList, false).is_err());
599599+600600+ // member but unlisted app fails app axis
601601+ assert!(app_axis(&access, Some("https://other.example")).is_err());
602602+ }
603603+604604+ #[test]
605605+ fn denial_reasons_are_populated() {
606606+ let d = user_axis_local(MintPolicy::MemberList, false).unwrap_err();
607607+ assert!(!d.reason().is_empty());
608608+ }
609609+610610+ #[test]
611611+ fn space_host_audience_format() {
612612+ assert_eq!(
613613+ space_host_audience("did:plc:owner"),
614614+ "did:plc:owner#atproto_space_host"
615615+ );
616616+ }
617617+618618+ #[test]
619619+ fn attestation_rejects_non_compact_jws() {
620620+ // Build a client that will never be used (decode fails first).
621621+ let http = reqwest::Client::new();
622622+ let space = SpaceUri::new(
623623+ "did:plc:owner".to_string(),
624624+ atproto_space::types::SpaceType::new("app.bsky.group").unwrap(),
625625+ atproto_space::types::SpaceKey::new("k").unwrap(),
626626+ );
627627+ let guard = crate::security::JtiReplayGuard::new(16);
628628+ let rt = tokio::runtime::Builder::new_current_thread()
629629+ .enable_all()
630630+ .build()
631631+ .unwrap();
632632+ let r = rt.block_on(verify_client_attestation(
633633+ &http,
634634+ &guard,
635635+ "not-a-jwt",
636636+ &space,
637637+ ));
638638+ assert!(matches!(
639639+ r,
640640+ Err(MintDenial::InvalidClientAttestation { .. })
641641+ ));
642642+ }
643643+}
+14-6
crates/atproto-pds/src/space/mod.rs
···11//! Permissioned-data spaces subsystem — wires `atproto-space` orchestrators
22//! into the per-actor SQLite store and exposes them via XRPC.
33//!
44+//! Components:
45//!
56//! - `SpaceService` — `createSpace`, `getSpace`, `listSpaces`, `addMember`,
66-//! `removeMember`, `getMembers`.
77+//! `removeMember`, `listMembers`.
78//! - `SpaceWriter` — `createRecord` / `putRecord` / `deleteRecord` /
89//! `applyWrites` against a permissioned repo (per-(DID, space-URI) lock).
910//! - `SpaceReader` — dual-auth `getRecord` / `listRecords` (own-PDS OAuth
1011//! or remote SpaceCredential).
1111-//! - `SpaceSync` — `getRepoState`, `getRepoOplog`, `getMemberState`,
1212-//! `getMemberOplog`.
1212+//! - `SpaceSync` — `getRepoState`, `listRepoOps`.
1313//! - Credential mint/verify wired to `atproto-space::credential`.
1414//!
1515-//! The auth-extractor extensions for MemberGrant/SpaceCredential JWTs
1515+//! The auth-extractor extensions for delegation-token/SpaceCredential JWTs
1616//! live in the HTTP layer alongside the app-password sessions.
17171818-pub mod export;
1818+pub mod config;
1919+pub mod declaration;
1920pub mod inbound;
2121+pub mod mint_authz;
2022pub mod notify;
2123pub mod reader;
2224pub mod recipient;
2325pub mod service;
2626+pub mod service_auth;
2427pub mod sync;
2528pub mod writer;
26293030+pub use config::{AppAccess, MintPolicy, SpaceConfig, SpaceConfigPatch};
3131+pub use declaration::{
3232+ CachingSpaceDeclarationResolver, NetworkSpaceDeclarationResolver, SpaceDeclaration,
3333+ SpaceDeclarationResolver,
3434+};
2735pub use reader::SpaceReader;
2828-pub use service::{SpaceInfo, SpaceService};
3636+pub use service::{GetSpaceOutput, SpaceInfo, SpaceService};
2937pub use sync::SpaceSync;
3038pub use writer::{SpaceWriteAction, SpaceWriteOp, SpaceWriter};
+201-165
crates/atproto-pds/src/space/notify.rs
···11//! Spaces notification fan-out — payload definitions + enqueue helpers.
22//!
33-//! and
44-//! after every successful Spaces commit the owner's PDS
55-//! must broadcast to every registered consumer service. This module
33+//! Spaces writes don't fan out over the public firehose. Instead the owner's
44+//! PDS broadcasts to every registered consumer service. This module
65//! encapsulates:
76//!
88-//! - The on-the-wire DAG-CBOR payload shapes for `notifyWrite` (records) and
99-//! `notifyMembership` (member list).
1010-//! - Helpers that look up `space_credential_recipient` rows on the owner's
1111-//! per-actor store and append one `notify_attempt` row per recipient on the
1212-//! shared accounts pool.
77+//! - The on-the-wire payload shape for `notifyWrite` (contentless;
88+//! `{ space, repo, rev }`).
99+//! - Helpers that read/write `space_credential_recipient` subscription rows on
1010+//! the owner's per-actor store and append one `notify_attempt` row per
1111+//! recipient on the shared accounts pool.
1312//!
1413//! The actual delivery happens in [`crate::notifier::Notifier::tick`].
1514//!
1616-//! Recipient registration is owned by [`crate::http::space_handlers`] —
1717-//! [`upsert_recipient`] is the helper called from `getSpaceCredential`.
1515+//! # Notify payloads
1616+//!
1717+//! `notifyWrite` carries **no record data** per the 0016 Permissioned Data
1818+//! draft lexicon `com.atproto.space.notifyWrite`: the body is exactly
1919+//! `{ space, repo, rev }` and is encoded as `application/json`. Consumers PULL
2020+//! the actual ops via `listRepoOps`/`getRepoState`. The 0016 draft has no
2121+//! membership-notification flow.
1822//!
1923//! # Recipient discovery
2024//!
2121-//! (closed in commit `9cbdd7f`) wired DID-document
2222-//! resolution to find each consumer's declared `atproto_pds` service
2323-//! endpoint. The `recipient.rs` resolver picks the path automatically;
2424-//! see for the soft-fail contract on consumers
2525-//! whose DID document doesn't expose a discoverable PDS endpoint.
2525+//! Subscriptions are recorded two ways:
2626+//! - `getSpaceCredential` self-registers the credential consumer for the whole
2727+//! space (`repo` NULL, no expiry).
2828+//! - `registerNotify` records an endpoint subscription, either whole-space
2929+//! (`repo` NULL) or for a single repo (`repo` = that DID), with an
3030+//! `expires_at` registration lifetime.
26312732use crate::actor_store::sql::SqlActorStore;
2833use crate::errors::{PdsError, PdsResult};
2934use crate::notifier::enqueue_notification;
3030-use atproto_space::commit::Commit;
3131-use atproto_space::space_repo::{Op, OpAction};
3535+use crate::space::service_auth::{NOTIFY_SERVICE_AUTH_TTL_SECS, mint_service_auth};
3636+use atproto_identity::key::KeyData;
3237use atproto_space::types::SpaceUri;
3338use chrono::Utc;
3439use serde::{Deserialize, Serialize};
···3742/// NSID for outbound `notifyWrite` POSTs.
3843pub const NOTIFY_WRITE_NSID: &str = "com.atproto.space.notifyWrite";
39444040-/// NSID for outbound `notifyMembership` POSTs.
4141-pub const NOTIFY_MEMBERSHIP_NSID: &str = "com.atproto.space.notifyMembership";
4242-4343-/// One op inside a [`NotifyWritePayload`].
4444-#[derive(Debug, Clone, Serialize, Deserialize)]
4545-pub struct NotifyOp {
4646- /// `"create"` | `"update"` | `"delete"`.
4747- pub action: String,
4848- /// NSID collection.
4949- pub collection: String,
5050- /// Record key.
5151- pub rkey: String,
5252- /// CID of the value (`None` for delete).
5353- #[serde(skip_serializing_if = "Option::is_none")]
5454- pub cid: Option<String>,
5555- /// DAG-CBOR-encoded record value (`None` for delete).
5656- #[serde(skip_serializing_if = "Option::is_none", with = "serde_bytes_opt")]
5757- pub value: Option<Vec<u8>>,
5858-}
5959-6060-/// Wire-shape of `com.atproto.space.notifyWrite` request body (DAG-CBOR).
4545+/// Wire-shape of `com.atproto.space.notifyWrite` request body
4646+/// (`application/json`). Contentless: it announces that `repo` advanced to
4747+/// `rev` within `space`; consumers PULL the ops via `listRepoOps`.
6148#[derive(Debug, Clone, Serialize, Deserialize)]
6249pub struct NotifyWritePayload {
6350 /// Space URI.
6451 pub space: String,
6565- /// DID of the member who wrote.
6666- pub member: String,
6767- /// Signed commit (set_hash, rev, ikm, tag, sig).
6868- pub commit: Commit,
6969- /// Per-op detail.
7070- pub ops: Vec<NotifyOp>,
5252+ /// DID of the account whose repo advanced.
5353+ pub repo: String,
5454+ /// The revision of the write.
5555+ pub rev: String,
7156}
72577373-/// Wire-shape of `com.atproto.space.notifyMembership` request body (DAG-CBOR).
7474-#[derive(Debug, Clone, Serialize, Deserialize)]
7575-pub struct NotifyMembershipPayload {
7676- /// Space URI.
7777- pub space: String,
7878- /// `"add"` | `"remove"`.
7979- pub action: String,
8080- /// DID of the member affected.
8181- pub member: String,
8282- /// Signed commit covering this membership change.
8383- pub commit: Commit,
8484-}
8585-8686-/// Translate a [`crate::space::writer::SpaceWriter`]-style [`Op`] into the
8787-/// notify-payload shape (drops the value bytes for deletes).
8888-#[must_use]
8989-pub fn op_to_notify(op: &Op) -> NotifyOp {
9090- NotifyOp {
9191- action: match op.action {
9292- OpAction::Create => "create".to_string(),
9393- OpAction::Update => "update".to_string(),
9494- OpAction::Delete => "delete".to_string(),
9595- },
9696- collection: op.collection.clone(),
9797- rkey: op.rkey.clone(),
9898- cid: op.cid.clone(),
9999- value: op.value.clone(),
100100- }
101101-}
102102-103103-/// One row from `space_credential_recipient` for a given space.
5858+/// One subscription row from `space_credential_recipient` for a given space.
10459#[derive(Debug, Clone)]
10560pub struct Recipient {
10661 /// Recipient service DID.
···10964 pub service_endpoint: String,
11065}
11166112112-/// Read all recipients for `space` from the owner's per-actor store.
6767+/// Read recipients for `space` from the owner's per-actor store, expanding the
6868+/// per-repo subscription filter: whole-space rows (`repo = ''`) always match;
6969+/// per-repo rows match only when `repo == writer_repo`. Expired rows
7070+/// (`expires_at` in the past) are skipped.
7171+///
7272+/// `writer_repo` is the DID of the account that wrote (for `notifyWrite`
7373+/// fan-out); pass `None` for membership fan-out, which targets whole-space
7474+/// subscribers only.
11375pub async fn list_recipients(
11476 owner_actor_pool: &SqlitePool,
11577 space: &SpaceUri,
7878+ writer_repo: Option<&str>,
11679) -> PdsResult<Vec<Recipient>> {
117117- let rows: Vec<(String, String)> = sqlx::query_as(
118118- "SELECT service_did, service_endpoint
8080+ let now = Utc::now().to_rfc3339();
8181+ let rows: Vec<(String, String, String, Option<String>)> = sqlx::query_as(
8282+ "SELECT service_did, service_endpoint, repo, expires_at
11983 FROM space_credential_recipient
12084 WHERE space = ?",
12185 )
···12791 })?;
12892 Ok(rows
12993 .into_iter()
130130- .map(|(service_did, service_endpoint)| Recipient {
9494+ .filter(|(_, _, repo, expires_at)| {
9595+ // Skip expired registrations.
9696+ if let Some(exp) = expires_at
9797+ && exp.as_str() <= now.as_str()
9898+ {
9999+ return false;
100100+ }
101101+ // Whole-space rows always match; per-repo rows match the writer.
102102+ repo.is_empty() || writer_repo == Some(repo.as_str())
103103+ })
104104+ .map(|(service_did, service_endpoint, _, _)| Recipient {
131105 service_did,
132106 service_endpoint,
133107 })
134108 .collect())
135109}
136110137137-/// INSERT-OR-REPLACE into the owner's per-actor `space_credential_recipient`
138138-/// table. Idempotent on `(space, service_did)` — re-issuing a credential to
139139-/// the same client just bumps `last_issued_at`.
111111+/// INSERT-OR-REPLACE a whole-space subscription into the owner's per-actor
112112+/// `space_credential_recipient` table (the `getSpaceCredential` self-register
113113+/// path). Idempotent on `(space, '', service_did)` — re-issuing a credential to
114114+/// the same client just bumps `last_issued_at`. Leaves `expires_at` unset.
140115pub async fn upsert_recipient(
141116 owner_actor_pool: &SqlitePool,
142117 space: &SpaceUri,
143118 service_did: &str,
144119 service_endpoint: &str,
145120) -> PdsResult<()> {
121121+ upsert_subscription(
122122+ owner_actor_pool,
123123+ space,
124124+ None,
125125+ service_did,
126126+ service_endpoint,
127127+ None,
128128+ )
129129+ .await
130130+}
131131+132132+/// INSERT-OR-REPLACE a subscription, keyed `(space, repo-or-empty,
133133+/// service_did)`. `repo = None` is the whole-space sentinel; `expires_at` is
134134+/// the optional RFC 3339 registration lifetime. Used by both the
135135+/// `getSpaceCredential` self-register path ([`upsert_recipient`]) and
136136+/// `registerNotify`.
137137+pub async fn upsert_subscription(
138138+ owner_actor_pool: &SqlitePool,
139139+ space: &SpaceUri,
140140+ repo: Option<&str>,
141141+ service_did: &str,
142142+ service_endpoint: &str,
143143+ expires_at: Option<&str>,
144144+) -> PdsResult<()> {
146145 let now = Utc::now().to_rfc3339();
147146 sqlx::query(
148147 "INSERT INTO space_credential_recipient
149149- (space, service_did, service_endpoint, last_issued_at)
150150- VALUES (?, ?, ?, ?)
151151- ON CONFLICT(space, service_did) DO UPDATE SET
148148+ (space, repo, service_did, service_endpoint, last_issued_at, expires_at)
149149+ VALUES (?, ?, ?, ?, ?, ?)
150150+ ON CONFLICT(space, repo, service_did) DO UPDATE SET
152151 service_endpoint = excluded.service_endpoint,
153153- last_issued_at = excluded.last_issued_at",
152152+ last_issued_at = excluded.last_issued_at,
153153+ expires_at = excluded.expires_at",
154154 )
155155 .bind(space.to_string())
156156+ .bind(repo.unwrap_or(""))
156157 .bind(service_did)
157158 .bind(service_endpoint)
158159 .bind(&now)
160160+ .bind(expires_at)
159161 .execute(owner_actor_pool)
160162 .await
161163 .map_err(|e| PdsError::Storage {
162162- reason: format!("upsert_recipient: {e}"),
164164+ reason: format!("upsert_subscription: {e}"),
163165 })?;
164166 Ok(())
165167}
166168167167-/// Encode `payload` as DAG-CBOR and enqueue one `notify_attempt` row
168168-/// recipient. Returns the count of rows appended.
169169+/// Encode the contentless `notifyWrite` payload as JSON and enqueue one
170170+/// `notify_attempt` row per matching recipient (HOP 2 fan-out). `writer_repo`
171171+/// filters per-repo subscriptions. Each row carries a fresh service-auth token
172172+/// signed by the owner (`iss = space.space_did`, `aud = recipient service`).
173173+/// Returns the count of rows appended.
169174///
170170-/// Failures here are non-fatal to the caller — e.g. if a recipient lookup
171171-/// returns zero rows, the writer's commit is still durable. The caller
172172-/// passes both pools because the recipient table lives on the owner's
173173-/// per-actor store while the queue lives on the shared accounts pool.
175175+/// Failures here are non-fatal to the caller — if a recipient lookup returns
176176+/// zero rows, the writer's commit is still durable. The caller passes both
177177+/// pools because the subscription table lives on the owner's per-actor store
178178+/// while the queue lives on the shared accounts pool.
174179pub async fn enqueue_writes(
175180 accounts_pool: &SqlitePool,
176181 data_dir: &std::path::Path,
177182 space: &SpaceUri,
178183 payload: &NotifyWritePayload,
179179-) -> PdsResult<u32> {
180180- enqueue_for_space(accounts_pool, data_dir, space, NOTIFY_WRITE_NSID, payload).await
181181-}
182182-183183-/// Encode `payload` as DAG-CBOR and enqueue one `notify_attempt` row
184184-/// recipient — membership flavor.
185185-pub async fn enqueue_membership(
186186- accounts_pool: &SqlitePool,
187187- data_dir: &std::path::Path,
188188- space: &SpaceUri,
189189- payload: &NotifyMembershipPayload,
184184+ owner_signing_key: &KeyData,
190185) -> PdsResult<u32> {
191186 enqueue_for_space(
192187 accounts_pool,
193188 data_dir,
194189 space,
195195- NOTIFY_MEMBERSHIP_NSID,
196196- payload,
190190+ Some(payload.repo.as_str()),
191191+ NOTIFY_WRITE_NSID,
192192+ "application/json",
193193+ &serde_json::to_vec(payload).map_err(|e| PdsError::Storage {
194194+ reason: format!("encode notifyWrite payload: {e}"),
195195+ })?,
196196+ Some(owner_signing_key),
197197 )
198198 .await
199199}
200200201201-async fn enqueue_for_space<P: serde::Serialize>(
201201+#[allow(clippy::too_many_arguments)]
202202+async fn enqueue_for_space(
202203 accounts_pool: &SqlitePool,
203204 data_dir: &std::path::Path,
204205 space: &SpaceUri,
206206+ writer_repo: Option<&str>,
205207 nsid: &str,
206206- payload: &P,
208208+ content_type: &str,
209209+ body: &[u8],
210210+ owner_signing_key: Option<&KeyData>,
207211) -> PdsResult<u32> {
208208- let owner_store = SqlActorStore::open(data_dir, &space.owner_did).await?;
209209- let recipients = list_recipients(owner_store.pool(), space).await?;
212212+ let owner_store = SqlActorStore::open(data_dir, &space.space_did).await?;
213213+ let recipients = list_recipients(owner_store.pool(), space, writer_repo).await?;
210214 if recipients.is_empty() {
211215 return Ok(0);
212216 }
213217214214- let body = atproto_dasl::to_vec(payload).map_err(|e| PdsError::Storage {
215215- reason: format!("encode notify payload: {e}"),
216216- })?;
217217-218218 let mut count = 0u32;
219219 for r in recipients {
220220+ // Mint a per-recipient service-auth token when a signing key is
221221+ // supplied (notifyWrite). aud is the recipient service DID.
222222+ let auth_token = match owner_signing_key {
223223+ Some(key) => Some(mint_service_auth(
224224+ key,
225225+ &space.space_did,
226226+ &r.service_did,
227227+ nsid,
228228+ NOTIFY_SERVICE_AUTH_TTL_SECS,
229229+ )?),
230230+ None => None,
231231+ };
220232 enqueue_notification(
221233 accounts_pool,
222234 &r.service_did,
223235 &r.service_endpoint,
224224- body.clone(),
236236+ body.to_vec(),
225237 nsid,
238238+ content_type,
239239+ auth_token.as_deref(),
226240 )
227241 .await?;
228242 count += 1;
229243 }
230244 Ok(count)
231231-}
232232-233233-/// `serde_bytes`-style adapter for `Option<Vec<u8>>` so the payload encodes
234234-/// the raw bytes as a CBOR byte string instead of an array of integers.
235235-mod serde_bytes_opt {
236236- use serde::{Deserialize, Deserializer, Serialize, Serializer};
237237-238238- pub fn serialize<S: Serializer>(
239239- value: &Option<Vec<u8>>,
240240- serializer: S,
241241- ) -> Result<S::Ok, S::Error> {
242242- match value {
243243- Some(bytes) => serde_bytes::ByteBuf::from(bytes.clone()).serialize(serializer),
244244- None => serializer.serialize_none(),
245245- }
246246- }
247247-248248- pub fn deserialize<'de, D: Deserializer<'de>>(
249249- deserializer: D,
250250- ) -> Result<Option<Vec<u8>>, D::Error> {
251251- let opt: Option<serde_bytes::ByteBuf> = Option::deserialize(deserializer)?;
252252- Ok(opt.map(|b| b.into_vec()))
253253- }
254245}
255246256247#[cfg(test)]
···310301 .await
311302 .unwrap();
312303313313- let recipients = list_recipients(owner_store.pool(), &uri).await.unwrap();
304304+ let recipients = list_recipients(owner_store.pool(), &uri, None)
305305+ .await
306306+ .unwrap();
314307 assert_eq!(recipients.len(), 1);
315308 assert_eq!(recipients[0].service_did, "did:web:appview.example");
316309 }
···328321 .unwrap();
329322 let payload = NotifyWritePayload {
330323 space: test_space().to_string(),
331331- member: "did:plc:alice".to_string(),
332332- commit: Commit {
333333- set_hash: vec![0u8; 32],
334334- rev: "rev".to_string(),
335335- ikm: vec![0u8; 32],
336336- tag: vec![0u8; 32],
337337- sig: vec![0u8; 64],
338338- },
339339- ops: vec![],
324324+ repo: "did:plc:alice".to_string(),
325325+ rev: "rev".to_string(),
340326 };
341341- let count = enqueue_writes(accounts.pool(), &dir, &test_space(), &payload)
327327+ let owner_key =
328328+ atproto_identity::key::generate_key(atproto_identity::key::KeyType::P256Private)
329329+ .unwrap();
330330+ let count = enqueue_writes(accounts.pool(), &dir, &test_space(), &payload, &owner_key)
342331 .await
343332 .unwrap();
344333 assert_eq!(count, 0);
···373362 .unwrap();
374363 let payload = NotifyWritePayload {
375364 space: uri.to_string(),
376376- member: "did:plc:alice".to_string(),
377377- commit: Commit {
378378- set_hash: vec![0u8; 32],
379379- rev: "3kmev".to_string(),
380380- ikm: vec![0u8; 32],
381381- tag: vec![0u8; 32],
382382- sig: vec![0u8; 64],
383383- },
384384- ops: vec![NotifyOp {
385385- action: "create".to_string(),
386386- collection: "c".to_string(),
387387- rkey: "k".to_string(),
388388- cid: Some("bafy...".to_string()),
389389- value: Some(b"v".to_vec()),
390390- }],
365365+ repo: "did:plc:alice".to_string(),
366366+ rev: "3kmev".to_string(),
391367 };
392392- let count = enqueue_writes(accounts.pool(), &dir, &uri, &payload)
368368+ let owner_key =
369369+ atproto_identity::key::generate_key(atproto_identity::key::KeyType::P256Private)
370370+ .unwrap();
371371+ let count = enqueue_writes(accounts.pool(), &dir, &uri, &payload, &owner_key)
393372 .await
394373 .unwrap();
395374 assert_eq!(count, 2);
···399378 .unwrap();
400379 assert_eq!(due.len(), 2);
401380 assert!(due.iter().all(|d| d.nsid == NOTIFY_WRITE_NSID));
381381+ }
382382+383383+ /// A per-repo subscription only matches notifyWrite fan-out for that repo.
384384+ #[tokio::test(flavor = "multi_thread")]
385385+ async fn per_repo_subscription_filters_by_writer() {
386386+ let tmp = TempDir::new().unwrap();
387387+ let dir = tmp.path();
388388+ let owner_store = SqlActorStore::open(dir, "did:plc:owner").await.unwrap();
389389+ let uri = test_space();
390390+ ensure_space_row(owner_store.pool(), &uri).await;
391391+ // Subscribe `svc` to alice's repo only.
392392+ upsert_subscription(
393393+ owner_store.pool(),
394394+ &uri,
395395+ Some("did:plc:alice"),
396396+ "did:web:svc.example",
397397+ "https://svc.example",
398398+ None,
399399+ )
400400+ .await
401401+ .unwrap();
402402+403403+ // Writer = alice → matches.
404404+ let for_alice = list_recipients(owner_store.pool(), &uri, Some("did:plc:alice"))
405405+ .await
406406+ .unwrap();
407407+ assert_eq!(for_alice.len(), 1);
408408+409409+ // Writer = bob → no match.
410410+ let for_bob = list_recipients(owner_store.pool(), &uri, Some("did:plc:bob"))
411411+ .await
412412+ .unwrap();
413413+ assert_eq!(for_bob.len(), 0);
414414+ }
415415+416416+ /// Expired registrations are skipped by `list_recipients`.
417417+ #[tokio::test(flavor = "multi_thread")]
418418+ async fn expired_subscription_is_skipped() {
419419+ let tmp = TempDir::new().unwrap();
420420+ let dir = tmp.path();
421421+ let owner_store = SqlActorStore::open(dir, "did:plc:owner").await.unwrap();
422422+ let uri = test_space();
423423+ ensure_space_row(owner_store.pool(), &uri).await;
424424+ upsert_subscription(
425425+ owner_store.pool(),
426426+ &uri,
427427+ None,
428428+ "did:web:svc.example",
429429+ "https://svc.example",
430430+ Some("2000-01-01T00:00:00Z"),
431431+ )
432432+ .await
433433+ .unwrap();
434434+ let recipients = list_recipients(owner_store.pool(), &uri, Some("did:plc:alice"))
435435+ .await
436436+ .unwrap();
437437+ assert_eq!(recipients.len(), 0);
402438 }
403439}
+264-90
crates/atproto-pds/src/space/reader.rs
···11//! `SpaceReader` — dual-auth permissioned-record reads.
22//!
33-//! Spaces reads accept
44-//! either:
33+//! Spaces reads accept either:
54//! - **Own-PDS OAuth** — the caller holds an OAuth bearer for the local
65//! member's account and can read whatever rows their per-actor store has.
76//! The PDS itself is the auth boundary.
87//! - **Remote `SpaceCredential`** — a JWT minted by the space owner via
98//! `getSpaceCredential` and bound to a `clientId`. The owner's PDS verifies
109//! the credential against its own signing key, then serves rows from the
1111-//! *owner's* per-actor store.
1010+//! per-actor store of the `repo` DID supplied by the caller.
1211//!
1312//! `SpaceReader` is intentionally storage-agnostic above the
1413//! `SpaceRepoStorage` trait: **the PDS does not enforce
1515-//! membership at read time** for own-PDS-OAuth callers (G35) — the consumer
1414+//! membership at read time** for own-PDS-OAuth callers — the consumer
1615//! is responsible for membership checks at sync. For SpaceCredential callers,
1716//! the credential itself acts as proof that the owner has authorized this
1817//! `clientId` to read the space.
1818+//!
1919+//! Both auth modes accept an explicit `target_repo` (the DID whose per-actor
2020+//! store to read from). For OAuth, the caller may omit `repo` and the read
2121+//! defaults to their own subject; for SpaceCredential the caller MUST supply
2222+//! `repo` — handler-level validation rejects missing values before they reach
2323+//! the reader.
19242025use crate::actor_store::sql::{SqlActorStore, SqlSpaceRepoStorage};
2126use crate::errors::{PdsError, PdsResult};
2227use crate::realm::PdsSetHash;
2828+use crate::space::config::ensure_space_live;
2329use atproto_identity::key::{KeyData, to_public};
2430use atproto_space::credential::{SpaceCredential, verify_space_credential};
2531use atproto_space::space_repo::SpaceRepo;
···3339pub enum SpaceReadAuth<'a> {
3440 /// Caller holds an OAuth bearer for the named account on this PDS.
3541 /// Reads happen against that account's per-actor store. No membership
3636- /// check is performed here (G35) — consumers verify at sync time.
4242+ /// check is performed here — consumers verify at sync time.
3743 OwnPds {
3844 /// DID of the local account whose per-actor store to read from.
3945 account_did: &'a str,
4046 },
4141- /// Caller presented a `SpaceCredential` JWT minted by the space owner.
4242- /// Reads happen against the *owner's* per-actor store.
4747+ /// Caller presented a `SpaceCredential` JWT minted by the space authority.
4848+ /// Reads happen against the *authority's* per-actor store. The credential's
4949+ /// `client_id` claim is advisory (the trust comes from the authority's
5050+ /// signature), so no expected-client check is performed here.
4351 SpaceCredential {
4452 /// The compact-form JWT.
4553 token: &'a str,
4646- /// `client_id` the credential is expected to be bound to (from the
4747- /// HTTP-layer DPoP/auth check).
4848- expected_client_id: &'a str,
4954 },
5055}
5156···6267 Self { data_dir, accounts }
6368 }
64696565- /// `getRecord` — fetch a single record by `(collection, rkey)`.
7070+ /// Fail with `SpaceNotFound` when the space is tombstoned. The tombstone
7171+ /// lives in the space-authority's (`space.space_did`) per-actor store; the
7272+ /// check is a no-op when that store is not local (cross-PDS spaces, where
7373+ /// the authority enforces deletion on its own side).
7474+ async fn ensure_space_live(&self, space: &SpaceUri) -> PdsResult<()> {
7575+ ensure_space_live(&self.data_dir, space).await?;
7676+ Ok(())
7777+ }
7878+7979+ /// `getRecord` — fetch a single record by `(collection, rkey)` from the
8080+ /// per-actor store of `target_repo`.
8181+ ///
8282+ /// `target_repo` is the DID of the member whose records to read from.
8383+ /// For OAuth callers this is typically the caller's own DID (but may
8484+ /// differ if the caller passed `repo` to read another member's repo on
8585+ /// this PDS). For SpaceCredential callers this is the `repo` query
8686+ /// parameter; handler-level validation ensures it is always supplied.
6687 ///
6788 /// Returns `Ok(None)` when the record does not exist OR is taken-down
6868- /// per §4.4 (`space_record_takedown`); returns [`PdsError::AuthDenied`]
8989+ /// (`space_record_takedown`); returns [`PdsError::AuthDenied`]
6990 /// for invalid SpaceCredentials.
7091 pub async fn get_record(
7192 &self,
7293 space: &SpaceUri,
7394 auth: SpaceReadAuth<'_>,
9595+ target_repo: &str,
7496 collection: &str,
7597 rkey: &str,
7698 ) -> PdsResult<Option<RecordRow>> {
7777- let owner_did_for_read = self.resolve_read_target(space, &auth).await?;
7878- let store = SqlActorStore::open(&self.data_dir, &owner_did_for_read).await?;
9999+ self.verify_auth(space, &auth).await?;
100100+ self.ensure_space_live(space).await?;
101101+ let store = SqlActorStore::open(&self.data_dir, target_repo).await?;
791028080- // §4.4 takedown gate — admin moderation hides the record at read time.
103103+ // Takedown gate — admin moderation hides the record at read time.
81104 if is_record_taken_down(store.pool(), space, collection, rkey).await? {
82105 return Ok(None);
83106 }
···90113 .map_err(PdsError::Space)
91114 }
921159393- /// `listRecords` — paginated listing within a collection.
116116+ /// `listRecords` — paginated listing within a collection, or across all
117117+ /// collections when `collection` is `None`.
118118+ ///
119119+ /// When `collection` is `Some`, the call is paginated via the supplied
120120+ /// `cursor`. When `collection` is `None`, the reader iterates every
121121+ /// collection in the space and concatenates the first `limit` records
122122+ /// from each; `cursor` is ignored in this mode (matching the TypeScript
123123+ /// PDS behavior).
124124+ ///
125125+ /// `target_repo` has the same meaning as in [`Self::get_record`].
94126 pub async fn list_records(
95127 &self,
96128 space: &SpaceUri,
97129 auth: SpaceReadAuth<'_>,
9898- collection: &str,
130130+ target_repo: &str,
131131+ collection: Option<&str>,
99132 cursor: Option<&str>,
100133 limit: u32,
101134 ) -> PdsResult<RecordPage> {
102102- let owner_did_for_read = self.resolve_read_target(space, &auth).await?;
103103- let store = SqlActorStore::open(&self.data_dir, &owner_did_for_read).await?;
135135+ self.verify_auth(space, &auth).await?;
136136+ self.ensure_space_live(space).await?;
137137+ let store = SqlActorStore::open(&self.data_dir, target_repo).await?;
104138 let storage = SqlSpaceRepoStorage::new(store.pool().clone());
105139 let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> =
106140 SpaceRepo::new(space.clone(), storage);
107107- let mut page = repo
108108- .list_records(collection, cursor, limit)
109109- .await
110110- .map_err(PdsError::Space)?;
111141112112- // §4.4 takedown filter — drop taken-down rkeys from the page.
113113- // We hit the takedown table once per page rather than per record.
114114- let taken: std::collections::HashSet<String> =
115115- taken_down_rkeys(store.pool(), space, collection).await?;
116116- if !taken.is_empty() {
117117- page.records.retain(|r| !taken.contains(&r.rkey));
142142+ match collection {
143143+ Some(coll) => {
144144+ let mut page = repo
145145+ .list_records(coll, cursor, limit)
146146+ .await
147147+ .map_err(PdsError::Space)?;
148148+ let taken: std::collections::HashSet<String> =
149149+ taken_down_rkeys(store.pool(), space, coll).await?;
150150+ if !taken.is_empty() {
151151+ page.records.retain(|r| !taken.contains(&r.rkey));
152152+ }
153153+ Ok(page)
154154+ }
155155+ None => {
156156+ let collections = repo.list_collections().await.map_err(PdsError::Space)?;
157157+ let mut all_records = Vec::new();
158158+ for coll in collections {
159159+ let mut page = repo
160160+ .list_records(&coll, None, limit)
161161+ .await
162162+ .map_err(PdsError::Space)?;
163163+ let taken: std::collections::HashSet<String> =
164164+ taken_down_rkeys(store.pool(), space, &coll).await?;
165165+ if !taken.is_empty() {
166166+ page.records.retain(|r| !taken.contains(&r.rkey));
167167+ }
168168+ all_records.append(&mut page.records);
169169+ }
170170+ Ok(RecordPage {
171171+ records: all_records,
172172+ cursor: None,
173173+ })
174174+ }
118175 }
119119- Ok(page)
120176 }
121177122122- /// Verify the read auth and return the DID of the per-actor store to read.
123123- ///
124124- /// - `OwnPds { account_did }` → that account.
125125- /// - `SpaceCredential { .. }` → the space owner (after JWT verification).
126126- async fn resolve_read_target(
178178+ /// Public entry point to verify a record-read auth against `space` without
179179+ /// reading a record. Used by `com.atproto.space.getBlob`, which serves
180180+ /// blob bytes under the same auth gate as `getRecord` / `listRecords`
181181+ /// (space-credential-space-match OR OAuth/session) but does not go through
182182+ /// the record path.
183183+ pub async fn verify_read_auth(
127184 &self,
128185 space: &SpaceUri,
129186 auth: &SpaceReadAuth<'_>,
130130- ) -> PdsResult<String> {
187187+ ) -> PdsResult<()> {
188188+ self.verify_auth(space, auth).await
189189+ }
190190+191191+ /// Verify a presented `SpaceCredential` JWT against `space` without reading
192192+ /// a record. Resolves the space authority's `#atproto_space` signing key and
193193+ /// runs the full credential check (signature + `iss`/`sub`/`exp` bound to
194194+ /// `space`), returning [`PdsError::AuthDenied`] on any failure.
195195+ ///
196196+ /// Used by the host/sync read methods (`getSpace`, `getRepoState`,
197197+ /// `listRepoOps`, `listRepos`) to verify a credential-`typ` bearer at the
198198+ /// HTTP layer, so a forged, unsigned, expired, or wrong-space credential is
199199+ /// rejected rather than admitted on its `typ` string alone.
200200+ pub async fn verify_space_credential_for(
201201+ &self,
202202+ space: &SpaceUri,
203203+ token: &str,
204204+ ) -> PdsResult<()> {
205205+ self.verify_auth(space, &SpaceReadAuth::SpaceCredential { token })
206206+ .await
207207+ }
208208+209209+ /// Verify the read auth. For SpaceCredential, performs JWT signature
210210+ /// verification against the space authority's `#atproto_space` signing key
211211+ /// plus `iss`/`sub`/`exp` checks. The credential's `client_id` is advisory
212212+ /// and not enforced. For OwnPds, this is a no-op — the HTTP-layer
213213+ /// OAuth/session check already validated the bearer.
214214+ async fn verify_auth(&self, space: &SpaceUri, auth: &SpaceReadAuth<'_>) -> PdsResult<()> {
131215 match auth {
132132- SpaceReadAuth::OwnPds { account_did } => Ok((*account_did).to_string()),
133133- SpaceReadAuth::SpaceCredential {
134134- token,
135135- expected_client_id,
136136- } => {
137137- // Verify with the owner's *public* signing key. The owner is
138138- // an account managed by this PDS (otherwise we could not have
139139- // minted the credential), so we look up the signing-key ref
140140- // from `account` and convert to public form for verification.
141141- let owner_did = &space.owner_did;
142142- let owner_pub = self.owner_public_key(owner_did).await?;
216216+ SpaceReadAuth::OwnPds { .. } => Ok(()),
217217+ SpaceReadAuth::SpaceCredential { token } => {
218218+ let authority_did = &space.space_did;
219219+ let authority_pub = self.authority_public_key(authority_did).await?;
143220 let _payload: SpaceCredential =
144144- verify_space_credential(token, owner_did, space, &owner_pub).map_err(|e| {
145145- PdsError::AuthDenied {
146146- reason: format!("invalid SpaceCredential: {e}"),
147147- }
148148- })?;
149149- // Re-verify the bound client_id matches the HTTP-layer
150150- // expected value (the JWT carries this in payload.client_id;
151151- // verify_space_credential already returns the payload).
152152- let payload: SpaceCredential =
153153- verify_space_credential(token, owner_did, space, &owner_pub).map_err(|e| {
154154- PdsError::AuthDenied {
221221+ verify_space_credential(token, authority_did, space, &authority_pub).map_err(
222222+ |e| PdsError::AuthDenied {
155223 reason: format!("invalid SpaceCredential: {e}"),
156156- }
157157- })?;
158158- if &payload.client_id != expected_client_id {
159159- return Err(PdsError::AuthDenied {
160160- reason: format!(
161161- "SpaceCredential clientId mismatch: token={}, expected={}",
162162- payload.client_id, expected_client_id
163163- ),
164164- });
165165- }
166166- Ok(owner_did.clone())
224224+ },
225225+ )?;
226226+ Ok(())
167227 }
168228 }
169229 }
170230171171- /// Resolve an account's atproto signing key in *public* form. Used to
172172- /// verify SpaceCredentials minted by this PDS for one of its owners.
173173- async fn owner_public_key(&self, owner_did: &str) -> PdsResult<KeyData> {
231231+ /// Resolve a local authority's space-credential verification key in
232232+ /// *public* form. Used to verify SpaceCredentials minted by this PDS for one
233233+ /// of its authorities. Per 0016 line 92 the authority's `#atproto_space`
234234+ /// verification method MAY coincide with `#atproto`; for accounts this PDS
235235+ /// manages it does, so this returns the account's atproto signing key.
236236+ async fn authority_public_key(&self, authority_did: &str) -> PdsResult<KeyData> {
174237 let key_ref: Option<(String,)> =
175238 sqlx::query_as("SELECT signing_key_ref FROM account WHERE did = ?")
176176- .bind(owner_did)
239239+ .bind(authority_did)
177240 .fetch_optional(self.accounts.pool())
178241 .await
179242 .map_err(|e| PdsError::Storage {
···181244 })?;
182245 let key_ref = key_ref
183246 .ok_or_else(|| PdsError::NotFound {
184184- what: format!("account {owner_did} (signing_key_ref)"),
247247+ what: format!("account {authority_did} (signing_key_ref)"),
185248 })?
186249 .0;
187250 let private = self.accounts.key_store().get(&key_ref).await?;
···192255}
193256194257/// Returns true when a row in `space_record_takedown` matches the
195195-/// `(space, collection, rkey)` triple. Per §4.4.
258258+/// `(space, collection, rkey)` triple.
196259pub(crate) async fn is_record_taken_down(
197260 pool: &sqlx::SqlitePool,
198261 space: &SpaceUri,
···311374 SpaceReadAuth::OwnPds {
312375 account_did: "did:plc:owner",
313376 },
377377+ "did:plc:owner",
314378 "app.bsky.group.message",
315379 "abc",
316380 )
···336400 SpaceReadAuth::OwnPds {
337401 account_did: "did:plc:owner",
338402 },
339339- "app.bsky.group.message",
403403+ "did:plc:owner",
404404+ Some("app.bsky.group.message"),
340405 None,
341406 10,
342407 )
···346411 }
347412348413 #[tokio::test(flavor = "multi_thread")]
414414+ async fn list_records_across_collections_when_collection_none() {
415415+ let tmp = TempDir::new().unwrap();
416416+ let dir = tmp.path().to_path_buf();
417417+ let manager = fresh_manager(&dir).await;
418418+ let uri = test_space();
419419+ let writer = crate::space::writer::SpaceWriter::new(manager.clone(), dir.clone());
420420+ for (collection, rkey) in [
421421+ ("app.bsky.group.message", "m1"),
422422+ ("app.bsky.group.like", "l1"),
423423+ ] {
424424+ writer
425425+ .apply_writes(
426426+ "did:plc:owner",
427427+ &uri,
428428+ vec![SpaceWriteOp {
429429+ action: SpaceWriteAction::Create,
430430+ collection: collection.to_string(),
431431+ rkey: rkey.to_string(),
432432+ value: Some(serde_json::json!({})),
433433+ }],
434434+ )
435435+ .await
436436+ .unwrap();
437437+ }
438438+439439+ let reader = SpaceReader::new(manager, dir);
440440+ let page = reader
441441+ .list_records(
442442+ &uri,
443443+ SpaceReadAuth::OwnPds {
444444+ account_did: "did:plc:owner",
445445+ },
446446+ "did:plc:owner",
447447+ None,
448448+ None,
449449+ 10,
450450+ )
451451+ .await
452452+ .unwrap();
453453+ assert_eq!(page.records.len(), 2, "should aggregate across collections");
454454+ assert!(
455455+ page.cursor.is_none(),
456456+ "cross-collection listing has no cursor"
457457+ );
458458+ }
459459+460460+ #[tokio::test(flavor = "multi_thread")]
349461 async fn space_credential_round_trip() {
350462 let tmp = TempDir::new().unwrap();
351463 let dir = tmp.path().to_path_buf();
···365477 let token = create_space_credential(
366478 "did:plc:owner",
367479 &uri,
368368- "https://app.example/client-metadata.json",
480480+ Some("https://app.example/client-metadata.json"),
369481 &signing_key,
370482 SPACE_CREDENTIAL_TTL_SECS,
371483 )
···375487 let row = reader
376488 .get_record(
377489 &uri,
378378- SpaceReadAuth::SpaceCredential {
379379- token: &token,
380380- expected_client_id: "https://app.example/client-metadata.json",
381381- },
490490+ SpaceReadAuth::SpaceCredential { token: &token },
491491+ "did:plc:owner",
382492 "app.bsky.group.message",
383493 "abc",
384494 )
···389499 }
390500391501 #[tokio::test(flavor = "multi_thread")]
392392- async fn space_credential_wrong_client_rejected() {
502502+ async fn space_credential_wrong_space_rejected() {
393503 let tmp = TempDir::new().unwrap();
394504 let dir = tmp.path().to_path_buf();
395505 let manager = fresh_manager(&dir).await;
···403513 .await
404514 .unwrap();
405515 let signing_key = manager.key_store().get(&key_ref.0).await.unwrap();
516516+ // Mint a credential bound to a *different* space; the reader must
517517+ // reject it against `uri` on the `sub` claim mismatch.
518518+ let other = SpaceUri::new(
519519+ "did:plc:owner".to_string(),
520520+ atproto_space::types::SpaceType::new("app.bsky.group").unwrap(),
521521+ atproto_space::types::SpaceKey::new("other").unwrap(),
522522+ );
406523 let token = create_space_credential(
407524 "did:plc:owner",
408408- &uri,
409409- "client-A",
525525+ &other,
526526+ None,
410527 &signing_key,
411528 SPACE_CREDENTIAL_TTL_SECS,
412529 )
···416533 let result = reader
417534 .get_record(
418535 &uri,
419419- SpaceReadAuth::SpaceCredential {
420420- token: &token,
421421- expected_client_id: "client-B",
422422- },
536536+ SpaceReadAuth::SpaceCredential { token: &token },
537537+ "did:plc:owner",
423538 "app.bsky.group.message",
424539 "abc",
425540 )
···428543 }
429544430545 #[tokio::test(flavor = "multi_thread")]
546546+ async fn verify_space_credential_for_accepts_valid_and_rejects_forged() {
547547+ let tmp = TempDir::new().unwrap();
548548+ let dir = tmp.path().to_path_buf();
549549+ let manager = fresh_manager(&dir).await;
550550+ let uri = test_space();
551551+ seed_record(manager.clone(), dir.clone(), uri.clone()).await;
552552+553553+ let key_ref: (String,) =
554554+ sqlx::query_as("SELECT signing_key_ref FROM account WHERE did = ?")
555555+ .bind("did:plc:owner")
556556+ .fetch_one(manager.pool())
557557+ .await
558558+ .unwrap();
559559+ let signing_key = manager.key_store().get(&key_ref.0).await.unwrap();
560560+ let token = create_space_credential(
561561+ "did:plc:owner",
562562+ &uri,
563563+ None,
564564+ &signing_key,
565565+ SPACE_CREDENTIAL_TTL_SECS,
566566+ )
567567+ .unwrap();
568568+569569+ let reader = SpaceReader::new(manager, dir);
570570+ // A genuinely authority-signed credential verifies.
571571+ reader
572572+ .verify_space_credential_for(&uri, &token)
573573+ .await
574574+ .unwrap();
575575+576576+ // A token that merely *classifies* as a credential (correct typ/kid
577577+ // header) but carries a zero signature must be rejected.
578578+ let header = serde_json::json!({
579579+ "alg": "ES256",
580580+ "typ": "atproto-space-credential+jwt",
581581+ "kid": "#atproto_space"
582582+ });
583583+ let payload = serde_json::json!({
584584+ "iss": "did:plc:owner",
585585+ "sub": uri.to_string(),
586586+ "iat": 0,
587587+ "exp": 9_999_999_999u64,
588588+ });
589589+ use base64::Engine as _;
590590+ let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
591591+ let forged = format!(
592592+ "{}.{}.{}",
593593+ b64.encode(serde_json::to_vec(&header).unwrap()),
594594+ b64.encode(serde_json::to_vec(&payload).unwrap()),
595595+ b64.encode([0u8; 64]),
596596+ );
597597+ let result = reader.verify_space_credential_for(&uri, &forged).await;
598598+ assert!(matches!(result, Err(PdsError::AuthDenied { .. })));
599599+ }
600600+601601+ #[tokio::test(flavor = "multi_thread")]
431602 async fn nonexistent_record_returns_none() {
432603 let tmp = TempDir::new().unwrap();
433604 let dir = tmp.path().to_path_buf();
···441612 SpaceReadAuth::OwnPds {
442613 account_did: "did:plc:owner",
443614 },
615615+ "did:plc:owner",
444616 "app.bsky.group.message",
445617 "missing",
446618 )
···449621 assert!(row.is_none());
450622 }
451623452452- /// §4.4 takedown gate: a row in `space_record_takedown` hides the
624624+ /// Takedown gate: a row in `space_record_takedown` hides the
453625 /// record from `get_record` even when the underlying `space_record`
454626 /// row is intact. Re-inserting via `list_records` confirms the
455627 /// page-level filter is also wired.
···486658 SpaceReadAuth::OwnPds {
487659 account_did: "did:plc:owner",
488660 },
661661+ "did:plc:owner",
489662 "app.bsky.group.message",
490663 "abc",
491664 )
···500673 SpaceReadAuth::OwnPds {
501674 account_did: "did:plc:owner",
502675 },
503503- "app.bsky.group.message",
676676+ "did:plc:owner",
677677+ Some("app.bsky.group.message"),
504678 None,
505679 10,
506680 )
+53-10
crates/atproto-pds/src/space/recipient.rs
···11//! Consumer recipient discovery for `getSpaceCredential`.
22//!
33-//! when a consumer service swaps a `MemberGrant`
44-//! for a `SpaceCredential`, the owner's PDS must record the consumer's
33+//! When a consumer service swaps a delegation token
44+//! for a `SpaceCredential`, the authority's PDS must record the consumer's
55//! `(service_did, service_endpoint)` so the notifier knows where to fan
66//! out future commits.
77//!
···2626use crate::errors::{PdsError, PdsResult};
2727use atproto_identity::resolve::resolve_handle_http;
28282929-/// Resolved consumer identity, derivable from a MemberGrant + its
3030-/// bearing `client_id`.
2929+/// Resolved consumer identity, derivable from a delegation token's issuer +
3030+/// the attested `client_id`.
3131#[derive(Debug, Clone, PartialEq, Eq)]
3232pub struct ResolvedRecipient {
3333 /// Consumer service DID (e.g. `did:web:appview.example`).
···104104 did: &str,
105105 plc_directory_hostname: Option<&str>,
106106) -> PdsResult<Option<String>> {
107107+ let Some(document) = fetch_did_document(http, did, plc_directory_hostname).await? else {
108108+ return Ok(None);
109109+ };
110110+ Ok(document.pds_endpoints().first().map(|s| s.to_string()))
111111+}
112112+113113+/// Fetch a DID document for `did:plc:`/`did:web:`, returning `None` for
114114+/// unsupported methods (e.g. `did:webvh`).
115115+async fn fetch_did_document(
116116+ http: &reqwest::Client,
117117+ did: &str,
118118+ plc_directory_hostname: Option<&str>,
119119+) -> PdsResult<Option<atproto_identity::model::Document>> {
107120 use atproto_identity::plc::query as plc_query;
108121 use atproto_identity::web::query as web_query;
109109- let document = if let Some(stripped) = did.strip_prefix("did:plc:") {
122122+ let document = if did.starts_with("did:plc:") {
110123 let host = plc_directory_hostname.unwrap_or("plc.directory");
111111- let _ = stripped;
112124 plc_query(http, host, did)
113125 .await
114126 .map_err(|e| PdsError::Storage {
···119131 reason: format!("web query: {e}"),
120132 })?
121133 } else {
122122- // did:webvh and other methods aren't yet covered by the
123123- // recipient resolver — see. Surface a soft
124124- // failure so the stub kicks in.
134134+ // did:webvh and other methods aren't yet covered.
135135+ return Ok(None);
136136+ };
137137+ Ok(Some(document))
138138+}
139139+140140+/// Resolve a service identifier of the form `<did>#<fragment>` (e.g.
141141+/// `did:web:example.com#forum`) to the service's base endpoint URL by fetching
142142+/// the DID document and matching the service entry by fragment.
143143+///
144144+/// Returns `Ok(None)` when the DID method is unsupported, the document has no
145145+/// matching service entry, or the identifier is malformed.
146146+///
147147+/// # Errors
148148+/// Returns [`PdsError::Storage`] on a DID-document fetch failure.
149149+pub async fn resolve_service_endpoint(
150150+ http: &reqwest::Client,
151151+ service_id: &str,
152152+ plc_directory_hostname: Option<&str>,
153153+) -> PdsResult<Option<String>> {
154154+ let (did, fragment) = match service_id.split_once('#') {
155155+ Some((did, frag)) => (did, Some(frag)),
156156+ None => (service_id, None),
157157+ };
158158+ let Some(document) = fetch_did_document(http, did, plc_directory_hostname).await? else {
125159 return Ok(None);
126160 };
127127- Ok(document.pds_endpoints().first().map(|s| s.to_string()))
161161+ // Match the service whose `id` ends with the requested fragment
162162+ // (DID documents render service ids as `<did>#frag` or `#frag`).
163163+ let endpoint = document.service.iter().find_map(|svc| match fragment {
164164+ Some(frag) => {
165165+ let svc_frag = svc.id.rsplit('#').next().unwrap_or(svc.id.as_str());
166166+ (svc_frag == frag).then(|| svc.service_endpoint.clone())
167167+ }
168168+ None => Some(svc.service_endpoint.clone()),
169169+ });
170170+ Ok(endpoint)
128171}
129172130173/// Build the fallback recipient — used both as the result on failure
+636-160
crates/atproto-pds/src/space/service.rs
···11//! `SpaceService` — owner-side management endpoints.
22//!
33//! `createSpace`, `getSpace`, `listSpaces`, `addMember`, `removeMember`,
44-//! `getMembers`. Operates against the per-actor SQLite store.
44+//! `listMembers`. Operates against the per-actor SQLite store.
5566use crate::account::AccountManager;
77use crate::actor_store::sql::{SqlActorStore, SqlSpaceMembersStorage};
88use crate::errors::{PdsError, PdsResult};
99use crate::realm::PdsSetHash;
1010-use crate::space::notify::{NotifyMembershipPayload, enqueue_membership};
1111-use atproto_space::commit::{CommitScope, SpaceContext, create_commit};
1212-use atproto_space::set_hash::SetHash;
1010+use crate::space::config::{SpaceConfig, SpaceConfigPatch, ensure_not_deleted};
1311use atproto_space::space_members::{MemberOp, MemberOpAction, SpaceMembers};
1412use atproto_space::storage::{MemberPage, MemberState};
1513use atproto_space::types::{SpaceKey, SpaceType, SpaceUri};
···2119/// Space-management orchestrator.
2220pub struct SpaceService {
2321 data_dir: PathBuf,
2424- /// Optional accounts manager — required for `notifyMembership` fan-out
2525- /// (signing the membership commit + enqueuing into the shared queue).
2626- /// Test fixtures pass `None`; production wiring in `bin/pds.rs` always
2727- /// supplies an instance.
2828- accounts: Option<Arc<AccountManager>>,
2922}
30233124impl SpaceService {
3232- /// Construct without account-manager wiring. Suitable for tests that
3333- /// don't exercise membership-notification fan-out; `add_member` /
3434- /// `remove_member` will silently skip the `notifyMembership` enqueue.
2525+ /// Construct a space-management orchestrator rooted at `data_dir`.
3526 #[must_use]
3627 pub fn new(data_dir: PathBuf) -> Self {
3737- Self {
3838- data_dir,
3939- accounts: None,
4040- }
2828+ Self { data_dir }
4129 }
42304343- /// Construct with account-manager wiring for full `notifyMembership`
4444- /// fan-out. Production binaries call this; the manager backs both the
4545- /// signing-key lookup (to sign the membership commit) and the shared
4646- /// notify-attempt queue.
3131+ /// Construct with account-manager wiring. The 0016 Permissioned Data draft
3232+ /// has no membership-notification flow, so the manager is no longer needed
3333+ /// for member management; this constructor is retained for call-site
3434+ /// compatibility and is equivalent to [`SpaceService::new`].
4735 #[must_use]
4848- pub fn with_accounts(data_dir: PathBuf, accounts: Arc<AccountManager>) -> Self {
4949- Self {
5050- data_dir,
5151- accounts: Some(accounts),
5252- }
3636+ pub fn with_accounts(data_dir: PathBuf, _accounts: Arc<AccountManager>) -> Self {
3737+ Self { data_dir }
5338 }
54395540 /// `createSpace` — owner-side. Inserts a `space` row marked `is_owner=1`
5656- /// in the owner's per-actor store and seeds an empty member-list state.
5757- /// Idempotent: re-creating the same URI yields the existing row.
4141+ /// in the owner's per-actor store, seeds the member-list state, and adds
4242+ /// the owner as the first member of the space. Idempotent: re-creating the
4343+ /// same URI is a no-op on the second call (the duplicate member-add is
4444+ /// skipped via the `INSERT OR IGNORE` semantics in `SpaceMembersStorage`).
5845 pub async fn create_space(
5946 &self,
6047 owner_did: &str,
6148 space_type: &str,
6249 space_key: &str,
5050+ config: SpaceConfig,
6351 ) -> PdsResult<SpaceInfo> {
6452 let space_type = SpaceType::new(space_type).map_err(space_err)?;
6553 let space_key = SpaceKey::new(space_key).map_err(space_err)?;
···6755 let store = SqlActorStore::open(&self.data_dir, owner_did).await?;
68566957 let now = Utc::now().to_rfc3339();
7070- sqlx::query(
7171- "INSERT INTO space (uri, is_owner, is_member, created_at) VALUES (?, 1, 1, ?)
5858+ let mint_policy = config.mint_policy.as_str().to_string();
5959+ let app_access = config.app_access.to_storage_json()?;
6060+ // Use INSERT … ON CONFLICT here so the existing `created_at` is
6161+ // preserved on re-creation; the conflict branch reasserts the
6262+ // owner/member flags in case they were unset by a prior takedown.
6363+ let inserted = sqlx::query(
6464+ "INSERT INTO space (uri, is_owner, is_member, created_at, mint_policy, app_access, managing_app)
6565+ VALUES (?, 1, 1, ?, ?, ?, ?)
7266 ON CONFLICT(uri) DO UPDATE SET is_owner = 1, is_member = 1",
7367 )
7468 .bind(uri.to_string())
7569 .bind(&now)
7070+ .bind(&mint_policy)
7171+ .bind(&app_access)
7272+ .bind(config.managing_app.as_deref())
7673 .execute(store.pool())
7774 .await
7875 .map_err(|e| PdsError::Storage {
···9087 reason: format!("createSpace seed member_state: {e}"),
9188 })?;
92899090+ // On first creation, add the owner as the initial member so the
9191+ // member set contains them from t=0. Skipped on idempotent re-create
9292+ // (rows_affected == 0) since a second `Add` for the same DID would
9393+ // fail the SpaceMembers duplicate-add check.
9494+ if inserted.rows_affected() > 0 {
9595+ let storage = SqlSpaceMembersStorage::new(store.pool().clone());
9696+ let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> =
9797+ SpaceMembers::new(uri.clone(), storage);
9898+ let prepared = members
9999+ .format_commit(&[MemberOp {
100100+ action: MemberOpAction::Add,
101101+ did: owner_did.to_string(),
102102+ }])
103103+ .await
104104+ .map_err(space_err)?;
105105+ members.apply_commit(prepared).await.map_err(space_err)?;
106106+ }
107107+108108+ // Re-read created_at so the response reflects the persisted row even
109109+ // on idempotent re-create (where the INSERT was a no-op).
110110+ let created_at: String = sqlx::query_scalar("SELECT created_at FROM space WHERE uri = ?")
111111+ .bind(uri.to_string())
112112+ .fetch_one(store.pool())
113113+ .await
114114+ .map_err(|e| PdsError::Storage {
115115+ reason: format!("createSpace re-read created_at: {e}"),
116116+ })?;
117117+93118 Ok(SpaceInfo {
94119 uri: uri.to_string(),
95120 is_owner: true,
96121 is_member: true,
9797- created_at: now,
122122+ created_at,
123123+ })
124124+ }
125125+126126+ /// `getSpace` — return `{uri, config}` for a non-deleted space.
127127+ ///
128128+ /// The `config` is the `com.atproto.simplespace.defs#spaceConfig` open
129129+ /// union (mintPolicy + appAccess + optional managingApp). A tombstoned
130130+ /// space (`deleted_at IS NOT NULL`) or a missing row both yield
131131+ /// [`PdsError::SpaceNotFound`].
132132+ pub async fn get_space(&self, viewer_did: &str, uri: &SpaceUri) -> PdsResult<GetSpaceOutput> {
133133+ let store = SqlActorStore::open(&self.data_dir, viewer_did).await?;
134134+ let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
135135+ "SELECT mint_policy, app_access, managing_app, deleted_at FROM space WHERE uri = ?",
136136+ )
137137+ .bind(uri.to_string())
138138+ .fetch_optional(store.pool())
139139+ .await
140140+ .map_err(|e| PdsError::Storage {
141141+ reason: format!("getSpace: {e}"),
142142+ })?;
143143+ let (mint_policy, app_access, managing_app, deleted_at) =
144144+ row.ok_or_else(|| PdsError::SpaceNotFound {
145145+ uri: uri.to_string(),
146146+ })?;
147147+ if deleted_at.is_some() {
148148+ return Err(PdsError::SpaceNotFound {
149149+ uri: uri.to_string(),
150150+ });
151151+ }
152152+ let config = SpaceConfig::from_columns(&mint_policy, &app_access, managing_app)?;
153153+ Ok(GetSpaceOutput {
154154+ uri: uri.to_string(),
155155+ config: config.to_wire(),
98156 })
99157 }
100158101101- /// `getSpace` — return the row by URI.
102102- pub async fn get_space(
159159+ /// Load the mint-time authorization inputs for `getSpaceCredential`:
160160+ /// the space's persisted [`SpaceConfig`], whether the row exists, whether
161161+ /// it has been tombstoned, and whether the requesting `member_did` is in
162162+ /// the space's member list.
163163+ ///
164164+ /// Unlike [`Self::get_space`], this does **not** collapse the deleted and
165165+ /// missing states — the credential-mint handler must distinguish
166166+ /// `SpaceNotFound` from `SpaceDeleted` per the `getSpaceCredential`
167167+ /// lexicon. The query runs against the space authority's own per-actor
168168+ /// store (`uri.space_did`).
169169+ ///
170170+ /// # Errors
171171+ /// Returns [`PdsError::Storage`] on a query failure.
172172+ pub async fn load_mint_authz_inputs(
103173 &self,
104104- viewer_did: &str,
105174 uri: &SpaceUri,
106106- ) -> PdsResult<Option<SpaceInfo>> {
107107- let store = SqlActorStore::open(&self.data_dir, viewer_did).await?;
108108- let row: Option<(i64, i64, String)> =
109109- sqlx::query_as("SELECT is_owner, is_member, created_at FROM space WHERE uri = ?")
175175+ member_did: &str,
176176+ ) -> PdsResult<MintAuthzInputs> {
177177+ let store = SqlActorStore::open(&self.data_dir, &uri.space_did).await?;
178178+ let row: Option<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
179179+ "SELECT mint_policy, app_access, managing_app, deleted_at FROM space WHERE uri = ?",
180180+ )
181181+ .bind(uri.to_string())
182182+ .fetch_optional(store.pool())
183183+ .await
184184+ .map_err(|e| PdsError::Storage {
185185+ reason: format!("load_mint_authz_inputs: {e}"),
186186+ })?;
187187+188188+ let Some((mint_policy, app_access, managing_app, deleted_at)) = row else {
189189+ return Ok(MintAuthzInputs {
190190+ found: false,
191191+ deleted: false,
192192+ config: SpaceConfig::default(),
193193+ is_member: false,
194194+ });
195195+ };
196196+ if deleted_at.is_some() {
197197+ return Ok(MintAuthzInputs {
198198+ found: true,
199199+ deleted: true,
200200+ config: SpaceConfig::default(),
201201+ is_member: false,
202202+ });
203203+ }
204204+205205+ let config = SpaceConfig::from_columns(&mint_policy, &app_access, managing_app)?;
206206+ let is_member: Option<i64> =
207207+ sqlx::query_scalar("SELECT 1 FROM space_member WHERE space = ? AND did = ? LIMIT 1")
110208 .bind(uri.to_string())
209209+ .bind(member_did)
111210 .fetch_optional(store.pool())
112211 .await
113212 .map_err(|e| PdsError::Storage {
114114- reason: format!("getSpace: {e}"),
213213+ reason: format!("load_mint_authz_inputs member check: {e}"),
115214 })?;
116116- Ok(row.map(|(is_owner, is_member, created_at)| SpaceInfo {
117117- uri: uri.to_string(),
118118- is_owner: is_owner != 0,
119119- is_member: is_member != 0,
120120- created_at,
121121- }))
215215+216216+ Ok(MintAuthzInputs {
217217+ found: true,
218218+ deleted: false,
219219+ config,
220220+ is_member: is_member.is_some(),
221221+ })
222222+ }
223223+224224+ /// `updateSpace` — owner-only. Applies a [`SpaceConfigPatch`]; omitted
225225+ /// fields are left unchanged and `managingApp == ""` clears the column to
226226+ /// `NULL`. Fails with [`PdsError::SpaceNotFound`] for a missing or
227227+ /// tombstoned space and [`PdsError::NotSpaceOwner`] when the caller is not
228228+ /// the space authority.
229229+ pub async fn update_space(
230230+ &self,
231231+ owner_did: &str,
232232+ uri: &SpaceUri,
233233+ patch: SpaceConfigPatch,
234234+ ) -> PdsResult<()> {
235235+ if uri.space_did != owner_did {
236236+ return Err(PdsError::NotSpaceOwner {
237237+ uri: uri.to_string(),
238238+ });
239239+ }
240240+ let store = SqlActorStore::open(&self.data_dir, owner_did).await?;
241241+ // Confirm the row exists and is not tombstoned before mutating.
242242+ let exists: Option<Option<String>> =
243243+ sqlx::query_scalar("SELECT deleted_at FROM space WHERE uri = ?")
244244+ .bind(uri.to_string())
245245+ .fetch_optional(store.pool())
246246+ .await
247247+ .map_err(|e| PdsError::Storage {
248248+ reason: format!("updateSpace lookup: {e}"),
249249+ })?;
250250+ match exists {
251251+ Some(None) => {}
252252+ _ => {
253253+ return Err(PdsError::SpaceNotFound {
254254+ uri: uri.to_string(),
255255+ });
256256+ }
257257+ }
258258+259259+ if let Some(policy) = patch.mint_policy {
260260+ sqlx::query("UPDATE space SET mint_policy = ? WHERE uri = ?")
261261+ .bind(policy.as_str())
262262+ .bind(uri.to_string())
263263+ .execute(store.pool())
264264+ .await
265265+ .map_err(|e| PdsError::Storage {
266266+ reason: format!("updateSpace mint_policy: {e}"),
267267+ })?;
268268+ }
269269+ if let Some(ref access) = patch.app_access {
270270+ sqlx::query("UPDATE space SET app_access = ? WHERE uri = ?")
271271+ .bind(access.to_storage_json()?)
272272+ .bind(uri.to_string())
273273+ .execute(store.pool())
274274+ .await
275275+ .map_err(|e| PdsError::Storage {
276276+ reason: format!("updateSpace app_access: {e}"),
277277+ })?;
278278+ }
279279+ if let Some(ref app) = patch.managing_app {
280280+ // Empty string clears to NULL; any other value sets it.
281281+ let value = if app.is_empty() {
282282+ None
283283+ } else {
284284+ Some(app.as_str())
285285+ };
286286+ sqlx::query("UPDATE space SET managing_app = ? WHERE uri = ?")
287287+ .bind(value)
288288+ .bind(uri.to_string())
289289+ .execute(store.pool())
290290+ .await
291291+ .map_err(|e| PdsError::Storage {
292292+ reason: format!("updateSpace managing_app: {e}"),
293293+ })?;
294294+ }
295295+ Ok(())
296296+ }
297297+298298+ /// `deleteSpace` — owner-only tombstone. Sets `deleted_at`; after this all
299299+ /// reads and writes against the space fail with
300300+ /// [`PdsError::SpaceNotFound`]. Idempotent on an already-deleted space.
301301+ pub async fn delete_space(&self, owner_did: &str, uri: &SpaceUri) -> PdsResult<()> {
302302+ if uri.space_did != owner_did {
303303+ return Err(PdsError::NotSpaceOwner {
304304+ uri: uri.to_string(),
305305+ });
306306+ }
307307+ let store = SqlActorStore::open(&self.data_dir, owner_did).await?;
308308+ let now = Utc::now().to_rfc3339();
309309+ let affected =
310310+ sqlx::query("UPDATE space SET deleted_at = ? WHERE uri = ? AND deleted_at IS NULL")
311311+ .bind(&now)
312312+ .bind(uri.to_string())
313313+ .execute(store.pool())
314314+ .await
315315+ .map_err(|e| PdsError::Storage {
316316+ reason: format!("deleteSpace: {e}"),
317317+ })?;
318318+ if affected.rows_affected() == 0 {
319319+ // Either no such row, or already deleted. Confirm existence so we
320320+ // return SpaceNotFound only for a genuinely absent space.
321321+ let exists: Option<String> = sqlx::query_scalar("SELECT uri FROM space WHERE uri = ?")
322322+ .bind(uri.to_string())
323323+ .fetch_optional(store.pool())
324324+ .await
325325+ .map_err(|e| PdsError::Storage {
326326+ reason: format!("deleteSpace recheck: {e}"),
327327+ })?;
328328+ if exists.is_none() {
329329+ return Err(PdsError::SpaceNotFound {
330330+ uri: uri.to_string(),
331331+ });
332332+ }
333333+ }
334334+335335+ // Per the 0016 Permissioned Data draft (line 363): "The authority also
336336+ // deletes its own repo in the space." The `space` row itself is kept as
337337+ // a tombstone (repo-host flag behavior, spec line 365 — the host flags
338338+ // rather than erases), but the authority's *own* repo data within the
339339+ // space (records, repo set-hash state, and the record oplog) is erased.
340340+ //
341341+ // Note: spec line 367's "a syncer should delete every copy" is a
342342+ // *syncer-role* behavior, not applicable to this PDS acting as the
343343+ // repo-host/authority; see `notify_space_deleted` for the recipient side.
344344+ for table in ["space_record", "space_record_oplog", "space_repo"] {
345345+ sqlx::query(&format!("DELETE FROM {table} WHERE space = ?"))
346346+ .bind(uri.to_string())
347347+ .execute(store.pool())
348348+ .await
349349+ .map_err(|e| PdsError::Storage {
350350+ reason: format!("deleteSpace purge {table}: {e}"),
351351+ })?;
352352+ }
353353+ Ok(())
354354+ }
355355+356356+ /// Whether `did` is a member of `space` (reference `store.space.isMember`).
357357+ ///
358358+ /// Membership is the `space_member` table; the space owner is implicitly a
359359+ /// member (owner-as-member). Used by the owner-side `notifyWrite` fan-out to
360360+ /// reject notifications from non-members. A missing or tombstoned space
361361+ /// yields `false`.
362362+ pub async fn is_member(&self, uri: &SpaceUri, did: &str) -> PdsResult<bool> {
363363+ if uri.space_did == did {
364364+ return Ok(true);
365365+ }
366366+ let store = SqlActorStore::open(&self.data_dir, &uri.space_did).await?;
367367+ let row: Option<i64> =
368368+ sqlx::query_scalar("SELECT 1 FROM space_member WHERE space = ? AND did = ? LIMIT 1")
369369+ .bind(uri.to_string())
370370+ .bind(did)
371371+ .fetch_optional(store.pool())
372372+ .await
373373+ .map_err(|e| PdsError::Storage {
374374+ reason: format!("is_member: {e}"),
375375+ })?;
376376+ Ok(row.is_some())
122377 }
123378124379 /// `listSpaces` — paginated listing for a viewer DID. `filter` is one of
···132387 ) -> PdsResult<Vec<SpaceInfo>> {
133388 let store = SqlActorStore::open(&self.data_dir, viewer_did).await?;
134389 let limit = limit.clamp(1, 100);
135135- let mut clauses: Vec<String> = Vec::new();
390390+ // Tombstoned spaces are never listed.
391391+ let mut clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
136392 match filter {
137393 "owned" => clauses.push("is_owner = 1".to_string()),
138394 "member" => clauses.push("is_member = 1".to_string()),
···181437182438 /// `addMember` — owner-side. Atomic via `SpaceMembers::format_commit` +
183439 /// `apply_commit`. Caller is responsible for verifying that
184184- /// `caller_did == uri.owner_did` (auth check) before invoking.
440440+ /// `caller_did == uri.space_did` (auth check) before invoking.
185441 pub async fn add_member(
186442 &self,
187443 owner_did: &str,
···204460 }
205461206462 /// Shared body of `add_member` / `remove_member`. Formats + applies the
207207- /// commit, then (when an `AccountManager` is wired) signs the commit and
208208- /// enqueues a `notifyMembership` row per registered recipient.
463463+ /// member-list change against the owner's per-actor store.
464464+ ///
465465+ /// The 0016 Permissioned Data draft has no member commits or
466466+ /// membership-notification flow: the member list is plain owner-managed
467467+ /// state backing `mintPolicy=member-list`, not a signed-commit log.
209468 async fn apply_member_op(
210469 &self,
211470 owner_did: &str,
···213472 action: MemberOpAction,
214473 target_did: &str,
215474 ) -> PdsResult<()> {
216216- if uri.owner_did != owner_did {
217217- return Err(PdsError::AuthDenied {
218218- reason: format!(
219219- "{owner_did} is not the owner of {} (owner is {})",
220220- uri, uri.owner_did
221221- ),
475475+ if uri.space_did != owner_did {
476476+ return Err(PdsError::NotSpaceOwner {
477477+ uri: uri.to_string(),
222478 });
223479 }
224480 let store = SqlActorStore::open(&self.data_dir, owner_did).await?;
481481+ ensure_not_deleted(store.pool(), uri).await?;
225482 let storage = SqlSpaceMembersStorage::new(store.pool().clone());
226483 let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> =
227484 SpaceMembers::new(uri.clone(), storage);
···232489 }])
233490 .await
234491 .map_err(space_err)?;
235235-236236- let action_str = match action {
237237- MemberOpAction::Add => "add",
238238- MemberOpAction::Remove => "remove",
239239- };
240240- let rev = prepared.rev.clone();
241241- let set_hash_digest = prepared.storage_commit.new_set_hash.clone();
242242-243243- // Drop the storage borrow before any await on the notifier path.
244492 members.apply_commit(prepared).await.map_err(space_err)?;
245245-246246- // `notifyMembership` fan-out — only when wiring is complete.
247247- if let Some(ref accounts) = self.accounts
248248- && let Err(e) = self
249249- .enqueue_membership_notification(
250250- accounts,
251251- uri,
252252- action_str,
253253- target_did,
254254- &rev,
255255- &set_hash_digest,
256256- )
257257- .await
258258- {
259259- tracing::warn!(
260260- error = ?e,
261261- space = %uri,
262262- action = action_str,
263263- member = target_did,
264264- "notifyMembership enqueue failed; recipients catch up via getMemberOplog"
265265- );
266266- }
267267-268493 Ok(())
269494 }
270495271271- /// Sign a membership commit with the owner's atproto signing key and
272272- /// enqueue one `notify_attempt` per registered recipient.
273273- async fn enqueue_membership_notification(
274274- &self,
275275- accounts: &Arc<AccountManager>,
276276- uri: &SpaceUri,
277277- action_str: &str,
278278- target_did: &str,
279279- rev: &str,
280280- set_hash_digest: &[u8],
281281- ) -> PdsResult<()> {
282282- // Resolve the owner's signing key.
283283- let key_ref: Option<(String,)> =
284284- sqlx::query_as("SELECT signing_key_ref FROM account WHERE did = ?")
285285- .bind(&uri.owner_did)
286286- .fetch_optional(accounts.pool())
287287- .await
288288- .map_err(|e| PdsError::Storage {
289289- reason: format!("lookup signing_key_ref: {e}"),
290290- })?;
291291- let key_ref = key_ref
292292- .ok_or_else(|| PdsError::NotFound {
293293- what: format!("account {} has no signing_key_ref", uri.owner_did),
294294- })?
295295- .0;
296296- let signing_key = accounts.key_store().get(&key_ref).await?;
297297-298298- // Build the SpaceContext — `userDid` is the owner since they're the
299299- // one mutating the member list (owner is the only
300300- // writer of member-list commits).
301301- let context = SpaceContext {
302302- space_did: uri.owner_did.clone(),
303303- space_type: uri.space_type.to_string(),
304304- space_key: uri.space_key.to_string(),
305305- user_did: uri.owner_did.clone(),
306306- scope: CommitScope::Members,
307307- rev: rev.to_string(),
308308- };
309309- let set_hash = PdsSetHash::from_digest(set_hash_digest).map_err(PdsError::Space)?;
310310- let signed = create_commit(&set_hash, &context, &signing_key).map_err(PdsError::Space)?;
311311-312312- let payload = NotifyMembershipPayload {
313313- space: uri.to_string(),
314314- action: action_str.to_string(),
315315- member: target_did.to_string(),
316316- commit: signed,
317317- };
318318- enqueue_membership(accounts.pool(), &self.data_dir, uri, &payload).await?;
319319- Ok(())
320320- }
321321-322322- /// `getMembers` — paginated.
496496+ /// `listMembers` — paginated.
323497 pub async fn list_members(
324498 &self,
325499 owner_did: &str,
···327501 cursor: Option<&str>,
328502 limit: u32,
329503 ) -> PdsResult<MemberPage> {
330330- if uri.owner_did != owner_did {
331331- return Err(PdsError::AuthDenied {
332332- reason: format!("{owner_did} is not the owner of {uri}"),
504504+ if uri.space_did != owner_did {
505505+ return Err(PdsError::NotSpaceOwner {
506506+ uri: uri.to_string(),
333507 });
334508 }
335509 let store = SqlActorStore::open(&self.data_dir, owner_did).await?;
510510+ ensure_not_deleted(store.pool(), uri).await?;
336511 let storage = SqlSpaceMembersStorage::new(store.pool().clone());
337512 let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> =
338513 SpaceMembers::new(uri.clone(), storage);
···341516342517 /// Read the member-list commitment.
343518 pub async fn member_state(&self, owner_did: &str, uri: &SpaceUri) -> PdsResult<MemberState> {
344344- if uri.owner_did != owner_did {
345345- return Err(PdsError::AuthDenied {
346346- reason: format!("{owner_did} is not the owner of {uri}"),
519519+ if uri.space_did != owner_did {
520520+ return Err(PdsError::NotSpaceOwner {
521521+ uri: uri.to_string(),
347522 });
348523 }
349524 let store = SqlActorStore::open(&self.data_dir, owner_did).await?;
···354529 }
355530}
356531357357-/// Lexicon-shape of a space row.
532532+/// Internal view of a space row for `createSpace` / `listSpaces` (viewer
533533+/// relationship + creation time). Not the `getSpace` wire shape — see
534534+/// [`GetSpaceOutput`] for that.
358535#[derive(Debug, Clone, Serialize, Deserialize)]
359536pub struct SpaceInfo {
360360- /// Full `ats://` URI.
537537+ /// Full space URI.
361538 pub uri: String,
362539 /// Whether the viewer is the owner.
363540 #[serde(rename = "isOwner")]
···370547 pub created_at: String,
371548}
372549550550+/// `com.atproto.space.getSpace` output: the space URI plus the open-union
551551+/// `config` (a `com.atproto.simplespace.defs#spaceConfig` wire value).
552552+#[derive(Debug, Clone, Serialize, Deserialize)]
553553+pub struct GetSpaceOutput {
554554+ /// URI of the space.
555555+ pub uri: String,
556556+ /// Implementation-specific configuration union (carries `$type`).
557557+ pub config: serde_json::Value,
558558+}
559559+560560+/// Mint-time authorization inputs loaded by
561561+/// [`SpaceService::load_mint_authz_inputs`].
562562+#[derive(Debug, Clone)]
563563+pub struct MintAuthzInputs {
564564+ /// Whether a `space` row exists for the URI in the authority's store.
565565+ pub found: bool,
566566+ /// Whether the existing row is tombstoned (`deleted_at IS NOT NULL`).
567567+ pub deleted: bool,
568568+ /// The persisted space configuration (default when missing/deleted).
569569+ pub config: SpaceConfig,
570570+ /// Whether the requesting member DID is in the space member list.
571571+ pub is_member: bool,
572572+}
573573+373574fn space_err(err: atproto_space::SpaceError) -> PdsError {
374575 PdsError::Space(err)
375576}
···389590 async fn create_then_get_space() {
390591 let (svc, _tmp) = fresh_service().await;
391592 let info = svc
392392- .create_space("did:plc:owner", "app.bsky.group", "default")
593593+ .create_space(
594594+ "did:plc:owner",
595595+ "app.bsky.group",
596596+ "default",
597597+ SpaceConfig::default(),
598598+ )
393599 .await
394600 .unwrap();
395601 assert!(info.is_owner);
396602 assert!(info.is_member);
397603398604 let uri = info.uri.parse::<SpaceUri>().unwrap();
399399- let got = svc.get_space("did:plc:owner", &uri).await.unwrap().unwrap();
605605+ let got = svc.get_space("did:plc:owner", &uri).await.unwrap();
400606 assert_eq!(got.uri, info.uri);
607607+ // Defaults surface as member-list + #open.
608608+ assert_eq!(got.config["mintPolicy"], "member-list");
609609+ assert_eq!(
610610+ got.config["appAccess"]["$type"],
611611+ crate::space::config::APP_ACCESS_OPEN_TYPE
612612+ );
613613+ }
614614+615615+ #[tokio::test(flavor = "multi_thread")]
616616+ async fn update_then_get_space_reflects_config() {
617617+ let (svc, _tmp) = fresh_service().await;
618618+ let info = svc
619619+ .create_space(
620620+ "did:plc:owner",
621621+ "app.bsky.group",
622622+ "default",
623623+ SpaceConfig::default(),
624624+ )
625625+ .await
626626+ .unwrap();
627627+ let uri = info.uri.parse::<SpaceUri>().unwrap();
628628+629629+ let patch = SpaceConfigPatch {
630630+ mint_policy: Some(crate::space::config::MintPolicy::Public),
631631+ app_access: Some(crate::space::config::AppAccess::AllowList {
632632+ allowed: vec!["c1".to_string()],
633633+ }),
634634+ managing_app: Some("did:web:m.example#svc".to_string()),
635635+ };
636636+ svc.update_space("did:plc:owner", &uri, patch)
637637+ .await
638638+ .unwrap();
639639+640640+ let got = svc.get_space("did:plc:owner", &uri).await.unwrap();
641641+ assert_eq!(got.config["mintPolicy"], "public");
642642+ assert_eq!(got.config["managingApp"], "did:web:m.example#svc");
643643+ assert_eq!(
644644+ got.config["appAccess"]["$type"],
645645+ crate::space::config::APP_ACCESS_ALLOW_LIST_TYPE
646646+ );
647647+648648+ // Clearing managingApp with empty string drops the field.
649649+ let clear = SpaceConfigPatch {
650650+ managing_app: Some(String::new()),
651651+ ..Default::default()
652652+ };
653653+ svc.update_space("did:plc:owner", &uri, clear)
654654+ .await
655655+ .unwrap();
656656+ let got = svc.get_space("did:plc:owner", &uri).await.unwrap();
657657+ assert!(got.config.get("managingApp").is_none());
658658+ }
659659+660660+ #[tokio::test(flavor = "multi_thread")]
661661+ async fn load_mint_authz_inputs_reports_membership_and_config() {
662662+ let (svc, _tmp) = fresh_service().await;
663663+ let info = svc
664664+ .create_space(
665665+ "did:plc:owner",
666666+ "app.bsky.group",
667667+ "default",
668668+ SpaceConfig {
669669+ mint_policy: crate::space::config::MintPolicy::MemberList,
670670+ app_access: crate::space::config::AppAccess::AllowList {
671671+ allowed: vec!["https://app.example".to_string()],
672672+ },
673673+ managing_app: None,
674674+ },
675675+ )
676676+ .await
677677+ .unwrap();
678678+ let uri = info.uri.parse::<SpaceUri>().unwrap();
679679+680680+ // Owner is auto-added as the first member.
681681+ let owner_in = svc
682682+ .load_mint_authz_inputs(&uri, "did:plc:owner")
683683+ .await
684684+ .unwrap();
685685+ assert!(owner_in.found);
686686+ assert!(!owner_in.deleted);
687687+ assert!(owner_in.is_member);
688688+ assert_eq!(
689689+ owner_in.config.mint_policy,
690690+ crate::space::config::MintPolicy::MemberList
691691+ );
692692+693693+ // A stranger is not a member.
694694+ let stranger = svc
695695+ .load_mint_authz_inputs(&uri, "did:plc:stranger")
696696+ .await
697697+ .unwrap();
698698+ assert!(stranger.found);
699699+ assert!(!stranger.is_member);
700700+ }
701701+702702+ #[tokio::test(flavor = "multi_thread")]
703703+ async fn load_mint_authz_inputs_missing_space_not_found() {
704704+ let (svc, _tmp) = fresh_service().await;
705705+ // No createSpace — the owner store has no row for this URI.
706706+ let uri: SpaceUri = "ats://did:plc:owner/app.bsky.group/missing"
707707+ .parse()
708708+ .unwrap();
709709+ let inputs = svc
710710+ .load_mint_authz_inputs(&uri, "did:plc:owner")
711711+ .await
712712+ .unwrap();
713713+ assert!(!inputs.found);
714714+ assert!(!inputs.deleted);
715715+ }
716716+717717+ #[tokio::test(flavor = "multi_thread")]
718718+ async fn load_mint_authz_inputs_deleted_space_flagged() {
719719+ let (svc, _tmp) = fresh_service().await;
720720+ let info = svc
721721+ .create_space(
722722+ "did:plc:owner",
723723+ "app.bsky.group",
724724+ "default",
725725+ SpaceConfig::default(),
726726+ )
727727+ .await
728728+ .unwrap();
729729+ let uri = info.uri.parse::<SpaceUri>().unwrap();
730730+731731+ // Tombstone the row directly.
732732+ let store = SqlActorStore::open(svc.data_dir.as_path(), "did:plc:owner")
733733+ .await
734734+ .unwrap();
735735+ sqlx::query("UPDATE space SET deleted_at = ? WHERE uri = ?")
736736+ .bind(Utc::now().to_rfc3339())
737737+ .bind(uri.to_string())
738738+ .execute(store.pool())
739739+ .await
740740+ .unwrap();
741741+742742+ let inputs = svc
743743+ .load_mint_authz_inputs(&uri, "did:plc:owner")
744744+ .await
745745+ .unwrap();
746746+ assert!(inputs.found);
747747+ assert!(inputs.deleted);
748748+ }
749749+750750+ #[tokio::test(flavor = "multi_thread")]
751751+ async fn update_space_by_non_owner_rejected() {
752752+ let (svc, _tmp) = fresh_service().await;
753753+ let info = svc
754754+ .create_space(
755755+ "did:plc:owner",
756756+ "app.bsky.group",
757757+ "default",
758758+ SpaceConfig::default(),
759759+ )
760760+ .await
761761+ .unwrap();
762762+ let uri = info.uri.parse::<SpaceUri>().unwrap();
763763+ let result = svc
764764+ .update_space("did:plc:eve", &uri, SpaceConfigPatch::default())
765765+ .await;
766766+ assert!(matches!(result, Err(PdsError::NotSpaceOwner { .. })));
767767+ }
768768+769769+ #[tokio::test(flavor = "multi_thread")]
770770+ async fn delete_space_tombstones_reads_and_writes() {
771771+ let (svc, _tmp) = fresh_service().await;
772772+ let info = svc
773773+ .create_space(
774774+ "did:plc:owner",
775775+ "app.bsky.group",
776776+ "default",
777777+ SpaceConfig::default(),
778778+ )
779779+ .await
780780+ .unwrap();
781781+ let uri = info.uri.parse::<SpaceUri>().unwrap();
782782+783783+ // Seed an authority-owned record in the space so we can assert the
784784+ // authority's own repo is erased on delete (spec line 363).
785785+ {
786786+ let store = SqlActorStore::open(&svc.data_dir, "did:plc:owner")
787787+ .await
788788+ .unwrap();
789789+ sqlx::query(
790790+ "INSERT INTO space_record (space, collection, rkey, cid, value, repo_rev, indexed_at)
791791+ VALUES (?, 'app.bsky.feed.post', 'rk1', 'bafycid', X'00', '3jui', '2026-06-25T00:00:00Z')",
792792+ )
793793+ .bind(uri.to_string())
794794+ .execute(store.pool())
795795+ .await
796796+ .unwrap();
797797+ }
798798+799799+ svc.delete_space("did:plc:owner", &uri).await.unwrap();
800800+801801+ // getSpace now reports SpaceNotFound.
802802+ assert!(matches!(
803803+ svc.get_space("did:plc:owner", &uri).await,
804804+ Err(PdsError::SpaceNotFound { .. })
805805+ ));
806806+ // The authority's own repo data within the space is erased.
807807+ {
808808+ let store = SqlActorStore::open(&svc.data_dir, "did:plc:owner")
809809+ .await
810810+ .unwrap();
811811+ let remaining: i64 =
812812+ sqlx::query_scalar("SELECT COUNT(*) FROM space_record WHERE space = ?")
813813+ .bind(uri.to_string())
814814+ .fetch_one(store.pool())
815815+ .await
816816+ .unwrap();
817817+ assert_eq!(
818818+ remaining, 0,
819819+ "authority's own repo records should be purged"
820820+ );
821821+ }
822822+ // Member mutations are gated.
823823+ assert!(matches!(
824824+ svc.add_member("did:plc:owner", &uri, "did:plc:alice").await,
825825+ Err(PdsError::SpaceNotFound { .. })
826826+ ));
827827+ // listSpaces excludes the tombstoned space.
828828+ let owned = svc
829829+ .list_spaces("did:plc:owner", "owned", None, 10)
830830+ .await
831831+ .unwrap();
832832+ assert!(owned.is_empty());
833833+ // Re-deleting is idempotent.
834834+ svc.delete_space("did:plc:owner", &uri).await.unwrap();
835835+ }
836836+837837+ #[tokio::test(flavor = "multi_thread")]
838838+ async fn delete_unknown_space_is_not_found() {
839839+ let (svc, _tmp) = fresh_service().await;
840840+ let uri: SpaceUri = "ats://did:plc:owner/app.bsky.group/missing"
841841+ .parse()
842842+ .unwrap();
843843+ assert!(matches!(
844844+ svc.delete_space("did:plc:owner", &uri).await,
845845+ Err(PdsError::SpaceNotFound { .. })
846846+ ));
401847 }
402848403849 #[tokio::test(flavor = "multi_thread")]
404850 async fn add_then_list_members() {
405851 let (svc, _tmp) = fresh_service().await;
406852 let info = svc
407407- .create_space("did:plc:owner", "app.bsky.group", "default")
853853+ .create_space(
854854+ "did:plc:owner",
855855+ "app.bsky.group",
856856+ "default",
857857+ SpaceConfig::default(),
858858+ )
408859 .await
409860 .unwrap();
410861 let uri = info.uri.parse::<SpaceUri>().unwrap();
···420871 .list_members("did:plc:owner", &uri, None, 10)
421872 .await
422873 .unwrap();
423423- assert_eq!(page.members.len(), 2);
874874+ // createSpace seeds the owner as the first member, then add_member
875875+ // appends alice + bob.
876876+ assert_eq!(page.members.len(), 3);
424877 let dids: Vec<_> = page.members.iter().map(|m| m.did.clone()).collect();
878878+ assert!(dids.contains(&"did:plc:owner".to_string()));
425879 assert!(dids.contains(&"did:plc:alice".to_string()));
426880 assert!(dids.contains(&"did:plc:bob".to_string()));
427881 }
···430884 async fn add_member_by_non_owner_rejected() {
431885 let (svc, _tmp) = fresh_service().await;
432886 let info = svc
433433- .create_space("did:plc:owner", "app.bsky.group", "default")
887887+ .create_space(
888888+ "did:plc:owner",
889889+ "app.bsky.group",
890890+ "default",
891891+ SpaceConfig::default(),
892892+ )
434893 .await
435894 .unwrap();
436895 let uri = info.uri.parse::<SpaceUri>().unwrap();
437896 let result = svc.add_member("did:plc:eve", &uri, "did:plc:alice").await;
438438- assert!(matches!(result, Err(PdsError::AuthDenied { .. })));
897897+ assert!(matches!(result, Err(PdsError::NotSpaceOwner { .. })));
439898 }
440899441900 #[tokio::test(flavor = "multi_thread")]
442901 async fn remove_member_round_trip() {
443902 let (svc, _tmp) = fresh_service().await;
444903 let info = svc
445445- .create_space("did:plc:owner", "app.bsky.group", "default")
904904+ .create_space(
905905+ "did:plc:owner",
906906+ "app.bsky.group",
907907+ "default",
908908+ SpaceConfig::default(),
909909+ )
446910 .await
447911 .unwrap();
448912 let uri = info.uri.parse::<SpaceUri>().unwrap();
···456920 .list_members("did:plc:owner", &uri, None, 10)
457921 .await
458922 .unwrap();
459459- assert_eq!(page.members.len(), 0);
923923+ // Owner remains after alice is removed.
924924+ assert_eq!(page.members.len(), 1);
925925+ assert_eq!(page.members[0].did, "did:plc:owner");
460926 }
461927462928 #[tokio::test(flavor = "multi_thread")]
463929 async fn list_spaces_filters() {
464930 let (svc, _tmp) = fresh_service().await;
465465- svc.create_space("did:plc:owner", "app.bsky.group", "a")
466466- .await
467467- .unwrap();
468468- svc.create_space("did:plc:owner", "app.bsky.group", "b")
469469- .await
470470- .unwrap();
931931+ svc.create_space(
932932+ "did:plc:owner",
933933+ "app.bsky.group",
934934+ "a",
935935+ SpaceConfig::default(),
936936+ )
937937+ .await
938938+ .unwrap();
939939+ svc.create_space(
940940+ "did:plc:owner",
941941+ "app.bsky.group",
942942+ "b",
943943+ SpaceConfig::default(),
944944+ )
945945+ .await
946946+ .unwrap();
471947 let owned = svc
472948 .list_spaces("did:plc:owner", "owned", None, 10)
473949 .await
+244
crates/atproto-pds/src/space/service_auth.rs
···11+//! Inter-PDS service-auth JWTs for the Spaces notify path.
22+//!
33+//! `notifyWrite` and `notifySpaceDeleted` are authenticated with AT Protocol
44+//! **service auth** (the same short-lived bearer the reference's
55+//! `authVerifier.serviceAuth` accepts): a compact JWS signed by the issuer's
66+//! `#atproto` signing key with `iss` / `aud` / `lxm` / `iat` / `exp` / `jti`
77+//! claims.
88+//!
99+//! This module provides:
1010+//! - [`mint_service_auth`] — sign a service-auth JWT with a local account's
1111+//! private signing key (writer side, before POSTing notifyWrite to the owner
1212+//! PDS).
1313+//! - [`verify_service_auth`] — verify an inbound service-auth bearer by
1414+//! resolving the `iss` DID document's `#atproto` key, checking the signature,
1515+//! `aud`, `lxm`, and `exp`.
1616+1717+use crate::errors::{PdsError, PdsResult};
1818+use atproto_identity::key::{
1919+ KeyData, identify_key, jws_alg, sign as identity_sign, validate as identity_validate,
2020+};
2121+use atproto_identity::model::VerificationMethod;
2222+use base64::{Engine as _, engine::general_purpose};
2323+use rand::RngExt;
2424+use serde::{Deserialize, Serialize};
2525+use std::time::{SystemTime, UNIX_EPOCH};
2626+2727+/// `typ` header value for service-auth JWTs.
2828+pub const TYP_SERVICE_AUTH: &str = "at+jwt";
2929+3030+/// Default TTL for a minted notify service-auth token (60s).
3131+pub const NOTIFY_SERVICE_AUTH_TTL_SECS: u64 = 60;
3232+3333+/// Service-auth JWT header.
3434+#[derive(Debug, Serialize)]
3535+struct JwtHeader {
3636+ alg: String,
3737+ typ: String,
3838+}
3939+4040+/// Service-auth JWT claims. `iss`/`aud` are DIDs; `lxm` scopes the token to a
4141+/// single XRPC method.
4242+#[derive(Debug, Clone, Serialize, Deserialize)]
4343+pub struct ServiceAuthClaims {
4444+ /// Issuer DID (the signer).
4545+ pub iss: String,
4646+ /// Audience DID (the receiving service).
4747+ pub aud: String,
4848+ /// NSID of the lexicon method this token is scoped to.
4949+ #[serde(skip_serializing_if = "Option::is_none")]
5050+ pub lxm: Option<String>,
5151+ /// Issued-at (epoch seconds).
5252+ pub iat: u64,
5353+ /// Expiry (epoch seconds).
5454+ pub exp: u64,
5555+ /// Random nonce.
5656+ pub jti: String,
5757+}
5858+5959+fn now_secs() -> u64 {
6060+ SystemTime::now()
6161+ .duration_since(UNIX_EPOCH)
6262+ .unwrap_or_default()
6363+ .as_secs()
6464+}
6565+6666+fn b64url(bytes: &[u8]) -> String {
6767+ general_purpose::URL_SAFE_NO_PAD.encode(bytes)
6868+}
6969+7070+fn random_jti() -> String {
7171+ let mut bytes = [0u8; 16];
7272+ rand::rng().fill(&mut bytes);
7373+ b64url(&bytes)
7474+}
7575+7676+/// Mint a service-auth JWT signed by `signing_key` (a private atproto signing
7777+/// key), bound to `iss` / `aud` / `lxm`, valid for `ttl_secs`.
7878+///
7979+/// # Errors
8080+/// Returns [`PdsError::Storage`] on a JSON-encode or signing failure.
8181+pub fn mint_service_auth(
8282+ signing_key: &KeyData,
8383+ iss: &str,
8484+ aud: &str,
8585+ lxm: &str,
8686+ ttl_secs: u64,
8787+) -> PdsResult<String> {
8888+ let iat = now_secs();
8989+ let header = JwtHeader {
9090+ alg: jws_alg(signing_key).to_string(),
9191+ typ: TYP_SERVICE_AUTH.to_string(),
9292+ };
9393+ let claims = ServiceAuthClaims {
9494+ iss: iss.to_string(),
9595+ aud: aud.to_string(),
9696+ lxm: Some(lxm.to_string()),
9797+ iat,
9898+ exp: iat + ttl_secs,
9999+ jti: random_jti(),
100100+ };
101101+ let header_bytes = serde_json::to_vec(&header).map_err(|e| PdsError::Storage {
102102+ reason: format!("encode service-auth header: {e}"),
103103+ })?;
104104+ let claims_bytes = serde_json::to_vec(&claims).map_err(|e| PdsError::Storage {
105105+ reason: format!("encode service-auth claims: {e}"),
106106+ })?;
107107+ let signing_input = format!("{}.{}", b64url(&header_bytes), b64url(&claims_bytes));
108108+ let sig =
109109+ identity_sign(signing_key, signing_input.as_bytes()).map_err(|e| PdsError::Storage {
110110+ reason: format!("sign service-auth token: {e}"),
111111+ })?;
112112+ Ok(format!("{}.{}", signing_input, b64url(&sig)))
113113+}
114114+115115+/// Verify an inbound service-auth `token`. Resolves the `iss` DID document's
116116+/// `#atproto` signing key, checks the signature over `header.payload`, then
117117+/// validates `aud == expected_aud`, `lxm == expected_lxm` (when the token
118118+/// carries one), and `exp` in the future.
119119+///
120120+/// Returns the verified claims on success.
121121+///
122122+/// # Errors
123123+/// Returns [`PdsError::AuthDenied`] for any verification failure (bad shape,
124124+/// unknown issuer, bad signature, wrong audience/method, or expiry).
125125+pub async fn verify_service_auth(
126126+ http: &reqwest::Client,
127127+ token: &str,
128128+ plc_directory_hostname: Option<&str>,
129129+ expected_aud: &str,
130130+ expected_lxm: &str,
131131+) -> PdsResult<ServiceAuthClaims> {
132132+ let mut parts = token.split('.');
133133+ let (Some(header_b64), Some(payload_b64), Some(sig_b64), None) =
134134+ (parts.next(), parts.next(), parts.next(), parts.next())
135135+ else {
136136+ return Err(deny("malformed service-auth token"));
137137+ };
138138+ let payload_bytes = general_purpose::URL_SAFE_NO_PAD
139139+ .decode(payload_b64.as_bytes())
140140+ .map_err(|_| deny("service-auth payload not base64url"))?;
141141+ let claims: ServiceAuthClaims = serde_json::from_slice(&payload_bytes)
142142+ .map_err(|_| deny("service-auth payload not JSON"))?;
143143+144144+ // Claim checks before the (more expensive) DID-document resolution.
145145+ if claims.aud != expected_aud {
146146+ return Err(deny(&format!(
147147+ "service-auth aud mismatch: token={}, expected={}",
148148+ claims.aud, expected_aud
149149+ )));
150150+ }
151151+ if let Some(lxm) = claims.lxm.as_deref()
152152+ && lxm != expected_lxm
153153+ {
154154+ return Err(deny(&format!(
155155+ "service-auth lxm mismatch: token={lxm}, expected={expected_lxm}"
156156+ )));
157157+ }
158158+ if claims.exp <= now_secs() {
159159+ return Err(deny("service-auth token expired"));
160160+ }
161161+162162+ let key = atproto_signing_key(http, &claims.iss, plc_directory_hostname)
163163+ .await
164164+ .map_err(|e| deny(&format!("resolve issuer signing key: {e}")))?;
165165+ let sig = general_purpose::URL_SAFE_NO_PAD
166166+ .decode(sig_b64.as_bytes())
167167+ .map_err(|_| deny("service-auth signature not base64url"))?;
168168+ let signing_input = format!("{header_b64}.{payload_b64}");
169169+ identity_validate(&key, &sig, signing_input.as_bytes())
170170+ .map_err(|_| deny("service-auth signature invalid"))?;
171171+ Ok(claims)
172172+}
173173+174174+fn deny(reason: &str) -> PdsError {
175175+ PdsError::AuthDenied {
176176+ reason: reason.to_string(),
177177+ }
178178+}
179179+180180+/// Resolve a DID's `#atproto` Multikey signing key via its DID document.
181181+async fn atproto_signing_key(
182182+ http: &reqwest::Client,
183183+ did: &str,
184184+ plc_directory_hostname: Option<&str>,
185185+) -> anyhow::Result<KeyData> {
186186+ use atproto_identity::plc::query as plc_query;
187187+ use atproto_identity::web::query as web_query;
188188+ let document = if did.starts_with("did:plc:") {
189189+ let host = plc_directory_hostname.unwrap_or("plc.directory");
190190+ plc_query(http, host, did).await?
191191+ } else if did.starts_with("did:web:") {
192192+ web_query(http, did).await?
193193+ } else {
194194+ anyhow::bail!("unsupported DID method for service-auth verification: {did}");
195195+ };
196196+ for method in &document.verification_method {
197197+ if let VerificationMethod::Multikey {
198198+ id,
199199+ public_key_multibase,
200200+ ..
201201+ } = method
202202+ && id.ends_with("#atproto")
203203+ {
204204+ let did_key = if public_key_multibase.starts_with("did:key:") {
205205+ public_key_multibase.clone()
206206+ } else {
207207+ format!("did:key:{public_key_multibase}")
208208+ };
209209+ return Ok(identify_key(&did_key)?);
210210+ }
211211+ }
212212+ anyhow::bail!("DID document for {did} has no #atproto Multikey verification method")
213213+}
214214+215215+#[cfg(test)]
216216+mod tests {
217217+ use super::*;
218218+ use atproto_identity::key::{KeyType, generate_key};
219219+220220+ #[test]
221221+ fn mint_then_decode_round_trip() {
222222+ let key = generate_key(KeyType::P256Private).unwrap();
223223+ let token = mint_service_auth(
224224+ &key,
225225+ "did:plc:writer",
226226+ "did:plc:owner",
227227+ "com.atproto.space.notifyWrite",
228228+ 60,
229229+ )
230230+ .unwrap();
231231+ // 3 segments.
232232+ assert_eq!(token.split('.').count(), 3);
233233+ // Payload decodes with the expected claims.
234234+ let payload_b64 = token.split('.').nth(1).unwrap();
235235+ let bytes = general_purpose::URL_SAFE_NO_PAD
236236+ .decode(payload_b64.as_bytes())
237237+ .unwrap();
238238+ let claims: ServiceAuthClaims = serde_json::from_slice(&bytes).unwrap();
239239+ assert_eq!(claims.iss, "did:plc:writer");
240240+ assert_eq!(claims.aud, "did:plc:owner");
241241+ assert_eq!(claims.lxm.as_deref(), Some("com.atproto.space.notifyWrite"));
242242+ assert!(claims.exp > claims.iat);
243243+ }
244244+}
+31-85
crates/atproto-pds/src/space/sync.rs
···11//! `SpaceSync` — sync-side reads of state and oplog.
22//!
33-//! syncing apps poll:
44-//! - `getRepoState {space, member}` → `{set_hash, rev}` for the per-member
55-//! record commitment.
66-//! - `getRepoOplog {space, member, since?, limit?}` → ordered ops since `rev`.
77-//! - `getMemberState {space}` → owner-only member-list commitment.
88-//! - `getMemberOplog {space, since?, limit?}` → owner-only member-list ops.
33+//! Syncing apps poll:
44+//! - `getRepoState {space, repo}` → the per-account record commitment as a
55+//! [`RepoState`] (full 2048-byte SetHash state + rev); the HTTP layer signs
66+//! it into a `com.atproto.space.defs#signedCommit`.
77+//! - `listRepoOps {space, repo, since?, limit?}` → ordered ops since `rev`.
88+//!
99+//! This module returns the raw [`RepoState`] / [`OplogPage`]; commit signing
1010+//! (rehydrating [`PdsSetHash`] and building a signed commit) happens in
1111+//! `crate::http::space_handlers`, which holds the account signing keys.
912//!
1013//! Auth is performed at the HTTP layer; this struct just queries the
1111-//! per-actor SQLite store. The owner's per-actor store backs both member-list
1212-//! state and (for their own member-DID) record state. Per-member record
1313-//! state lives in *each member's* per-actor store (since they wrote it).
1414+//! per-actor SQLite store. Per-member record state lives in *each member's*
1515+//! per-actor store (since they wrote it).
1416//!
1515-//! / G35, **the PDS does not enforce membership at sync
1616-//! time** — consumers verify membership inductively from the owner's
1717+//! The 0016 Permissioned Data draft has no member commits or member-list sync:
1818+//! consumers learn the writer set from `listRepos`, not from a signed
1719//! member-list oplog.
18201919-use crate::actor_store::sql::{SqlActorStore, SqlSpaceMembersStorage, SqlSpaceRepoStorage};
2121+use crate::actor_store::sql::{SqlActorStore, SqlSpaceRepoStorage};
2022use crate::errors::{PdsError, PdsResult};
2123use crate::realm::PdsSetHash;
2222-use atproto_space::space_members::SpaceMembers;
2424+use crate::space::config::ensure_space_live;
2325use atproto_space::space_repo::SpaceRepo;
2424-use atproto_space::storage::{MemberState, OplogPage, RepoState};
2626+use atproto_space::storage::{OplogCursor, OplogPage, RepoState};
2527use atproto_space::types::SpaceUri;
2628use std::path::PathBuf;
2729···3739 Self { data_dir }
3840 }
39414040- /// `getRepoState` — current `{set_hash, rev}` for `(space, member)`'s
4141- /// record commitment. Reads from the *member's* per-actor store, since
4242- /// each member's writes live in their own store.
4343- pub async fn get_repo_state(&self, space: &SpaceUri, member_did: &str) -> PdsResult<RepoState> {
4444- let store = SqlActorStore::open(&self.data_dir, member_did).await?;
4242+ /// `getRepoState` — current `{set_hash, rev}` for `(space, repo)`'s
4343+ /// record commitment. Reads from the *repo account's* per-actor store,
4444+ /// since each account's writes live in their own store.
4545+ pub async fn get_repo_state(&self, space: &SpaceUri, repo_did: &str) -> PdsResult<RepoState> {
4646+ ensure_space_live(&self.data_dir, space).await?;
4747+ let store = SqlActorStore::open(&self.data_dir, repo_did).await?;
4548 let storage = SqlSpaceRepoStorage::new(store.pool().clone());
4649 let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> =
4750 SpaceRepo::new(space.clone(), storage);
4851 repo.current_state().await.map_err(PdsError::Space)
4952 }
50535151- /// `getRepoOplog` — per-member record oplog from `since` (exclusive),
5252- /// up to `limit` ops.
5353- pub async fn get_repo_oplog(
5454+ /// `listRepoOps` — per-repo record oplog strictly after the `(rev, idx)`
5555+ /// `since` cursor, up to `limit` ops.
5656+ pub async fn list_repo_ops(
5457 &self,
5558 space: &SpaceUri,
5656- member_did: &str,
5757- since: Option<&str>,
5959+ repo_did: &str,
6060+ since: Option<&OplogCursor>,
5861 limit: u32,
5962 ) -> PdsResult<OplogPage> {
6060- let store = SqlActorStore::open(&self.data_dir, member_did).await?;
6363+ let store = SqlActorStore::open(&self.data_dir, repo_did).await?;
6164 let storage = SqlSpaceRepoStorage::new(store.pool().clone());
6265 let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> =
6366 SpaceRepo::new(space.clone(), storage);
6467 repo.read_oplog(since, limit).await.map_err(PdsError::Space)
6568 }
6666-6767- /// `getMemberState` — owner-only member-list commitment.
6868- ///
6969- /// Reads from the owner's per-actor store. Caller is responsible for
7070- /// verifying that the request is authorized (e.g. owner-self-auth or
7171- /// member-with-credential).
7272- pub async fn get_member_state(&self, space: &SpaceUri) -> PdsResult<MemberState> {
7373- let store = SqlActorStore::open(&self.data_dir, &space.owner_did).await?;
7474- let storage = SqlSpaceMembersStorage::new(store.pool().clone());
7575- let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> =
7676- SpaceMembers::new(space.clone(), storage);
7777- members.current_state().await.map_err(PdsError::Space)
7878- }
7979-8080- /// `getMemberOplog` — owner-only member-list oplog.
8181- pub async fn get_member_oplog(
8282- &self,
8383- space: &SpaceUri,
8484- since: Option<&str>,
8585- limit: u32,
8686- ) -> PdsResult<OplogPage> {
8787- let store = SqlActorStore::open(&self.data_dir, &space.owner_did).await?;
8888- let storage = SqlSpaceMembersStorage::new(store.pool().clone());
8989- let members: SpaceMembers<SqlSpaceMembersStorage, PdsSetHash> =
9090- SpaceMembers::new(space.clone(), storage);
9191- members
9292- .read_oplog(since, limit)
9393- .await
9494- .map_err(PdsError::Space)
9595- }
9669}
97709871#[cfg(test)]
···10073 use super::*;
10174 use crate::account::{AccountDirectory, AccountManager, CreateAccountParams};
10275 use crate::keys::{KeyStore, MemoryKeyStore};
103103- use crate::space::service::SpaceService;
10476 use crate::space::writer::{SpaceWriteAction, SpaceWriteOp, SpaceWriter};
10577 use atproto_identity::key::KeyType;
10678 use atproto_space::types::{SpaceKey, SpaceType};
···184156 assert!(state.rev.is_some());
185157186158 let oplog = sync
187187- .get_repo_oplog(&uri, "did:plc:alice", None, 100)
159159+ .list_repo_ops(&uri, "did:plc:alice", None, 100)
188160 .await
189161 .unwrap();
190162 assert_eq!(oplog.ops.len(), 1);
···192164 }
193165194166 #[tokio::test(flavor = "multi_thread")]
195195- async fn member_oplog_observes_add_remove() {
196196- let tmp = TempDir::new().unwrap();
197197- let dir = tmp.path().to_path_buf();
198198- let _manager = fresh_manager(&dir).await;
199199- let svc = SpaceService::new(dir.clone());
200200- svc.create_space("did:plc:owner", "app.bsky.group", "default")
201201- .await
202202- .unwrap();
203203- let uri = test_space();
204204- svc.add_member("did:plc:owner", &uri, "did:plc:alice")
205205- .await
206206- .unwrap();
207207- svc.remove_member("did:plc:owner", &uri, "did:plc:alice")
208208- .await
209209- .unwrap();
210210-211211- let sync = SpaceSync::new(dir);
212212- let state = sync.get_member_state(&uri).await.unwrap();
213213- assert!(state.set_hash.is_some());
214214-215215- let oplog = sync.get_member_oplog(&uri, None, 100).await.unwrap();
216216- assert_eq!(oplog.ops.len(), 2);
217217- assert_eq!(oplog.ops[0].action, "add");
218218- assert_eq!(oplog.ops[1].action, "remove");
219219- }
220220-221221- #[tokio::test(flavor = "multi_thread")]
222167 async fn since_filter_excludes_prior_revs() {
223168 let tmp = TempDir::new().unwrap();
224169 let dir = tmp.path().to_path_buf();
···253198 .unwrap();
254199255200 let sync = SpaceSync::new(dir);
201201+ let cursor = OplogCursor::new(first.rev.clone(), 0);
256202 let oplog = sync
257257- .get_repo_oplog(&uri, "did:plc:alice", Some(&first.rev), 100)
203203+ .list_repo_ops(&uri, "did:plc:alice", Some(&cursor), 100)
258204 .await
259205 .unwrap();
260206 // Only the second commit's ops should be returned.
+360-38
crates/atproto-pds/src/space/writer.rs
···11//! `SpaceWriter` — permissioned-record CRUD via per-(DID, space) lock.
22//!
33-//! each write batch
44-//! holds a per-(member-DID, space-URI) async mutex, computes a new SetHash,
55-//! signs the commit (HKDF + HMAC + ECDSA via `atproto-space::create_commit`),
66-//! and atomically persists changes + oplog. **/ G35**, the
33+//! Each write batch holds a per-(member-DID, space-URI) async mutex, computes a
44+//! new SetHash, signs the commit (HKDF + HMAC + ECDSA via
55+//! `atproto-space::create_commit`), and atomically persists changes + oplog. The
76//! PDS does not enforce membership at write time; consumers check at sync.
8798use crate::actor_store::sql::{SqlActorStore, SqlSpaceRepoStorage};
109use crate::errors::{PdsError, PdsResult};
1110use crate::realm::PdsSetHash;
1212-use crate::space::notify::{NotifyWritePayload, enqueue_writes, op_to_notify};
1111+use crate::space::config::ensure_space_live;
1212+use crate::space::notify::{NOTIFY_WRITE_NSID, NotifyWritePayload};
1313+use crate::space::service_auth::{NOTIFY_SERVICE_AUTH_TTL_SECS, mint_service_auth};
1414+use atproto_identity::key::KeyData;
1315use atproto_record::tid::Tid;
1414-use atproto_space::commit::{CommitScope, SpaceContext, create_commit};
1616+use atproto_space::commit::{SpaceContext, create_commit};
1517use atproto_space::space_repo::{Op, OpAction, SpaceRepo};
1618use atproto_space::types::SpaceUri;
1719use dashmap::DashMap;
···5557 pub set_hash: String,
5658 /// Per-op AT-URIs (`ats://` form).
5759 pub uris: Vec<String>,
6060+ /// Per-op record CIDs, parallel to `uris`. `None` for delete ops.
6161+ pub cids: Vec<Option<String>>,
5862}
59636064type WriteLocks = Arc<DashMap<(String, String), Arc<Mutex<()>>>>;
···6468 data_dir: PathBuf,
6569 accounts: Arc<crate::account::AccountManager>,
6670 locks: WriteLocks,
7171+ /// PLC directory hostname for resolving the owner PDS endpoint on the
7272+ /// outbound `notifyWrite` hop. `None` uses the upstream default.
7373+ plc_directory: Option<String>,
6774}
68756976impl SpaceWriter {
···7380 data_dir,
7481 accounts,
7582 locks: Arc::new(DashMap::new()),
8383+ plc_directory: None,
7684 }
7785 }
78868787+ /// Set the PLC directory hostname used to resolve the owner PDS endpoint
8888+ /// for the outbound `notifyWrite` hop (HOP 1, writer PDS → owner PDS).
8989+ #[must_use]
9090+ pub fn with_plc_directory(mut self, plc_directory: Option<String>) -> Self {
9191+ self.plc_directory = plc_directory;
9292+ self
9393+ }
9494+7995 fn lock_for(&self, did: &str, uri: &SpaceUri) -> Arc<Mutex<()>> {
8096 self.locks
8197 .entry((did.to_string(), uri.to_string()))
···100116 let lock = self.lock_for(member_did, space);
101117 let _guard = lock.lock().await;
102118119119+ ensure_space_live(&self.data_dir, space).await?;
103120 let store = SqlActorStore::open(&self.data_dir, member_did).await?;
104121 let storage = SqlSpaceRepoStorage::new(store.pool().clone());
105122 let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> =
106123 SpaceRepo::new(space.clone(), storage);
107124125125+ self.apply_writes_locked(member_did, space, &repo, ops)
126126+ .await
127127+ }
128128+129129+ /// `createRecord` — single-op `Create`. Errors if the record already
130130+ /// exists. An empty `rkey` auto-generates a TID.
131131+ pub async fn create_record(
132132+ &self,
133133+ member_did: &str,
134134+ space: &SpaceUri,
135135+ collection: String,
136136+ rkey: String,
137137+ value: serde_json::Value,
138138+ ) -> PdsResult<SpaceCommitResult> {
139139+ self.apply_writes(
140140+ member_did,
141141+ space,
142142+ vec![SpaceWriteOp {
143143+ action: SpaceWriteAction::Create,
144144+ collection,
145145+ rkey,
146146+ value: Some(value),
147147+ }],
148148+ )
149149+ .await
150150+ }
151151+152152+ /// `putRecord` — single-op create-or-update. Resolves to `Update` when
153153+ /// a record already exists at `(collection, rkey)`, else `Create`. The
154154+ /// existence check runs under the per-(member, space) lock so the chosen
155155+ /// action cannot race a concurrent write.
156156+ pub async fn put_record(
157157+ &self,
158158+ member_did: &str,
159159+ space: &SpaceUri,
160160+ collection: String,
161161+ rkey: String,
162162+ value: serde_json::Value,
163163+ ) -> PdsResult<SpaceCommitResult> {
164164+ let lock = self.lock_for(member_did, space);
165165+ let _guard = lock.lock().await;
166166+167167+ ensure_space_live(&self.data_dir, space).await?;
168168+ let store = SqlActorStore::open(&self.data_dir, member_did).await?;
169169+ let storage = SqlSpaceRepoStorage::new(store.pool().clone());
170170+ let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> =
171171+ SpaceRepo::new(space.clone(), storage);
172172+173173+ let exists = repo
174174+ .get_record(&collection, &rkey)
175175+ .await
176176+ .map_err(space_err)?
177177+ .is_some();
178178+ let action = if exists {
179179+ SpaceWriteAction::Update
180180+ } else {
181181+ SpaceWriteAction::Create
182182+ };
183183+ self.apply_writes_locked(
184184+ member_did,
185185+ space,
186186+ &repo,
187187+ vec![SpaceWriteOp {
188188+ action,
189189+ collection,
190190+ rkey,
191191+ value: Some(value),
192192+ }],
193193+ )
194194+ .await
195195+ }
196196+197197+ /// `deleteRecord` — single-op delete that is idempotent: when no record
198198+ /// exists at `(collection, rkey)` it is a no-op returning the current
199199+ /// `{rev, set_hash}` rather than erroring. The existence check runs under
200200+ /// the per-(member, space) lock.
201201+ pub async fn delete_record(
202202+ &self,
203203+ member_did: &str,
204204+ space: &SpaceUri,
205205+ collection: String,
206206+ rkey: String,
207207+ ) -> PdsResult<SpaceCommitResult> {
208208+ let lock = self.lock_for(member_did, space);
209209+ let _guard = lock.lock().await;
210210+211211+ ensure_space_live(&self.data_dir, space).await?;
212212+ let store = SqlActorStore::open(&self.data_dir, member_did).await?;
213213+ let storage = SqlSpaceRepoStorage::new(store.pool().clone());
214214+ let repo: SpaceRepo<SqlSpaceRepoStorage, PdsSetHash> =
215215+ SpaceRepo::new(space.clone(), storage);
216216+217217+ let exists = repo
218218+ .get_record(&collection, &rkey)
219219+ .await
220220+ .map_err(space_err)?
221221+ .is_some();
222222+ if !exists {
223223+ // Idempotent no-op: report current state without a new commit.
224224+ let state = repo.current_state().await.map_err(space_err)?;
225225+ let uri = format!(
226226+ "ats://{}/{}/{}/{}/{}/{}",
227227+ space.space_did, space.space_type, space.space_key, member_did, collection, rkey
228228+ );
229229+ return Ok(SpaceCommitResult {
230230+ rev: state.rev.unwrap_or_default(),
231231+ set_hash: state.set_hash.map(hex::encode).unwrap_or_default(),
232232+ uris: vec![uri],
233233+ cids: vec![None],
234234+ });
235235+ }
236236+ self.apply_writes_locked(
237237+ member_did,
238238+ space,
239239+ &repo,
240240+ vec![SpaceWriteOp {
241241+ action: SpaceWriteAction::Delete,
242242+ collection,
243243+ rkey,
244244+ value: None,
245245+ }],
246246+ )
247247+ .await
248248+ }
249249+250250+ /// Commit a batch of writes against an already-opened `repo`, with the
251251+ /// per-(member, space) lock already held by the caller. Shared by
252252+ /// `apply_writes` and the single-op `createRecord` / `putRecord` /
253253+ /// `deleteRecord` wrappers.
254254+ async fn apply_writes_locked(
255255+ &self,
256256+ member_did: &str,
257257+ space: &SpaceUri,
258258+ repo: &SpaceRepo<SqlSpaceRepoStorage, PdsSetHash>,
259259+ ops: Vec<SpaceWriteOp>,
260260+ ) -> PdsResult<SpaceCommitResult> {
108261 // Translate ops + auto-generate TIDs for empty rkeys on Create.
109262 let mut translated = Vec::with_capacity(ops.len());
110263 let mut output_uris = Vec::with_capacity(ops.len());
264264+ let mut output_cids = Vec::with_capacity(ops.len());
111265 for op in ops {
112266 let rkey = if matches!(op.action, SpaceWriteAction::Create) && op.rkey.is_empty() {
113267 Tid::new().to_string()
114268 } else {
115269 op.rkey.clone()
116270 };
271271+ // Six-segment permissioned record URI, including the author DID:
272272+ // ats://<spaceDid>/<spaceType>/<skey>/<authorDid>/<collection>/<rkey>.
273273+ // The author segment is required — records are not colocated, so two
274274+ // members writing the same (collection, rkey) must not collide.
117275 output_uris.push(format!(
118118- "ats://{}/{}/{}/{}/{}",
119119- space.owner_did, space.space_type, space.space_key, op.collection, rkey
276276+ "ats://{}/{}/{}/{}/{}/{}",
277277+ space.space_did, space.space_type, space.space_key, member_did, op.collection, rkey
120278 ));
121279 // Compute the value's CID (from DAG-CBOR) for create/update.
122280 let (cid, value_bytes) = match op.action {
···132290 }
133291 SpaceWriteAction::Delete => (None, None),
134292 };
293293+ output_cids.push(cid.clone());
135294 translated.push(Op {
136295 action: match op.action {
137296 SpaceWriteAction::Create => OpAction::Create,
···149308 let rev = prepared.rev.clone();
150309 let set_hash_hex = hex::encode(&prepared.storage_commit.new_set_hash);
151310 let context = SpaceContext {
152152- space_did: space.owner_did.clone(),
153153- space_type: space.space_type.to_string(),
154154- space_key: space.space_key.to_string(),
155155- user_did: member_did.to_string(),
156156- scope: CommitScope::Records,
311311+ space: space.to_string(),
157312 rev: rev.clone(),
158313 };
159314···173328 .0;
174329 let signing_key = self.accounts.key_store().get(&key_ref).await?;
175330176176- // Sign the commit. We keep the result here because the signed Commit
177177- // is broadcast via `notifyWrite` to subscribed consumers (peers don't
178178- // see our durable storage, so they need the signed commit + ops to
179179- // apply on their side).
180180- let signed_commit =
331331+ // Sign the commit so the repo's signed state is persisted. The commit
332332+ // is no longer broadcast — `notifyWrite` is contentless per the
333333+ // published spec; consumers PULL ops via `listRepoOps`.
334334+ let _signed_commit =
181335 create_commit(&prepared.set_hash, &context, &signing_key).map_err(space_err)?;
182336183337 repo.apply_commit(prepared).await.map_err(space_err)?;
184338185185- // Fan out a `notifyWrite` to every registered recipient. Failures
186186- // here are logged but non-fatal — the commit is durable; redelivery
187187- // of stale missed notifications is a sync-side problem, not a write
188188- // failure. (The owner's catch-up oplog at `getRepoOplog` is the
189189- // authoritative source.)
190190- let payload = NotifyWritePayload {
191191- space: space.to_string(),
192192- member: member_did.to_string(),
193193- commit: signed_commit,
194194- ops: translated.iter().map(op_to_notify).collect(),
195195- };
196196- if let Err(e) = enqueue_writes(self.accounts.pool(), &self.data_dir, space, &payload).await
197197- {
198198- tracing::warn!(
199199- error = ?e,
200200- space = %space,
201201- member = %member_did,
202202- "notifyWrite enqueue failed; recipients may miss this commit until next catch-up sync"
203203- );
204204- }
339339+ // HOP 1 (writer PDS → owner PDS): announce that this repo advanced to
340340+ // `rev`. Contentless payload `{ space, repo, rev }`, service-auth
341341+ // signed by the writer's key. Best-effort: failures are logged but
342342+ // never fail the (already-durable) write. The owner-side inbound
343343+ // handler does the isMember check + fans out to registered recipients.
344344+ self.fire_notify_write(space, member_did, &rev, &signing_key)
345345+ .await;
205346206347 Ok(SpaceCommitResult {
207348 rev,
208349 set_hash: set_hash_hex,
209350 uris: output_uris,
351351+ cids: output_cids,
210352 })
211353 }
354354+355355+ /// HOP 1 of the reference two-hop notify path: the writer's PDS POSTs a
356356+ /// contentless `notifyWrite` `{ space, repo, rev }` to the OWNER's PDS,
357357+ /// authenticated with service auth (iss = writer DID, aud = owner DID).
358358+ ///
359359+ /// Resolution: owner DID (`space.space_did`) → DID document →
360360+ /// `#atproto_pds` service endpoint. Entirely best-effort — every failure
361361+ /// path is logged and swallowed so a write never fails on a missed
362362+ /// notification (the owner's `listRepoOps` is the authoritative catch-up
363363+ /// source).
364364+ async fn fire_notify_write(
365365+ &self,
366366+ space: &SpaceUri,
367367+ writer_did: &str,
368368+ rev: &str,
369369+ writer_signing_key: &KeyData,
370370+ ) {
371371+ let owner_did = space.space_did.clone();
372372+ let http = reqwest::Client::builder()
373373+ .timeout(std::time::Duration::from_secs(10))
374374+ .user_agent(crate::user_agent())
375375+ .build()
376376+ .unwrap_or_default();
377377+378378+ // Resolve the owner's PDS endpoint from their DID document.
379379+ let owner_pds = match crate::space::recipient::resolve_service_endpoint(
380380+ &http,
381381+ &format!("{owner_did}#atproto_pds"),
382382+ self.plc_directory.as_deref(),
383383+ )
384384+ .await
385385+ {
386386+ Ok(Some(ep)) => ep,
387387+ Ok(None) => {
388388+ tracing::debug!(
389389+ space = %space,
390390+ owner = %owner_did,
391391+ "notifyWrite: owner DID document has no #atproto_pds service; skipping fan-out hop"
392392+ );
393393+ return;
394394+ }
395395+ Err(e) => {
396396+ tracing::warn!(
397397+ error = ?e,
398398+ space = %space,
399399+ owner = %owner_did,
400400+ "notifyWrite: failed to resolve owner PDS endpoint; skipping fan-out hop"
401401+ );
402402+ return;
403403+ }
404404+ };
405405+406406+ let token = match mint_service_auth(
407407+ writer_signing_key,
408408+ writer_did,
409409+ &owner_did,
410410+ NOTIFY_WRITE_NSID,
411411+ NOTIFY_SERVICE_AUTH_TTL_SECS,
412412+ ) {
413413+ Ok(t) => t,
414414+ Err(e) => {
415415+ tracing::warn!(
416416+ error = ?e,
417417+ space = %space,
418418+ "notifyWrite: failed to mint service-auth token; skipping fan-out hop"
419419+ );
420420+ return;
421421+ }
422422+ };
423423+424424+ let payload = NotifyWritePayload {
425425+ space: space.to_string(),
426426+ repo: writer_did.to_string(),
427427+ rev: rev.to_string(),
428428+ };
429429+ let url = format!(
430430+ "{}/xrpc/{}",
431431+ owner_pds.trim_end_matches('/'),
432432+ NOTIFY_WRITE_NSID
433433+ );
434434+ match http
435435+ .post(&url)
436436+ .bearer_auth(&token)
437437+ .json(&payload)
438438+ .send()
439439+ .await
440440+ {
441441+ Ok(resp) if resp.status().is_success() => {
442442+ tracing::debug!(space = %space, owner = %owner_did, rev = %rev, "notifyWrite delivered to owner PDS");
443443+ }
444444+ Ok(resp) => {
445445+ tracing::warn!(
446446+ status = %resp.status(),
447447+ space = %space,
448448+ owner = %owner_did,
449449+ "notifyWrite: owner PDS rejected the notification (best-effort, ignored)"
450450+ );
451451+ }
452452+ Err(e) => {
453453+ tracing::warn!(
454454+ error = ?e,
455455+ space = %space,
456456+ owner = %owner_did,
457457+ "notifyWrite: transport error delivering to owner PDS (best-effort, ignored)"
458458+ );
459459+ }
460460+ }
461461+ }
212462}
213463214464fn space_err(err: atproto_space::SpaceError) -> PdsError {
···343593 .unwrap();
344594 let last_seg = result.uris[0].split('/').next_back().unwrap();
345595 assert_eq!(last_seg.len(), 13, "TID rkey is 13 chars");
596596+ }
597597+598598+ #[tokio::test(flavor = "multi_thread")]
599599+ async fn create_record_returns_uri_and_cid() {
600600+ let (w, _tmp, uri) = fresh_writer().await;
601601+ let result = w
602602+ .create_record(
603603+ "did:plc:alice",
604604+ &uri,
605605+ "c".to_string(),
606606+ "k".to_string(),
607607+ serde_json::json!({"v": 1}),
608608+ )
609609+ .await
610610+ .unwrap();
611611+ assert_eq!(result.uris.len(), 1);
612612+ assert!(result.uris[0].ends_with("/c/k"));
613613+ assert!(result.cids[0].is_some());
614614+ }
615615+616616+ #[tokio::test(flavor = "multi_thread")]
617617+ async fn put_record_creates_then_updates() {
618618+ let (w, _tmp, uri) = fresh_writer().await;
619619+ // First put creates.
620620+ let first = w
621621+ .put_record(
622622+ "did:plc:alice",
623623+ &uri,
624624+ "c".to_string(),
625625+ "k".to_string(),
626626+ serde_json::json!({"v": 1}),
627627+ )
628628+ .await
629629+ .unwrap();
630630+ // Second put updates the same rkey (does not error on existing).
631631+ let second = w
632632+ .put_record(
633633+ "did:plc:alice",
634634+ &uri,
635635+ "c".to_string(),
636636+ "k".to_string(),
637637+ serde_json::json!({"v": 2}),
638638+ )
639639+ .await
640640+ .unwrap();
641641+ assert_eq!(first.uris[0], second.uris[0]);
642642+ assert_ne!(first.cids[0], second.cids[0], "value changed → new CID");
643643+ }
644644+645645+ #[tokio::test(flavor = "multi_thread")]
646646+ async fn delete_record_is_idempotent() {
647647+ let (w, _tmp, uri) = fresh_writer().await;
648648+ // Delete on a non-existent record is a no-op (does not error).
649649+ let absent = w
650650+ .delete_record("did:plc:alice", &uri, "c".to_string(), "k".to_string())
651651+ .await
652652+ .unwrap();
653653+ assert!(absent.cids[0].is_none());
654654+655655+ // Create then delete succeeds.
656656+ w.create_record(
657657+ "did:plc:alice",
658658+ &uri,
659659+ "c".to_string(),
660660+ "k".to_string(),
661661+ serde_json::json!({"v": 1}),
662662+ )
663663+ .await
664664+ .unwrap();
665665+ w.delete_record("did:plc:alice", &uri, "c".to_string(), "k".to_string())
666666+ .await
667667+ .unwrap();
346668 }
347669}
+1-2
crates/atproto-pds/tests/http_phase2.rs
···8282 .unwrap();
8383 let status = response.status();
8484 let body = response.into_body().collect().await.unwrap().to_bytes();
8585- let value: serde_json::Value =
8686- serde_json::from_slice(&body).unwrap_or_else(|_| serde_json::Value::Null);
8585+ let value: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);
8786 (status, value)
8887}
8988
+987-435
crates/atproto-pds/tests/http_phase7_spaces.rs
···11-//! Phase 7 HTTP integration tests — `com.atproto.space.*` endpoints.
11+//! Phase 7 HTTP integration tests — the `com.atproto.space.*` and
22+//! `com.atproto.simplespace.*` surface, aligned to the published 0016
33+//! Permissioned Data spec.
44+//!
55+//! This suite exercises the spec-aligned wire shapes:
66+//! - `com.atproto.simplespace.createSpace` (`{did?, type, skey?, config?}` ->
77+//! `{uri}`), `updateSpace`, `deleteSpace`, `addMember`, `removeMember`,
88+//! `listMembers`.
99+//! - `com.atproto.space.getSpace` (`{uri, config}`), `listSpaces`.
1010+//! - `applyWrites` + single-op `createRecord` / `putRecord` / `deleteRecord`.
1111+//! - `getRecord` (`{uri, cid, value}`) / `listRecords` (keys-only
1212+//! `{collection, rkey, cid}`).
1313+//! - `getRepoState` / `listRepoOps` — signed-commit (`{commit:#signedCommit}`)
1414+//! sync surface (sig over `ctx` + `mac`-bound `hash`, spec lines 285-316).
1515+//! - `getDelegationToken` (GET -> `{token}`; spec lines 147-176) exchanged at
1616+//! `getSpaceCredential` (delegation token in the `Authorization` header +
1717+//! body `{space, clientAttestation?}` -> `{credential}`; spec lines 200-251)
1818+//! with mint authorization (member-list allow, non-member deny, `#allowList`
1919+//! app deny).
2020+//! - `getBlob` permissioned gating, `listRepos` (`{did, rev}`),
2121+//! `registerNotify`, `notifyWrite` (contentless `{space, repo, rev}`),
2222+//! `notifySpaceDeleted`.
223//!
33-//! Coverage:
44-//! - createSpace / getSpace / listSpaces (owner-OAuth gated)
55-//! - addMember / removeMember / getMembers (owner-OAuth, 403 for non-owner)
66-//! - applyWrites against a permissioned repo (member-OAuth)
77-//! - getRecord / listRecords (own-PDS OAuth + remote SpaceCredential paths)
88-//! - getRepoState / getRepoOplog / getMemberState / getMemberOplog
99-//! - getMemberGrant + getSpaceCredential round-trip
2424+//! Management endpoints authenticate with an app-password session token (the
2525+//! `space:` scope gate is a no-op for non-OAuth subjects). `getDelegationToken`
2626+//! requires an OAuth token carrying a `client_id`, so those flows mint an
2727+//! HS256 OAuth access token directly (mirroring `tests/dpop_enforcement.rs`).
10281129use atproto_identity::key::KeyType;
1230use atproto_pds::account::{AccountDirectory, AccountManager};
···1634use atproto_pds::space::{SpaceReader, SpaceService, SpaceSync, SpaceWriter};
1735use axum::body::Body;
1836use axum::http::{Request, StatusCode};
3737+use base64::Engine as _;
3838+use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
3939+use hmac::{Hmac, Mac};
1940use http_body_util::BodyExt;
2041use serde_json::{Value, json};
4242+use sha2::Sha256;
4343+use sha2::digest::KeyInit;
2144use std::sync::Arc;
2245use tempfile::TempDir;
2346use tower::ServiceExt;
24474848+type HmacSha256 = Hmac<Sha256>;
4949+5050+const JWT_SECRET: &[u8] = b"test-secret-do-not-use-in-prod-32!";
5151+const CLIENT_ID: &str = "https://app.example/client-metadata.json";
5252+5353+/// Build a fresh PDS app with the full Spaces wiring (service + writer +
5454+/// reader + sync). Returns the router and the owning `TempDir`.
2555async fn build_app() -> (axum::Router, TempDir) {
2656 let tmp = TempDir::new().unwrap();
2757 let dir = tmp.path().to_path_buf();
···3868 let writer = Arc::new(RepoWriter::new(manager.clone(), dir.clone()));
3969 let reader = Arc::new(RepoReader::new(accounts, dir.clone()));
40704141- // Phase 7 wiring — `with_accounts` enables the notifyMembership fan-out
4242- // path (signing the membership commit + enqueuing into `notify_attempt`).
4371 let svc = Arc::new(SpaceService::with_accounts(dir.clone(), manager.clone()));
4472 let space_writer = Arc::new(SpaceWriter::new(manager.clone(), dir.clone()));
4573 let space_reader = Arc::new(SpaceReader::new(manager.clone(), dir.clone()));
···4977 reader,
5078 manager,
5179 "did:web:test.example".to_string(),
5252- b"test-secret-do-not-use-in-prod-32!".to_vec(),
8080+ JWT_SECRET.to_vec(),
5381 false,
5482 )
5583 .with_writer(writer)
5684 .with_spaces(svc, space_writer, space_reader, space_sync);
57855858- let app = build_router(state);
5959- (app, tmp)
8686+ (build_router(state), tmp)
6087}
61888989+/// Create an account and return its app-password session access token.
6290async fn create_account_and_token(app: &axum::Router, did: &str, handle: &str) -> String {
6391 let req = Request::builder()
6492 .uri("/xrpc/com.atproto.server.createAccount")
···79107 body["accessJwt"].as_str().unwrap().to_string()
80108}
81109110110+/// Mint an OAuth access JWT (`typ=at-oauth-access`, HS256 over `JWT_SECRET`)
111111+/// with the supplied `scope`, no `cnf` (so no DPoP proof is required). Carries
112112+/// a `client_id`, which `getDelegationToken` needs.
113113+fn mint_oauth_access(sub: &str, scope: &str) -> String {
114114+ let header = json!({"alg": "HS256", "typ": "at-oauth-access"});
115115+ let now = chrono::Utc::now().timestamp() as u64;
116116+ let payload = json!({
117117+ "sub": sub,
118118+ "iss": "did:web:test.example",
119119+ "aud": "did:web:test.example",
120120+ "client_id": CLIENT_ID,
121121+ "scope": scope,
122122+ "iat": now,
123123+ "exp": now + 3600,
124124+ "jti": format!("oauth-jti-{sub}-{now}"),
125125+ });
126126+ let h = B64URL.encode(serde_json::to_vec(&header).unwrap());
127127+ let p = B64URL.encode(serde_json::to_vec(&payload).unwrap());
128128+ let signing_input = format!("{h}.{p}");
129129+ let mut mac = <HmacSha256 as KeyInit>::new_from_slice(JWT_SECRET).unwrap();
130130+ mac.update(signing_input.as_bytes());
131131+ format!(
132132+ "{}.{}",
133133+ signing_input,
134134+ B64URL.encode(mac.finalize().into_bytes())
135135+ )
136136+}
137137+138138+/// Mint a *real*, authority-signed space credential for `member_did` over
139139+/// `space_uri` by running the full `getDelegationToken` -> `getSpaceCredential`
140140+/// flow. `member_did` must already be authorized to mint (a member under the
141141+/// default member-list policy). Returns the compact credential JWT.
142142+async fn mint_space_credential(app: &axum::Router, member_did: &str, space_uri: &str) -> String {
143143+ let oauth = mint_oauth_access(member_did, &read_scope());
144144+ let (status, body) = get_json(
145145+ app.clone(),
146146+ &format!(
147147+ "/xrpc/com.atproto.space.getDelegationToken?space={}",
148148+ urlencode(space_uri)
149149+ ),
150150+ Some(&oauth),
151151+ )
152152+ .await;
153153+ assert_eq!(status, StatusCode::OK, "getDelegationToken: {body}");
154154+ let grant = body["token"].as_str().unwrap().to_string();
155155+ let (status, body) = exchange_credential(app, &grant, space_uri, None).await;
156156+ assert_eq!(status, StatusCode::OK, "getSpaceCredential: {body}");
157157+ body["credential"].as_str().unwrap().to_string()
158158+}
159159+160160+/// Forge a JWT that merely *classifies* as a space credential — correct
161161+/// `typ`/`kid` header so [`classify`] tags it `SpaceCredential`, plausible
162162+/// `iss`/`sub`/`exp` claims, but a bogus (non-cryptographic) signature. The
163163+/// host/sync read methods must reject this: it is never signed by the space
164164+/// authority's `#atproto_space` key.
165165+fn forge_space_credential(authority_did: &str, space_uri: &str) -> String {
166166+ let header = json!({
167167+ "alg": "ES256",
168168+ "typ": "atproto-space-credential+jwt",
169169+ "kid": "#atproto_space"
170170+ });
171171+ let now = chrono::Utc::now().timestamp() as u64;
172172+ let payload = json!({
173173+ "iss": authority_did,
174174+ "sub": space_uri,
175175+ "iat": now,
176176+ "exp": now + 3600,
177177+ });
178178+ let h = B64URL.encode(serde_json::to_vec(&header).unwrap());
179179+ let p = B64URL.encode(serde_json::to_vec(&payload).unwrap());
180180+ // 64-byte all-zero "signature" — structurally a P-256 sig, cryptographically
181181+ // invalid.
182182+ let sig = B64URL.encode([0u8; 64]);
183183+ format!("{h}.{p}.{sig}")
184184+}
185185+82186async fn post_json(
83187 app: axum::Router,
84188 path: &str,
···88192 let mut req = Request::builder()
89193 .uri(path)
90194 .method("POST")
9191- .header("content-type", "application/json");
195195+ .header("content-type", "application/json")
196196+ .header("host", "test.example");
92197 if let Some(t) = bearer {
93198 req = req.header("authorization", format!("Bearer {t}"));
94199 }
···103208}
104209105210async fn get_json(app: axum::Router, path: &str, bearer: Option<&str>) -> (StatusCode, Value) {
106106- let mut req = Request::builder().uri(path);
211211+ let mut req = Request::builder().uri(path).header("host", "test.example");
107212 if let Some(t) = bearer {
108213 req = req.header("authorization", format!("Bearer {t}"));
109214 }
···115220 (status, value)
116221}
117222223223+/// Split a compact JWT into its decoded `(header, payload)` JSON objects,
224224+/// without verifying the signature (the handlers verify; tests assert shape).
225225+fn decode_jwt(jwt: &str) -> (Value, Value) {
226226+ let mut parts = jwt.split('.');
227227+ let header_b64 = parts.next().expect("jwt header segment");
228228+ let payload_b64 = parts.next().expect("jwt payload segment");
229229+ assert!(parts.next().is_some(), "jwt must carry a signature segment");
230230+ let header: Value =
231231+ serde_json::from_slice(&B64URL.decode(header_b64).expect("decode jwt header"))
232232+ .expect("parse jwt header");
233233+ let payload: Value =
234234+ serde_json::from_slice(&B64URL.decode(payload_b64).expect("decode jwt payload"))
235235+ .expect("parse jwt payload");
236236+ (header, payload)
237237+}
238238+239239+/// Percent-encode a string for use in a query parameter.
240240+fn urlencode(s: &str) -> String {
241241+ s.chars()
242242+ .map(|c| match c {
243243+ 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
244244+ _ => format!("%{:02X}", c as u8),
245245+ })
246246+ .collect()
247247+}
248248+249249+/// Create a space via `simplespace.createSpace` and return its URI.
250250+async fn create_space(app: &axum::Router, owner_token: &str, skey: &str) -> String {
251251+ let (status, body) = post_json(
252252+ app.clone(),
253253+ "/xrpc/com.atproto.simplespace.createSpace",
254254+ json!({"type": "app.bsky.group", "skey": skey}),
255255+ Some(owner_token),
256256+ )
257257+ .await;
258258+ assert_eq!(status, StatusCode::OK, "createSpace failed: {body}");
259259+ body["uri"].as_str().unwrap().to_string()
260260+}
261261+262262+/// Exchange a delegation token for a space credential at
263263+/// `com.atproto.space.getSpaceCredential`.
264264+///
265265+/// Per the 0016 spec (lines 200-251) the delegation token is presented in the
266266+/// `Authorization: Bearer` header and the body carries the target `space` plus
267267+/// an optional `clientAttestation`. The response is `{credential}`.
268268+async fn exchange_credential(
269269+ app: &axum::Router,
270270+ delegation_token: &str,
271271+ space: &str,
272272+ client_attestation: Option<&str>,
273273+) -> (StatusCode, Value) {
274274+ let mut body = json!({ "space": space });
275275+ if let Some(att) = client_attestation {
276276+ body["clientAttestation"] = json!(att);
277277+ }
278278+ post_json(
279279+ app.clone(),
280280+ "/xrpc/com.atproto.space.getSpaceCredential",
281281+ body,
282282+ Some(delegation_token),
283283+ )
284284+ .await
285285+}
286286+287287+// ---------------------------------------------------------------------------
288288+// simplespace management
289289+// ---------------------------------------------------------------------------
290290+118291#[tokio::test(flavor = "multi_thread")]
119292async fn create_space_round_trip() {
120293 let (app, _tmp) = build_app().await;
···122295123296 let (status, body) = post_json(
124297 app.clone(),
125125- "/xrpc/com.atproto.space.createSpace",
126126- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
298298+ "/xrpc/com.atproto.simplespace.createSpace",
299299+ json!({"type": "app.bsky.group", "skey": "default"}),
127300 Some(&token),
128301 )
129302 .await;
130303 assert_eq!(status, StatusCode::OK, "body: {body}");
131304 let uri = body["uri"].as_str().unwrap();
132305 assert_eq!(uri, "ats://did:plc:owner/app.bsky.group/default");
133133- assert_eq!(body["isOwner"], true);
134306135135- // Look it up.
307307+ // getSpace returns {uri, config}; config carries the default mint policy.
136308 let (status, info) = get_json(
137309 app,
138310 &format!("/xrpc/com.atproto.space.getSpace?space={}", urlencode(uri)),
···141313 .await;
142314 assert_eq!(status, StatusCode::OK, "body: {info}");
143315 assert_eq!(info["uri"], uri);
316316+ assert_eq!(info["config"]["mintPolicy"], "member-list");
144317}
145318146319#[tokio::test(flavor = "multi_thread")]
···148321 let (app, _tmp) = build_app().await;
149322 let (status, _) = post_json(
150323 app,
151151- "/xrpc/com.atproto.space.createSpace",
152152- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
324324+ "/xrpc/com.atproto.simplespace.createSpace",
325325+ json!({"type": "app.bsky.group", "skey": "default"}),
153326 None,
154327 )
155328 .await;
···157330}
158331159332#[tokio::test(flavor = "multi_thread")]
160160-async fn add_then_list_members() {
333333+async fn create_space_auto_generates_skey() {
334334+ let (app, _tmp) = build_app().await;
335335+ let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
336336+ let (status, body) = post_json(
337337+ app,
338338+ "/xrpc/com.atproto.simplespace.createSpace",
339339+ json!({"type": "app.bsky.group"}),
340340+ Some(&token),
341341+ )
342342+ .await;
343343+ assert_eq!(status, StatusCode::OK, "body: {body}");
344344+ let uri = body["uri"].as_str().unwrap();
345345+ assert!(uri.starts_with("ats://did:plc:owner/app.bsky.group/"));
346346+ // The auto-generated skey is a non-empty TID.
347347+ assert!(uri.rsplit('/').next().unwrap().len() >= 10);
348348+}
349349+350350+#[tokio::test(flavor = "multi_thread")]
351351+async fn create_space_did_must_match_caller() {
352352+ let (app, _tmp) = build_app().await;
353353+ let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
354354+ let (status, _) = post_json(
355355+ app,
356356+ "/xrpc/com.atproto.simplespace.createSpace",
357357+ json!({"did": "did:plc:someoneelse", "type": "app.bsky.group", "skey": "x"}),
358358+ Some(&token),
359359+ )
360360+ .await;
361361+ assert_eq!(status, StatusCode::FORBIDDEN);
362362+}
363363+364364+#[tokio::test(flavor = "multi_thread")]
365365+async fn update_space_reflects_in_get_space() {
366366+ let (app, _tmp) = build_app().await;
367367+ let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
368368+ let uri = create_space(&app, &token, "default").await;
369369+370370+ let (status, _) = post_json(
371371+ app.clone(),
372372+ "/xrpc/com.atproto.simplespace.updateSpace",
373373+ json!({"space": uri, "mintPolicy": "public"}),
374374+ Some(&token),
375375+ )
376376+ .await;
377377+ assert_eq!(status, StatusCode::OK);
378378+379379+ let (status, info) = get_json(
380380+ app,
381381+ &format!("/xrpc/com.atproto.space.getSpace?space={}", urlencode(&uri)),
382382+ Some(&token),
383383+ )
384384+ .await;
385385+ assert_eq!(status, StatusCode::OK, "body: {info}");
386386+ assert_eq!(info["config"]["mintPolicy"], "public");
387387+}
388388+389389+#[tokio::test(flavor = "multi_thread")]
390390+async fn delete_space_then_get_space_fails() {
391391+ let (app, _tmp) = build_app().await;
392392+ let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
393393+ let uri = create_space(&app, &token, "default").await;
394394+395395+ let (status, _) = post_json(
396396+ app.clone(),
397397+ "/xrpc/com.atproto.simplespace.deleteSpace",
398398+ json!({"space": uri}),
399399+ Some(&token),
400400+ )
401401+ .await;
402402+ assert_eq!(status, StatusCode::OK);
403403+404404+ // getSpace on a tombstoned space reads as SpaceNotFound.
405405+ let (status, body) = get_json(
406406+ app.clone(),
407407+ &format!("/xrpc/com.atproto.space.getSpace?space={}", urlencode(&uri)),
408408+ Some(&token),
409409+ )
410410+ .await;
411411+ assert!(
412412+ status.is_client_error(),
413413+ "expected 4xx after delete: {body}"
414414+ );
415415+ assert_eq!(
416416+ body["error"], "SpaceNotFound",
417417+ "deleted space must read as SpaceNotFound: {body}"
418418+ );
419419+420420+ // The tombstone gate also blocks writes: applyWrites to a deleted space
421421+ // must fail with SpaceNotFound (the owner is a member, so this is the
422422+ // tombstone gate firing, not a membership rejection).
423423+ let (status, body) = post_json(
424424+ app,
425425+ "/xrpc/com.atproto.space.applyWrites",
426426+ json!({
427427+ "space": uri,
428428+ "writes": [{
429429+ "action": "create",
430430+ "collection": "app.bsky.group.message",
431431+ "rkey": "x",
432432+ "value": {"text": "hi"}
433433+ }]
434434+ }),
435435+ Some(&token),
436436+ )
437437+ .await;
438438+ assert!(
439439+ status.is_client_error(),
440440+ "expected 4xx writing to deleted space: {body}"
441441+ );
442442+ assert_eq!(
443443+ body["error"], "SpaceNotFound",
444444+ "writing to a deleted space must fail with SpaceNotFound: {body}"
445445+ );
446446+}
447447+448448+#[tokio::test(flavor = "multi_thread")]
449449+async fn create_space_seeds_owner_in_member_list() {
450450+ let (app, _tmp) = build_app().await;
451451+ let token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
452452+ let uri = create_space(&app, &token, "default").await;
453453+454454+ let (status, body) = get_json(
455455+ app,
456456+ &format!(
457457+ "/xrpc/com.atproto.simplespace.listMembers?space={}",
458458+ urlencode(&uri)
459459+ ),
460460+ Some(&token),
461461+ )
462462+ .await;
463463+ assert_eq!(status, StatusCode::OK, "body: {body}");
464464+ let members = body["members"].as_array().unwrap();
465465+ assert_eq!(members.len(), 1);
466466+ assert_eq!(members[0]["did"], "did:plc:owner");
467467+}
468468+469469+#[tokio::test(flavor = "multi_thread")]
470470+async fn add_remove_then_list_members() {
161471 let (app, _tmp) = build_app().await;
162472 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
163473 let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
474474+ let uri = create_space(&app, &owner_token, "default").await;
164475165165- post_json(
476476+ let (status, _) = post_json(
166477 app.clone(),
167167- "/xrpc/com.atproto.space.createSpace",
168168- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
478478+ "/xrpc/com.atproto.simplespace.addMember",
479479+ json!({"space": uri, "did": "did:plc:alice"}),
480480+ Some(&owner_token),
481481+ )
482482+ .await;
483483+ assert_eq!(status, StatusCode::OK);
484484+485485+ let (status, body) = get_json(
486486+ app.clone(),
487487+ &format!(
488488+ "/xrpc/com.atproto.simplespace.listMembers?space={}",
489489+ urlencode(&uri)
490490+ ),
169491 Some(&owner_token),
170492 )
171493 .await;
172172- let uri = "ats://did:plc:owner/app.bsky.group/default";
494494+ assert_eq!(status, StatusCode::OK, "body: {body}");
495495+ let dids: Vec<&str> = body["members"]
496496+ .as_array()
497497+ .unwrap()
498498+ .iter()
499499+ .map(|m| m["did"].as_str().unwrap())
500500+ .collect();
501501+ assert!(dids.contains(&"did:plc:owner"));
502502+ assert!(dids.contains(&"did:plc:alice"));
173503504504+ // Remove alice.
174505 let (status, _) = post_json(
175506 app.clone(),
176176- "/xrpc/com.atproto.space.addMember",
507507+ "/xrpc/com.atproto.simplespace.removeMember",
177508 json!({"space": uri, "did": "did:plc:alice"}),
178509 Some(&owner_token),
179510 )
···183514 let (status, body) = get_json(
184515 app,
185516 &format!(
186186- "/xrpc/com.atproto.space.getMembers?space={}",
187187- urlencode(uri)
517517+ "/xrpc/com.atproto.simplespace.listMembers?space={}",
518518+ urlencode(&uri)
188519 ),
189520 Some(&owner_token),
190521 )
191522 .await;
192523 assert_eq!(status, StatusCode::OK, "body: {body}");
193193- assert_eq!(body["members"].as_array().unwrap().len(), 1);
194194- assert_eq!(body["members"][0]["did"], "did:plc:alice");
524524+ let dids: Vec<&str> = body["members"]
525525+ .as_array()
526526+ .unwrap()
527527+ .iter()
528528+ .map(|m| m["did"].as_str().unwrap())
529529+ .collect();
530530+ assert!(!dids.contains(&"did:plc:alice"));
195531}
196532197533#[tokio::test(flavor = "multi_thread")]
···199535 let (app, _tmp) = build_app().await;
200536 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
201537 let eve_token = create_account_and_token(&app, "did:plc:eve", "eve.example").await;
202202-203203- post_json(
204204- app.clone(),
205205- "/xrpc/com.atproto.space.createSpace",
206206- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
207207- Some(&owner_token),
208208- )
209209- .await;
210210- let uri = "ats://did:plc:owner/app.bsky.group/default";
538538+ let uri = create_space(&app, &owner_token, "default").await;
211539212540 let (status, _) = post_json(
213541 app,
214214- "/xrpc/com.atproto.space.addMember",
542542+ "/xrpc/com.atproto.simplespace.addMember",
215543 json!({"space": uri, "did": "did:plc:alice"}),
216544 Some(&eve_token),
217545 )
···219547 assert_eq!(status, StatusCode::FORBIDDEN);
220548}
221549550550+// ---------------------------------------------------------------------------
551551+// applyWrites + single-op record writes + reads
552552+// ---------------------------------------------------------------------------
553553+222554#[tokio::test(flavor = "multi_thread")]
223555async fn apply_writes_then_read_back() {
224556 let (app, _tmp) = build_app().await;
225557 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
226558 let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
227227-228228- post_json(
229229- app.clone(),
230230- "/xrpc/com.atproto.space.createSpace",
231231- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
232232- Some(&owner_token),
233233- )
234234- .await;
235235- let uri = "ats://did:plc:owner/app.bsky.group/default";
236236-237237- // Add Alice as member (owner-side).
559559+ let uri = create_space(&app, &owner_token, "default").await;
238560 post_json(
239561 app.clone(),
240240- "/xrpc/com.atproto.space.addMember",
562562+ "/xrpc/com.atproto.simplespace.addMember",
241563 json!({"space": uri, "did": "did:plc:alice"}),
242564 Some(&owner_token),
243565 )
244566 .await;
245567246246- // Alice writes a record into the permissioned repo.
247568 let (status, body) = post_json(
248569 app.clone(),
249570 "/xrpc/com.atproto.space.applyWrites",
···263584 assert!(!body["rev"].as_str().unwrap().is_empty());
264585 assert!(!body["setHash"].as_str().unwrap().is_empty());
265586266266- // Read it back via Alice's OAuth.
587587+ // getRecord returns {uri, cid, value}.
267588 let (status, body) = get_json(
268589 app,
269590 &format!(
270591 "/xrpc/com.atproto.space.getRecord?space={}&collection=app.bsky.group.message&rkey=first",
271271- urlencode(uri)
592592+ urlencode(&uri)
272593 ),
273594 Some(&alice_token),
274595 )
275596 .await;
276597 assert_eq!(status, StatusCode::OK, "body: {body}");
277598 assert_eq!(body["value"]["text"], "hi");
599599+ assert!(body["cid"].as_str().is_some());
600600+ assert!(
601601+ body["uri"]
602602+ .as_str()
603603+ .unwrap()
604604+ .contains("app.bsky.group.message/first")
605605+ );
278606}
279607280608#[tokio::test(flavor = "multi_thread")]
281281-async fn get_repo_state_advances_with_writes() {
609609+async fn apply_writes_empty_batch_rejected() {
282610 let (app, _tmp) = build_app().await;
283611 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
284284- let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
285285- post_json(
612612+ let uri = create_space(&app, &owner_token, "default").await;
613613+ let (status, _) = post_json(
614614+ app,
615615+ "/xrpc/com.atproto.space.applyWrites",
616616+ json!({"space": uri, "writes": []}),
617617+ Some(&owner_token),
618618+ )
619619+ .await;
620620+ assert!(status.is_client_error());
621621+}
622622+623623+#[tokio::test(flavor = "multi_thread")]
624624+async fn create_put_delete_record_single_ops() {
625625+ let (app, _tmp) = build_app().await;
626626+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
627627+ let uri = create_space(&app, &owner_token, "default").await;
628628+629629+ // createRecord (owner writes to its own repo).
630630+ let (status, body) = post_json(
286631 app.clone(),
287287- "/xrpc/com.atproto.space.createSpace",
288288- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
632632+ "/xrpc/com.atproto.space.createRecord",
633633+ json!({
634634+ "space": uri,
635635+ "repo": "did:plc:owner",
636636+ "collection": "app.bsky.group.message",
637637+ "rkey": "r1",
638638+ "record": {"$type": "app.bsky.group.message", "text": "v1"}
639639+ }),
640640+ Some(&owner_token),
641641+ )
642642+ .await;
643643+ assert_eq!(status, StatusCode::OK, "createRecord: {body}");
644644+ assert!(body["cid"].as_str().is_some());
645645+ assert!(
646646+ body["uri"]
647647+ .as_str()
648648+ .unwrap()
649649+ .contains("app.bsky.group.message/r1")
650650+ );
651651+652652+ // putRecord updates the same record.
653653+ let (status, body) = post_json(
654654+ app.clone(),
655655+ "/xrpc/com.atproto.space.putRecord",
656656+ json!({
657657+ "space": uri,
658658+ "repo": "did:plc:owner",
659659+ "collection": "app.bsky.group.message",
660660+ "rkey": "r1",
661661+ "record": {"$type": "app.bsky.group.message", "text": "v2"}
662662+ }),
289663 Some(&owner_token),
290664 )
291665 .await;
292292- let uri = "ats://did:plc:owner/app.bsky.group/default";
666666+ assert_eq!(status, StatusCode::OK, "putRecord: {body}");
293667294294- // Empty state first.
295668 let (status, body) = get_json(
296669 app.clone(),
297670 &format!(
298298- "/xrpc/com.atproto.space.getRepoState?space={}&member=did:plc:alice",
299299- urlencode(uri)
671671+ "/xrpc/com.atproto.space.getRecord?space={}&collection=app.bsky.group.message&rkey=r1",
672672+ urlencode(&uri)
300673 ),
301301- Some(&alice_token),
674674+ Some(&owner_token),
302675 )
303676 .await;
304304- assert_eq!(status, StatusCode::OK);
305305- assert!(body["setHash"].is_null());
677677+ assert_eq!(status, StatusCode::OK, "getRecord: {body}");
678678+ assert_eq!(body["value"]["text"], "v2");
306679307307- // Write something, then state should be populated.
308308- post_json(
680680+ // deleteRecord removes it.
681681+ let (status, _) = post_json(
309682 app.clone(),
310310- "/xrpc/com.atproto.space.applyWrites",
683683+ "/xrpc/com.atproto.space.deleteRecord",
311684 json!({
312685 "space": uri,
313313- "writes": [{
314314- "action": "create",
315315- "collection": "c",
316316- "rkey": "k",
317317- "value": {"v": 1}
318318- }]
686686+ "repo": "did:plc:owner",
687687+ "collection": "app.bsky.group.message",
688688+ "rkey": "r1"
319689 }),
320320- Some(&alice_token),
690690+ Some(&owner_token),
321691 )
322692 .await;
323323- let (status, body) = get_json(
324324- app.clone(),
693693+ assert_eq!(status, StatusCode::OK);
694694+695695+ let (status, _) = get_json(
696696+ app,
325697 &format!(
326326- "/xrpc/com.atproto.space.getRepoState?space={}&member=did:plc:alice",
327327- urlencode(uri)
698698+ "/xrpc/com.atproto.space.getRecord?space={}&collection=app.bsky.group.message&rkey=r1",
699699+ urlencode(&uri)
328700 ),
329329- Some(&alice_token),
701701+ Some(&owner_token),
702702+ )
703703+ .await;
704704+ assert_eq!(status, StatusCode::NOT_FOUND);
705705+}
706706+707707+#[tokio::test(flavor = "multi_thread")]
708708+async fn create_record_repo_must_match_subject() {
709709+ let (app, _tmp) = build_app().await;
710710+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
711711+ let uri = create_space(&app, &owner_token, "default").await;
712712+ let (status, _) = post_json(
713713+ app,
714714+ "/xrpc/com.atproto.space.createRecord",
715715+ json!({
716716+ "space": uri,
717717+ "repo": "did:plc:someoneelse",
718718+ "collection": "c",
719719+ "record": {"$type": "c", "v": 1}
720720+ }),
721721+ Some(&owner_token),
330722 )
331723 .await;
332332- assert_eq!(status, StatusCode::OK, "body: {body}");
333333- assert!(body["setHash"].as_str().is_some());
334334- assert!(body["rev"].as_str().is_some());
724724+ assert_eq!(status, StatusCode::FORBIDDEN);
725725+}
726726+727727+#[tokio::test(flavor = "multi_thread")]
728728+async fn list_records_keys_only_paginated() {
729729+ let (app, _tmp) = build_app().await;
730730+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
731731+ let uri = create_space(&app, &owner_token, "default").await;
732732+733733+ for r in ["a", "b", "c"] {
734734+ post_json(
735735+ app.clone(),
736736+ "/xrpc/com.atproto.space.applyWrites",
737737+ json!({
738738+ "space": uri,
739739+ "writes": [{"action": "create", "collection": "c", "rkey": r, "value": {"k": r}}]
740740+ }),
741741+ Some(&owner_token),
742742+ )
743743+ .await;
744744+ }
335745336336- // Oplog should report 1 op.
337746 let (status, body) = get_json(
338747 app,
339748 &format!(
340340- "/xrpc/com.atproto.space.getRepoOplog?space={}&member=did:plc:alice",
341341- urlencode(uri)
749749+ "/xrpc/com.atproto.space.listRecords?space={}&collection=c&limit=2",
750750+ urlencode(&uri)
342751 ),
343343- Some(&alice_token),
752752+ Some(&owner_token),
344753 )
345754 .await;
346755 assert_eq!(status, StatusCode::OK, "body: {body}");
347347- assert_eq!(body["ops"].as_array().unwrap().len(), 1);
348348- assert_eq!(body["ops"][0]["action"], "create");
756756+ let records = body["records"].as_array().unwrap();
757757+ assert_eq!(records.len(), 2);
758758+ // Keys-only shape: {collection, rkey, cid} and NO value.
759759+ assert_eq!(records[0]["collection"], "c");
760760+ assert!(records[0]["rkey"].as_str().is_some());
761761+ assert!(records[0]["cid"].as_str().is_some());
762762+ assert!(records[0].get("value").is_none());
763763+ assert!(body["cursor"].as_str().is_some());
349764}
350765351766#[tokio::test(flavor = "multi_thread")]
352352-async fn member_grant_then_credential_then_read() {
767767+async fn list_records_across_all_collections() {
353768 let (app, _tmp) = build_app().await;
354769 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
355355- let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
770770+ let uri = create_space(&app, &owner_token, "default").await;
356771357357- post_json(
358358- app.clone(),
359359- "/xrpc/com.atproto.space.createSpace",
360360- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
772772+ for (collection, rkey) in [
773773+ ("app.bsky.group.message", "m1"),
774774+ ("app.bsky.group.message", "m2"),
775775+ ("app.bsky.group.like", "l1"),
776776+ ] {
777777+ post_json(
778778+ app.clone(),
779779+ "/xrpc/com.atproto.space.applyWrites",
780780+ json!({
781781+ "space": uri,
782782+ "writes": [{"action": "create", "collection": collection, "rkey": rkey, "value": {}}]
783783+ }),
784784+ Some(&owner_token),
785785+ )
786786+ .await;
787787+ }
788788+789789+ let (status, body) = get_json(
790790+ app,
791791+ &format!(
792792+ "/xrpc/com.atproto.space.listRecords?space={}",
793793+ urlencode(&uri)
794794+ ),
361795 Some(&owner_token),
362796 )
363797 .await;
364364- let uri = "ats://did:plc:owner/app.bsky.group/default";
798798+ assert_eq!(status, StatusCode::OK, "body: {body}");
799799+ assert_eq!(body["records"].as_array().unwrap().len(), 3);
800800+}
801801+802802+#[tokio::test(flavor = "multi_thread")]
803803+async fn get_record_oauth_with_repo_override() {
804804+ let (app, _tmp) = build_app().await;
805805+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
806806+ let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
807807+ let uri = create_space(&app, &owner_token, "default").await;
365808 post_json(
366809 app.clone(),
367367- "/xrpc/com.atproto.space.addMember",
810810+ "/xrpc/com.atproto.simplespace.addMember",
368811 json!({"space": uri, "did": "did:plc:alice"}),
369812 Some(&owner_token),
370813 )
371814 .await;
372815373373- // Alice writes a record (so there's something to credential-read later).
816816+ // Alice writes to her own per-actor store.
374817 post_json(
375818 app.clone(),
376819 "/xrpc/com.atproto.space.applyWrites",
377820 json!({
378821 "space": uri,
379379- "writes": [{
380380- "action": "create",
381381- "collection": "c",
382382- "rkey": "k",
383383- "value": {"v": "secret"}
384384- }]
822822+ "writes": [{"action": "create", "collection": "c", "rkey": "alice-1", "value": {"who": "alice"}}]
385823 }),
386824 Some(&alice_token),
387825 )
388826 .await;
389827390390- // Step 1: Alice mints a MemberGrant for client-id `app1`.
391391- let (status, body) = post_json(
828828+ // Owner reads with repo=alice and pulls from Alice's store.
829829+ let (status, body) = get_json(
392830 app.clone(),
393393- "/xrpc/com.atproto.space.getMemberGrant",
394394- json!({"space": uri, "clientId": "https://app.example/client-metadata.json"}),
395395- Some(&alice_token),
831831+ &format!(
832832+ "/xrpc/com.atproto.space.getRecord?space={}&collection=c&rkey=alice-1&repo=did:plc:alice",
833833+ urlencode(&uri)
834834+ ),
835835+ Some(&owner_token),
396836 )
397837 .await;
398838 assert_eq!(status, StatusCode::OK, "body: {body}");
399399- let grant = body["token"].as_str().unwrap().to_string();
839839+ assert_eq!(body["value"]["who"], "alice");
400840401401- // Step 2: App swaps the grant for a SpaceCredential at the owner's PDS.
402402- let (status, body) = post_json(
403403- app.clone(),
404404- "/xrpc/com.atproto.space.getSpaceCredential",
405405- json!({"grant": grant}),
406406- None, // No auth — the grant *is* the auth.
841841+ // Without repo, the owner's own store has no such record.
842842+ let (status, _) = get_json(
843843+ app,
844844+ &format!(
845845+ "/xrpc/com.atproto.space.getRecord?space={}&collection=c&rkey=alice-1",
846846+ urlencode(&uri)
847847+ ),
848848+ Some(&owner_token),
407849 )
408850 .await;
409409- assert_eq!(status, StatusCode::OK, "body: {body}");
410410- let credential = body["token"].as_str().unwrap().to_string();
851851+ assert_eq!(status, StatusCode::NOT_FOUND);
852852+}
411853412412- // Step 3: App reads a record from the *owner's* per-actor store using the
413413- // credential. (Note: this reads from the owner's store, which won't have
414414- // Alice's record. So we test the negative path here — a successful
415415- // verify but no record present at the owner's store.)
854854+// ---------------------------------------------------------------------------
855855+// Sync: getRepoState / listRepoOps (signed commit)
856856+// ---------------------------------------------------------------------------
857857+858858+#[tokio::test(flavor = "multi_thread")]
859859+async fn get_repo_state_signed_commit() {
860860+ let (app, _tmp) = build_app().await;
861861+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
862862+ let uri = create_space(&app, &owner_token, "default").await;
863863+864864+ // Empty repo: no commit yet.
416865 let (status, body) = get_json(
417866 app.clone(),
418867 &format!(
419419- "/xrpc/com.atproto.space.getRecord?space={}&collection=c&rkey=k",
420420- urlencode(uri)
868868+ "/xrpc/com.atproto.space.getRepoState?space={}&repo=did:plc:owner",
869869+ urlencode(&uri)
421870 ),
422422- Some(&credential),
871871+ Some(&owner_token),
423872 )
424873 .await;
425425- // Expect 404 since the owner hasn't written this record (Alice did, in
426426- // her own per-actor store). The credential auth path itself succeeded —
427427- // the request reached the reader and returned RecordNotFound.
428428- assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}");
429429-}
874874+ assert_eq!(status, StatusCode::OK, "body: {body}");
875875+ assert!(body.get("commit").is_none() || body["commit"].is_null());
430876431431-#[tokio::test(flavor = "multi_thread")]
432432-async fn applywrites_empty_batch_rejected() {
433433- let (app, _tmp) = build_app().await;
434434- let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
877877+ // Write, then a signed commit is present.
435878 post_json(
436879 app.clone(),
437437- "/xrpc/com.atproto.space.createSpace",
438438- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
880880+ "/xrpc/com.atproto.space.applyWrites",
881881+ json!({
882882+ "space": uri,
883883+ "writes": [{"action": "create", "collection": "c", "rkey": "k", "value": {"v": 1}}]
884884+ }),
885885+ Some(&owner_token),
886886+ )
887887+ .await;
888888+889889+ let (status, body) = get_json(
890890+ app.clone(),
891891+ &format!(
892892+ "/xrpc/com.atproto.space.getRepoState?space={}&repo=did:plc:owner",
893893+ urlencode(&uri)
894894+ ),
439895 Some(&owner_token),
440896 )
441897 .await;
442442- let uri = "ats://did:plc:owner/app.bsky.group/default";
443443- let (status, _) = post_json(
898898+ assert_eq!(status, StatusCode::OK, "body: {body}");
899899+ let commit = &body["commit"];
900900+ assert!(commit["rev"].as_str().is_some());
901901+ // Signed-commit byte fields are lex-data {"$bytes": <base64>}.
902902+ assert!(commit["hash"]["$bytes"].as_str().is_some());
903903+ assert!(commit["mac"]["$bytes"].as_str().is_some());
904904+ assert!(commit["ikm"]["$bytes"].as_str().is_some());
905905+ assert!(commit["sig"]["$bytes"].as_str().is_some());
906906+907907+ // listRepoOps: the single create op + a caught-up signed commit.
908908+ let (status, body) = get_json(
444909 app,
445445- "/xrpc/com.atproto.space.applyWrites",
446446- json!({"space": uri, "writes": []}),
910910+ &format!(
911911+ "/xrpc/com.atproto.space.listRepoOps?space={}&repo=did:plc:owner",
912912+ urlencode(&uri)
913913+ ),
447914 Some(&owner_token),
448915 )
449916 .await;
450450- // Either 400 InvalidRequest or 400 SpaceError — both communicate the
451451- // empty-batch problem. Just assert it's a 4xx.
452452- assert!(status.is_client_error());
917917+ assert_eq!(status, StatusCode::OK, "body: {body}");
918918+ let ops = body["ops"].as_array().unwrap();
919919+ assert_eq!(ops.len(), 1);
920920+ assert_eq!(ops[0]["collection"], "c");
921921+ assert_eq!(ops[0]["rkey"], "k");
922922+ assert!(ops[0]["cid"].as_str().is_some());
923923+ assert!(body["commit"]["sig"]["$bytes"].as_str().is_some());
924924+}
925925+926926+// ---------------------------------------------------------------------------
927927+// Credential flow: getDelegationToken -> getSpaceCredential, mint authz
928928+// ---------------------------------------------------------------------------
929929+930930+/// Read scope string covering the default space (`ats://did:plc:owner/app.bsky.group/default`).
931931+fn read_scope() -> String {
932932+ "atproto space:app.bsky.group?did=did:plc:owner&skey=default&action=read".to_string()
453933}
454934455935#[tokio::test(flavor = "multi_thread")]
456456-async fn list_records_returns_paginated() {
936936+async fn delegation_token_then_space_credential_member_allowed() {
457937 let (app, _tmp) = build_app().await;
458938 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
459459- let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
939939+ let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
940940+ let uri = create_space(&app, &owner_token, "default").await;
460941 post_json(
461942 app.clone(),
462462- "/xrpc/com.atproto.space.createSpace",
463463- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
943943+ "/xrpc/com.atproto.simplespace.addMember",
944944+ json!({"space": uri, "did": "did:plc:alice"}),
464945 Some(&owner_token),
465946 )
466947 .await;
467467- let uri = "ats://did:plc:owner/app.bsky.group/default";
468468- post_json(
948948+949949+ // Alice mints a delegation token via OAuth (needs a client_id).
950950+ let alice_oauth = mint_oauth_access("did:plc:alice", &read_scope());
951951+ let (status, body) = get_json(
469952 app.clone(),
470470- "/xrpc/com.atproto.space.addMember",
471471- json!({"space": uri, "did": "did:plc:alice"}),
472472- Some(&owner_token),
953953+ &format!(
954954+ "/xrpc/com.atproto.space.getDelegationToken?space={}",
955955+ urlencode(&uri)
956956+ ),
957957+ Some(&alice_oauth),
473958 )
474959 .await;
475475- for r in ["a", "b", "c"] {
476476- post_json(
477477- app.clone(),
478478- "/xrpc/com.atproto.space.applyWrites",
479479- json!({
480480- "space": uri,
481481- "writes": [{
482482- "action": "create",
483483- "collection": "c",
484484- "rkey": r,
485485- "value": {"k": r}
486486- }]
487487- }),
488488- Some(&alice_token),
489489- )
490490- .await;
491491- }
960960+ assert_eq!(status, StatusCode::OK, "getDelegationToken: {body}");
961961+ let grant = body["token"].as_str().unwrap().to_string();
962962+ // Output is exactly {token} (spec lines 147-176; no expiresAt field).
963963+ assert!(body.get("expiresAt").is_none());
964964+ assert!(body.get("credential").is_none());
965965+966966+ // Delegation-token shape (spec lines 152-171): typ
967967+ // atproto-space-delegation+jwt, kid #atproto, sub == the space URI,
968968+ // aud == <spaceDid>#atproto_space_host, no lxm, exp == iat + 60.
969969+ let (dt_header, dt_payload) = decode_jwt(&grant);
970970+ assert_eq!(dt_header["typ"], "atproto-space-delegation+jwt");
971971+ assert_eq!(dt_header["kid"], "#atproto");
972972+ assert_eq!(dt_payload["iss"], "did:plc:alice");
973973+ assert_eq!(dt_payload["sub"], uri);
974974+ assert_eq!(dt_payload["aud"], "did:plc:owner#atproto_space_host");
975975+ assert!(
976976+ dt_payload.get("lxm").is_none(),
977977+ "delegation token has no lxm"
978978+ );
979979+ let dt_iat = dt_payload["iat"].as_u64().unwrap();
980980+ let dt_exp = dt_payload["exp"].as_u64().unwrap();
981981+ assert_eq!(dt_exp, dt_iat + 60, "delegation token exp is iat + 60");
982982+983983+ // Exchange the grant for a space credential (member-list + #open => allow).
984984+ // The delegation token rides in the Authorization header; the body carries
985985+ // only the target space. The response is {credential} (spec lines 200-251).
986986+ let (status, body) = exchange_credential(&app, &grant, &uri, None).await;
987987+ assert_eq!(status, StatusCode::OK, "getSpaceCredential: {body}");
988988+ let credential = body["credential"].as_str().unwrap();
989989+ // No legacy {token, expiresAt} shape.
990990+ assert!(body.get("token").is_none());
991991+ assert!(body.get("expiresAt").is_none());
992992+993993+ // Space-credential shape (spec lines 206-225): typ
994994+ // atproto-space-credential+jwt, kid #atproto_space, iss == the authority,
995995+ // sub == the space URI, NO aud. No attestation was presented, so client_id
996996+ // is omitted (spec lines 221, 228).
997997+ let (sc_header, sc_payload) = decode_jwt(credential);
998998+ assert_eq!(sc_header["typ"], "atproto-space-credential+jwt");
999999+ assert_eq!(sc_header["kid"], "#atproto_space");
10001000+ assert_eq!(sc_payload["iss"], "did:plc:owner");
10011001+ assert_eq!(sc_payload["sub"], uri);
10021002+ assert!(
10031003+ sc_payload.get("aud").is_none(),
10041004+ "space credential has no aud"
10051005+ );
10061006+ assert!(sc_payload.get("client_id").is_none());
10071007+}
4921008493493- let (status, body) = get_json(
10091009+#[tokio::test(flavor = "multi_thread")]
10101010+async fn delegation_token_requires_oauth_client_id() {
10111011+ let (app, _tmp) = build_app().await;
10121012+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
10131013+ let uri = create_space(&app, &owner_token, "default").await;
10141014+ // App-password session token carries no client_id -> 403.
10151015+ let (status, _) = get_json(
4941016 app,
4951017 &format!(
496496- "/xrpc/com.atproto.space.listRecords?space={}&collection=c&limit=2",
497497- urlencode(uri)
10181018+ "/xrpc/com.atproto.space.getDelegationToken?space={}",
10191019+ urlencode(&uri)
4981020 ),
499499- Some(&alice_token),
10211021+ Some(&owner_token),
5001022 )
5011023 .await;
502502- assert_eq!(status, StatusCode::OK, "body: {body}");
503503- assert_eq!(body["records"].as_array().unwrap().len(), 2);
504504- assert!(body["cursor"].as_str().is_some());
10241024+ assert_eq!(status, StatusCode::FORBIDDEN);
5051025}
50610265071027#[tokio::test(flavor = "multi_thread")]
508508-async fn member_state_and_oplog() {
10281028+async fn space_credential_non_member_denied() {
5091029 let (app, _tmp) = build_app().await;
5101030 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
511511- post_json(
10311031+ let _ = create_account_and_token(&app, "did:plc:eve", "eve.example").await;
10321032+ let uri = create_space(&app, &owner_token, "default").await;
10331033+ // Eve is NOT a member. She can still mint a delegation token (it's just a
10341034+ // signed assertion), but the owner's getSpaceCredential mint authz must
10351035+ // deny her under the default member-list policy.
10361036+ let eve_oauth = mint_oauth_access("did:plc:eve", &read_scope());
10371037+ let (status, body) = get_json(
5121038 app.clone(),
513513- "/xrpc/com.atproto.space.createSpace",
514514- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
515515- Some(&owner_token),
10391039+ &format!(
10401040+ "/xrpc/com.atproto.space.getDelegationToken?space={}",
10411041+ urlencode(&uri)
10421042+ ),
10431043+ Some(&eve_oauth),
5161044 )
5171045 .await;
518518- let uri = "ats://did:plc:owner/app.bsky.group/default";
10461046+ assert_eq!(status, StatusCode::OK, "getDelegationToken: {body}");
10471047+ let grant = body["token"].as_str().unwrap().to_string();
10481048+10491049+ let (status, _) = exchange_credential(&app, &grant, &uri, None).await;
10501050+ assert_eq!(status, StatusCode::FORBIDDEN, "non-member must be denied");
10511051+}
10521052+10531053+#[tokio::test(flavor = "multi_thread")]
10541054+async fn space_credential_app_allowlist_denies_unattested() {
10551055+ let (app, _tmp) = build_app().await;
10561056+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
10571057+ let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
10581058+ let uri = create_space(&app, &owner_token, "default").await;
5191059 post_json(
5201060 app.clone(),
521521- "/xrpc/com.atproto.space.addMember",
10611061+ "/xrpc/com.atproto.simplespace.addMember",
5221062 json!({"space": uri, "did": "did:plc:alice"}),
5231063 Some(&owner_token),
5241064 )
5251065 .await;
10661066+ // Tighten appAccess to an allow-list that excludes alice's client.
5261067 post_json(
5271068 app.clone(),
528528- "/xrpc/com.atproto.space.removeMember",
529529- json!({"space": uri, "did": "did:plc:alice"}),
10691069+ "/xrpc/com.atproto.simplespace.updateSpace",
10701070+ json!({
10711071+ "space": uri,
10721072+ "appAccess": {
10731073+ "$type": "com.atproto.simplespace.defs#allowList",
10741074+ "allowed": ["https://other.example/cm.json"]
10751075+ }
10761076+ }),
5301077 Some(&owner_token),
5311078 )
5321079 .await;
5331080534534- let (status, body) = get_json(
10811081+ let alice_oauth = mint_oauth_access("did:plc:alice", &read_scope());
10821082+ let (_, body) = get_json(
5351083 app.clone(),
5361084 &format!(
537537- "/xrpc/com.atproto.space.getMemberState?space={}",
538538- urlencode(uri)
10851085+ "/xrpc/com.atproto.space.getDelegationToken?space={}",
10861086+ urlencode(&uri)
5391087 ),
540540- Some(&owner_token),
10881088+ Some(&alice_oauth),
5411089 )
5421090 .await;
543543- assert_eq!(status, StatusCode::OK, "body: {body}");
544544- assert!(body["setHash"].as_str().is_some());
10911091+ let grant = body["token"].as_str().unwrap().to_string();
5451092546546- let (status, body) = get_json(
547547- app,
548548- &format!(
549549- "/xrpc/com.atproto.space.getMemberOplog?space={}",
550550- urlencode(uri)
551551- ),
552552- Some(&owner_token),
553553- )
554554- .await;
555555- assert_eq!(status, StatusCode::OK, "body: {body}");
556556- let ops = body["ops"].as_array().unwrap();
557557- assert_eq!(ops.len(), 2);
558558- assert_eq!(ops[0]["action"], "add");
559559- assert_eq!(ops[1]["action"], "remove");
560560-}
561561-562562-fn urlencode(s: &str) -> String {
563563- s.chars()
564564- .map(|c| match c {
565565- 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
566566- _ => format!("%{:02X}", c as u8),
567567- })
568568- .collect()
10931093+ // No client attestation -> the #allowList app axis denies (spec lines
10941094+ // 533-535: only the apps named in `allowed` may access the space).
10951095+ let (status, _) = exchange_credential(&app, &grant, &uri, None).await;
10961096+ assert_eq!(status, StatusCode::FORBIDDEN);
5691097}
5701098571571-/// acceptance: two `getSpaceCredential` calls from the
572572-/// same `clientId` produce one `space_credential_recipient` row (idempotent
573573-/// on `(space, service_did)`).
5741099#[tokio::test(flavor = "multi_thread")]
575575-async fn get_space_credential_registers_recipient_idempotently() {
11001100+async fn space_credential_registers_recipient_idempotently() {
11011101+ // build_app hides the data dir, so this test constructs the app inline
11021102+ // with a TempDir it can inspect afterwards.
5761103 let tmp = TempDir::new().unwrap();
577577- let dir = tmp.path().to_path_buf();
578578- let accounts = AccountDirectory::open(&dir.join("accounts.sqlite"))
11041104+ let data_dir = tmp.path().to_path_buf();
11051105+ let accounts = AccountDirectory::open(&data_dir.join("accounts.sqlite"))
5791106 .await
5801107 .unwrap();
5811108 let key_store: Arc<dyn KeyStore> = Arc::new(MemoryKeyStore::new());
5821109 let manager = Arc::new(AccountManager::new(
5831110 accounts.pool().clone(),
584584- dir.clone(),
11111111+ data_dir.clone(),
5851112 key_store,
5861113 KeyType::K256Private,
5871114 ));
588588- let writer = Arc::new(RepoWriter::new(manager.clone(), dir.clone()));
589589- let reader = Arc::new(RepoReader::new(accounts, dir.clone()));
590590- let svc = Arc::new(SpaceService::with_accounts(dir.clone(), manager.clone()));
591591- let space_writer = Arc::new(SpaceWriter::new(manager.clone(), dir.clone()));
592592- let space_reader = Arc::new(SpaceReader::new(manager.clone(), dir.clone()));
593593- let space_sync = Arc::new(SpaceSync::new(dir.clone()));
11151115+ let writer = Arc::new(RepoWriter::new(manager.clone(), data_dir.clone()));
11161116+ let reader = Arc::new(RepoReader::new(accounts, data_dir.clone()));
11171117+ let svc = Arc::new(SpaceService::with_accounts(
11181118+ data_dir.clone(),
11191119+ manager.clone(),
11201120+ ));
11211121+ let space_writer = Arc::new(SpaceWriter::new(manager.clone(), data_dir.clone()));
11221122+ let space_reader = Arc::new(SpaceReader::new(manager.clone(), data_dir.clone()));
11231123+ let space_sync = Arc::new(SpaceSync::new(data_dir.clone()));
5941124 let state = HttpState::with_account_manager(
5951125 reader,
596596- manager.clone(),
11261126+ manager,
5971127 "did:web:test.example".to_string(),
598598- b"test-secret-do-not-use-in-prod-32!".to_vec(),
11281128+ JWT_SECRET.to_vec(),
5991129 false,
6001130 )
6011131 .with_writer(writer)
···6031133 let app = build_router(state);
60411346051135 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
606606- let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
607607-11361136+ let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
11371137+ let uri = create_space(&app, &owner_token, "default").await;
6081138 post_json(
6091139 app.clone(),
610610- "/xrpc/com.atproto.space.createSpace",
611611- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
612612- Some(&owner_token),
613613- )
614614- .await;
615615- let uri = "ats://did:plc:owner/app.bsky.group/default";
616616- post_json(
617617- app.clone(),
618618- "/xrpc/com.atproto.space.addMember",
11401140+ "/xrpc/com.atproto.simplespace.addMember",
6191141 json!({"space": uri, "did": "did:plc:alice"}),
6201142 Some(&owner_token),
6211143 )
6221144 .await;
6231145624624- let client_id = "https://app.example/client-metadata.json";
625625-626626- // Call getSpaceCredential twice with the same clientId. The second
627627- // grant must be minted at a different `iat` second so its synthesized
628628- // jti differs (otherwise the replay guard rejects it). One second of
629629- // wall-clock sleep is the cheapest way to satisfy that.
6301146 for i in 0..2 {
6311147 if i > 0 {
6321148 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
6331149 }
634634- let (status, body) = post_json(
11501150+ let alice_oauth = mint_oauth_access("did:plc:alice", &read_scope());
11511151+ let (_, body) = get_json(
6351152 app.clone(),
636636- "/xrpc/com.atproto.space.getMemberGrant",
637637- json!({"space": uri, "clientId": client_id}),
638638- Some(&alice_token),
11531153+ &format!(
11541154+ "/xrpc/com.atproto.space.getDelegationToken?space={}",
11551155+ urlencode(&uri)
11561156+ ),
11571157+ Some(&alice_oauth),
6391158 )
6401159 .await;
641641- assert_eq!(status, StatusCode::OK, "getMemberGrant: {body}");
6421160 let grant = body["token"].as_str().unwrap().to_string();
643643-644644- let (status, body) = post_json(
645645- app.clone(),
646646- "/xrpc/com.atproto.space.getSpaceCredential",
647647- json!({"grant": grant}),
648648- None,
649649- )
650650- .await;
11611161+ let (status, body) = exchange_credential(&app, &grant, &uri, None).await;
6511162 assert_eq!(status, StatusCode::OK, "getSpaceCredential: {body}");
6521163 }
6531164654654- // Inspect the owner's per-actor `space_credential_recipient` table — we
655655- // expect exactly one row, even after two credential mints.
656656- let owner_store = atproto_pds::actor_store::sql::SqlActorStore::open(&dir, "did:plc:owner")
657657- .await
658658- .unwrap();
659659- let rows: Vec<(String, String, String)> = sqlx::query_as(
660660- "SELECT space, service_did, service_endpoint FROM space_credential_recipient",
661661- )
662662- .fetch_all(owner_store.pool())
663663- .await
664664- .unwrap();
665665- assert_eq!(
666666- rows.len(),
667667- 1,
668668- "expected exactly one recipient row, got: {rows:?}"
669669- );
11651165+ let owner_store =
11661166+ atproto_pds::actor_store::sql::SqlActorStore::open(&data_dir, "did:plc:owner")
11671167+ .await
11681168+ .unwrap();
11691169+ let rows: Vec<(String, String)> =
11701170+ sqlx::query_as("SELECT space, service_did FROM space_credential_recipient")
11711171+ .fetch_all(owner_store.pool())
11721172+ .await
11731173+ .unwrap();
11741174+ assert_eq!(rows.len(), 1, "expected one recipient row, got: {rows:?}");
6701175 assert_eq!(rows[0].0, uri);
671671- assert_eq!(rows[0].2, "https://app.example");
6721176}
6731177674674-/// acceptance (HTTP-end-to-end): a space write enqueues
675675-/// one `notify_attempt` row per registered recipient. We pre-seed the
676676-/// recipient table directly (so we don't need the full credential-mint
677677-/// flow), then assert the enqueue count.
11781178+// ---------------------------------------------------------------------------
11791179+// listRepos
11801180+// ---------------------------------------------------------------------------
11811181+6781182#[tokio::test(flavor = "multi_thread")]
679679-async fn space_write_enqueues_one_notify_per_recipient() {
680680- let tmp = TempDir::new().unwrap();
681681- let dir = tmp.path().to_path_buf();
682682- let accounts = AccountDirectory::open(&dir.join("accounts.sqlite"))
683683- .await
684684- .unwrap();
685685- let accounts_pool = accounts.pool().clone();
686686- let key_store: Arc<dyn KeyStore> = Arc::new(MemoryKeyStore::new());
687687- let manager = Arc::new(AccountManager::new(
688688- accounts.pool().clone(),
689689- dir.clone(),
690690- key_store,
691691- KeyType::K256Private,
692692- ));
693693- let writer = Arc::new(RepoWriter::new(manager.clone(), dir.clone()));
694694- let reader = Arc::new(RepoReader::new(accounts, dir.clone()));
695695- let svc = Arc::new(SpaceService::with_accounts(dir.clone(), manager.clone()));
696696- let space_writer = Arc::new(SpaceWriter::new(manager.clone(), dir.clone()));
697697- let space_reader = Arc::new(SpaceReader::new(manager.clone(), dir.clone()));
698698- let space_sync = Arc::new(SpaceSync::new(dir.clone()));
699699- let state = HttpState::with_account_manager(
700700- reader,
701701- manager.clone(),
702702- "did:web:test.example".to_string(),
703703- b"test-secret-do-not-use-in-prod-32!".to_vec(),
704704- false,
11831183+async fn list_repos_wrong_space_credential_rejected() {
11841184+ let (app, _tmp) = build_app().await;
11851185+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
11861186+ let uri = create_space(&app, &owner_token, "default").await;
11871187+ // A credential minted for `default` does not authorize a different space
11881188+ // URI: the `sub` claim must match the requested space, so the verify gate
11891189+ // rejects it (401) before any SpaceNotFound lookup.
11901190+ let credential = mint_space_credential(&app, "did:plc:owner", &uri).await;
11911191+ let (status, _) = get_json(
11921192+ app,
11931193+ &format!(
11941194+ "/xrpc/com.atproto.space.listRepos?space={}",
11951195+ urlencode("ats://did:plc:owner/app.bsky.group/missing")
11961196+ ),
11971197+ Some(&credential),
7051198 )
706706- .with_writer(writer)
707707- .with_spaces(svc, space_writer, space_reader, space_sync);
708708- let app = build_router(state);
11991199+ .await;
12001200+ assert_eq!(status, StatusCode::UNAUTHORIZED);
12011201+}
709120212031203+#[tokio::test(flavor = "multi_thread")]
12041204+async fn list_repos_empty_writer_set() {
12051205+ let (app, _tmp) = build_app().await;
7101206 let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
711711- let alice_token = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
712712- post_json(
12071207+ let uri = create_space(&app, &owner_token, "default").await;
12081208+ // listRepos is space-credential-only: mint a real, authority-signed
12091209+ // credential for the owner (an implicit member of their own space).
12101210+ let credential = mint_space_credential(&app, "did:plc:owner", &uri).await;
12111211+ let (status, body) = get_json(
7131212 app.clone(),
714714- "/xrpc/com.atproto.space.createSpace",
715715- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
716716- Some(&owner_token),
12131213+ &format!(
12141214+ "/xrpc/com.atproto.space.listRepos?space={}",
12151215+ urlencode(&uri)
12161216+ ),
12171217+ Some(&credential),
7171218 )
7181219 .await;
719719- let uri_str = "ats://did:plc:owner/app.bsky.group/default";
720720- post_json(
12201220+ assert_eq!(status, StatusCode::OK, "body: {body}");
12211221+ // No inbound write receipts yet -> empty writer set.
12221222+ assert_eq!(body["repos"].as_array().unwrap().len(), 0);
12231223+}
12241224+12251225+#[tokio::test(flavor = "multi_thread")]
12261226+async fn list_repos_rejects_oauth_token() {
12271227+ let (app, _tmp) = build_app().await;
12281228+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
12291229+ let uri = create_space(&app, &owner_token, "default").await;
12301230+ // listRepos accepts a space credential ONLY (spec XRPC table). An OAuth /
12311231+ // session bearer — even the owner's — must be rejected with 401.
12321232+ let oauth = mint_oauth_access("did:plc:owner", &read_scope());
12331233+ let (status, _) = get_json(
7211234 app.clone(),
722722- "/xrpc/com.atproto.space.addMember",
723723- json!({"space": uri_str, "did": "did:plc:alice"}),
12351235+ &format!(
12361236+ "/xrpc/com.atproto.space.listRepos?space={}",
12371237+ urlencode(&uri)
12381238+ ),
12391239+ Some(&oauth),
12401240+ )
12411241+ .await;
12421242+ assert_eq!(status, StatusCode::UNAUTHORIZED, "oauth must be rejected");
12431243+ // The owner's app-password session token is likewise rejected.
12441244+ let (status, _) = get_json(
12451245+ app,
12461246+ &format!(
12471247+ "/xrpc/com.atproto.space.listRepos?space={}",
12481248+ urlencode(&uri)
12491249+ ),
7241250 Some(&owner_token),
7251251 )
7261252 .await;
12531253+ assert_eq!(status, StatusCode::UNAUTHORIZED, "session must be rejected");
12541254+}
7271255728728- // Pre-seed two recipients on the owner's per-actor store.
729729- let owner_store = atproto_pds::actor_store::sql::SqlActorStore::open(&dir, "did:plc:owner")
730730- .await
731731- .unwrap();
732732- let space_uri: atproto_space::types::SpaceUri = uri_str.parse().unwrap();
733733- atproto_pds::space::notify::upsert_recipient(
734734- owner_store.pool(),
735735- &space_uri,
736736- "did:web:a.example",
737737- "https://a.example",
12561256+// ---------------------------------------------------------------------------
12571257+// Forged-credential rejection on the host/sync read methods (F1 security fix)
12581258+// ---------------------------------------------------------------------------
12591259+12601260+/// A bearer that merely *classifies* as a space credential (correct `typ`) but
12611261+/// carries an invalid signature must be rejected — not admitted on its `typ`
12621262+/// string alone — on every host/sync read method.
12631263+#[tokio::test(flavor = "multi_thread")]
12641264+async fn forged_space_credential_rejected_on_read_methods() {
12651265+ let (app, _tmp) = build_app().await;
12661266+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
12671267+ let uri = create_space(&app, &owner_token, "default").await;
12681268+ let forged = forge_space_credential("did:plc:owner", &uri);
12691269+12701270+ // getSpace
12711271+ let (status, _) = get_json(
12721272+ app.clone(),
12731273+ &format!("/xrpc/com.atproto.space.getSpace?space={}", urlencode(&uri)),
12741274+ Some(&forged),
7381275 )
739739- .await
740740- .unwrap();
741741- atproto_pds::space::notify::upsert_recipient(
742742- owner_store.pool(),
743743- &space_uri,
744744- "did:web:b.example",
745745- "https://b.example",
746746- )
747747- .await
748748- .unwrap();
12761276+ .await;
12771277+ assert_eq!(status, StatusCode::UNAUTHORIZED, "getSpace forged");
7491278750750- // Snapshot the queue length before the write.
751751- let before: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_attempt")
752752- .fetch_one(&accounts_pool)
753753- .await
754754- .unwrap();
12791279+ // getRepoState
12801280+ let (status, _) = get_json(
12811281+ app.clone(),
12821282+ &format!(
12831283+ "/xrpc/com.atproto.space.getRepoState?space={}&repo=did:plc:owner",
12841284+ urlencode(&uri)
12851285+ ),
12861286+ Some(&forged),
12871287+ )
12881288+ .await;
12891289+ assert_eq!(status, StatusCode::UNAUTHORIZED, "getRepoState forged");
7551290756756- // Alice writes a record.
757757- let (status, body) = post_json(
12911291+ // listRepoOps
12921292+ let (status, _) = get_json(
7581293 app.clone(),
759759- "/xrpc/com.atproto.space.applyWrites",
760760- json!({
761761- "space": uri_str,
762762- "writes": [{"action": "create", "collection": "c", "rkey": "k", "value": {"v": 1}}]
763763- }),
764764- Some(&alice_token),
12941294+ &format!(
12951295+ "/xrpc/com.atproto.space.listRepoOps?space={}&repo=did:plc:owner",
12961296+ urlencode(&uri)
12971297+ ),
12981298+ Some(&forged),
7651299 )
7661300 .await;
767767- assert_eq!(status, StatusCode::OK, "applyWrites: {body}");
13011301+ assert_eq!(status, StatusCode::UNAUTHORIZED, "listRepoOps forged");
7681302769769- let after: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_attempt")
770770- .fetch_one(&accounts_pool)
771771- .await
772772- .unwrap();
773773- // 2 new rows (one per recipient) for the write commit. addMember
774774- // happened before the seed so it didn't fan out (and it has no recipient
775775- // entries until we seeded them).
776776- assert_eq!(after.0 - before.0, 2);
13031303+ // listRepos
13041304+ let (status, _) = get_json(
13051305+ app,
13061306+ &format!(
13071307+ "/xrpc/com.atproto.space.listRepos?space={}",
13081308+ urlencode(&uri)
13091309+ ),
13101310+ Some(&forged),
13111311+ )
13121312+ .await;
13131313+ assert_eq!(status, StatusCode::UNAUTHORIZED, "listRepos forged");
7771314}
7781315779779-/// acceptance (membership variant): owner-side
780780-/// `addMember` after the recipient table is populated enqueues one
781781-/// `notifyMembership` row per recipient.
13161316+// ---------------------------------------------------------------------------
13171317+// getBlob gating
13181318+// ---------------------------------------------------------------------------
13191319+7821320#[tokio::test(flavor = "multi_thread")]
783783-async fn add_member_enqueues_membership_notification() {
784784- let tmp = TempDir::new().unwrap();
785785- let dir = tmp.path().to_path_buf();
786786- let accounts = AccountDirectory::open(&dir.join("accounts.sqlite"))
787787- .await
788788- .unwrap();
789789- let accounts_pool = accounts.pool().clone();
790790- let key_store: Arc<dyn KeyStore> = Arc::new(MemoryKeyStore::new());
791791- let manager = Arc::new(AccountManager::new(
792792- accounts.pool().clone(),
793793- dir.clone(),
794794- key_store,
795795- KeyType::K256Private,
796796- ));
797797- let writer = Arc::new(RepoWriter::new(manager.clone(), dir.clone()));
798798- let reader = Arc::new(RepoReader::new(accounts, dir.clone()));
799799- let svc = Arc::new(SpaceService::with_accounts(dir.clone(), manager.clone()));
800800- let space_writer = Arc::new(SpaceWriter::new(manager.clone(), dir.clone()));
801801- let space_reader = Arc::new(SpaceReader::new(manager.clone(), dir.clone()));
802802- let space_sync = Arc::new(SpaceSync::new(dir.clone()));
803803- let state = HttpState::with_account_manager(
804804- reader,
805805- manager.clone(),
806806- "did:web:test.example".to_string(),
807807- b"test-secret-do-not-use-in-prod-32!".to_vec(),
808808- false,
13211321+async fn get_blob_requires_repo_and_gates_auth() {
13221322+ let (app, _tmp) = build_app().await;
13231323+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
13241324+ let uri = create_space(&app, &owner_token, "default").await;
13251325+13261326+ // Missing bearer -> unauthorized.
13271327+ let (status, _) = get_json(
13281328+ app.clone(),
13291329+ &format!(
13301330+ "/xrpc/com.atproto.space.getBlob?space={}&repo=did:plc:owner&cid=bafkreigh2akiscaildc",
13311331+ urlencode(&uri)
13321332+ ),
13331333+ None,
8091334 )
810810- .with_writer(writer)
811811- .with_spaces(svc, space_writer, space_reader, space_sync);
812812- let app = build_router(state);
13351335+ .await;
13361336+ assert_eq!(status, StatusCode::UNAUTHORIZED);
8131337814814- let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
815815- let _ = create_account_and_token(&app, "did:plc:alice", "alice.example").await;
816816- post_json(
817817- app.clone(),
818818- "/xrpc/com.atproto.space.createSpace",
819819- json!({"spaceType": "app.bsky.group", "spaceKey": "default"}),
13381338+ // Authed but no such blob -> BlobNotFound (404). This proves the auth
13391339+ // gate passed and the reader was reached.
13401340+ let (status, body) = get_json(
13411341+ app,
13421342+ &format!(
13431343+ "/xrpc/com.atproto.space.getBlob?space={}&repo=did:plc:owner&cid=bafkreigh2akiscaildc",
13441344+ urlencode(&uri)
13451345+ ),
8201346 Some(&owner_token),
8211347 )
8221348 .await;
823823- let uri_str = "ats://did:plc:owner/app.bsky.group/default";
824824- let space_uri: atproto_space::types::SpaceUri = uri_str.parse().unwrap();
13491349+ assert_eq!(status, StatusCode::NOT_FOUND, "body: {body}");
13501350+ assert_eq!(body["error"], "BlobNotFound");
13511351+}
13521352+13531353+// ---------------------------------------------------------------------------
13541354+// registerNotify
13551355+// ---------------------------------------------------------------------------
8251356826826- // Seed one recipient before the membership op so the addMember below
827827- // produces exactly one notify row.
828828- let owner_store = atproto_pds::actor_store::sql::SqlActorStore::open(&dir, "did:plc:owner")
829829- .await
830830- .unwrap();
831831- atproto_pds::space::notify::upsert_recipient(
832832- owner_store.pool(),
833833- &space_uri,
834834- "did:web:appview.example",
835835- "https://appview.example",
13571357+#[tokio::test(flavor = "multi_thread")]
13581358+async fn register_notify_requires_space_credential() {
13591359+ let (app, _tmp) = build_app().await;
13601360+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
13611361+ let uri = create_space(&app, &owner_token, "default").await;
13621362+ // A plain session token is not a space credential -> 401.
13631363+ let (status, _) = post_json(
13641364+ app,
13651365+ "/xrpc/com.atproto.space.registerNotify",
13661366+ json!({"space": uri, "endpoint": "https://consumer.example"}),
13671367+ Some(&owner_token),
8361368 )
837837- .await
838838- .unwrap();
13691369+ .await;
13701370+ assert_eq!(status, StatusCode::UNAUTHORIZED);
13711371+}
8391372840840- let before: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_attempt")
841841- .fetch_one(&accounts_pool)
842842- .await
843843- .unwrap();
13731373+// ---------------------------------------------------------------------------
13741374+// Inbound notify endpoints (service auth required)
13751375+// ---------------------------------------------------------------------------
8441376845845- post_json(
13771377+#[tokio::test(flavor = "multi_thread")]
13781378+async fn notify_write_requires_service_auth() {
13791379+ let (app, _tmp) = build_app().await;
13801380+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
13811381+ let uri = create_space(&app, &owner_token, "default").await;
13821382+ // Contentless payload, but no bearer -> unauthorized.
13831383+ let (status, _) = post_json(
8461384 app.clone(),
847847- "/xrpc/com.atproto.space.addMember",
848848- json!({"space": uri_str, "did": "did:plc:alice"}),
849849- Some(&owner_token),
13851385+ "/xrpc/com.atproto.space.notifyWrite",
13861386+ json!({"space": uri, "repo": "did:plc:writer", "rev": "3kabc"}),
13871387+ None,
8501388 )
8511389 .await;
13901390+ assert_eq!(status, StatusCode::UNAUTHORIZED);
8521391853853- let after: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM notify_attempt")
854854- .fetch_one(&accounts_pool)
855855- .await
856856- .unwrap();
857857- assert_eq!(after.0 - before.0, 1);
13921392+ // A session token is not service auth -> rejected (4xx).
13931393+ let (status, _) = post_json(
13941394+ app,
13951395+ "/xrpc/com.atproto.space.notifyWrite",
13961396+ json!({"space": uri, "repo": "did:plc:writer", "rev": "3kabc"}),
13971397+ Some(&owner_token),
13981398+ )
13991399+ .await;
14001400+ assert!(
14011401+ status.is_client_error(),
14021402+ "session token must not pass service auth"
14031403+ );
14041404+}
8581405859859- let (target_did, nsid): (String, String) = sqlx::query_as(
860860- "SELECT target_service_did, nsid FROM notify_attempt ORDER BY next_attempt_at DESC LIMIT 1",
14061406+#[tokio::test(flavor = "multi_thread")]
14071407+async fn notify_space_deleted_requires_service_auth() {
14081408+ let (app, _tmp) = build_app().await;
14091409+ let owner_token = create_account_and_token(&app, "did:plc:owner", "owner.example").await;
14101410+ let uri = create_space(&app, &owner_token, "default").await;
14111411+ let (status, _) = post_json(
14121412+ app,
14131413+ "/xrpc/com.atproto.space.notifySpaceDeleted",
14141414+ json!({"space": uri}),
14151415+ None,
8611416 )
862862- .fetch_one(&accounts_pool)
863863- .await
864864- .unwrap();
865865- assert_eq!(target_did, "did:web:appview.example");
866866- assert_eq!(nsid, "com.atproto.space.notifyMembership");
14171417+ .await;
14181418+ assert_eq!(status, StatusCode::UNAUTHORIZED);
8671419}
+7-4
crates/atproto-pds/tests/notifier_e2e.rs
···31313232async fn spawn_test_server() -> (Counter, std::net::SocketAddr) {
3333 let counter = Counter::default();
3434+ // The notifier delivers contentless `notifyWrite` events to registered
3535+ // recipients (spec lines 343-351). The member-sync `notifyMembership` route
3636+ // was removed in the 0016 re-alignment, so only `notifyWrite` is served.
3437 let app: Router = Router::new()
3538 .route("/xrpc/com.atproto.space.notifyWrite", post(count_handler))
3636- .route(
3737- "/xrpc/com.atproto.space.notifyMembership",
3838- post(count_handler),
3939- )
4039 .with_state(counter.clone());
4140 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4241 let addr = listener.local_addr().unwrap();
···6463 &endpoint,
6564 b"payload-bytes".to_vec(),
6665 "com.atproto.space.notifyWrite",
6666+ "application/json",
6767+ None,
6768 )
6869 .await
6970 .unwrap();
···131132 &format!("http://{addr}"),
132133 b"x".to_vec(),
133134 "com.atproto.space.notifyWrite",
135135+ "application/json",
136136+ None,
134137 )
135138 .await
136139 .unwrap();
+1-1
crates/atproto-record/Cargo.toml
···11[package]
22name = "atproto-record"
33-version = "0.15.0-alpha.1"
33+version = "0.15.0-alpha.2"
44description = "AT Protocol record signature operations - cryptographic signing and verification for AT Protocol records"
55readme = "README.md"
66homepage = "https://tangled.org/ngerakines.me/atproto-crates"
···11[package]
22name = "atproto-space"
33-version = "0.15.0-alpha.1"
44-description = "AT Protocol permissioned-data spaces — primitives for the Spaces Design Spec (SetHash, signed commits, MemberGrant/SpaceCredential JWTs)"
33+version = "0.15.0-alpha.2"
44+description = "AT Protocol permissioned-data spaces — primitives for the 0016 Permissioned Data draft (LtHash, signed commits, delegation-token/space-credential JWTs)"
55readme = "README.md"
66homepage = "https://tangled.org/ngerakines.me/atproto-crates"
77documentation = "https://docs.rs/atproto-space"
···2222anyhow.workspace = true
2323async-trait.workspace = true
2424base64.workspace = true
2525+blake3.workspace = true
2526chrono.workspace = true
2627hkdf = "0.13"
2728hmac = "0.13"
···3334thiserror.workspace = true
3435tracing.workspace = true
35363636-# `ecmh` feature: pulls in k256 for the EcmhSetHash impl (per Spaces Design
3737-# Spec §ECMH). Gated so default builds stay lightweight; flip the default in
3838-# Phase 8 once ECMH is the chosen primitive workspace-wide.
3939-k256 = { workspace = true, optional = true, features = ["arithmetic"] }
4040-4137[dev-dependencies]
4242-criterion.workspace = true
4338proptest.workspace = true
4439tokio = { workspace = true, features = ["macros", "rt"] }
45404641[features]
4742default = []
4848-ecmh = ["dep:k256"]
4949-5050-[[bench]]
5151-name = "set_hash"
5252-harness = false
5353-required-features = ["ecmh"]
54435544[lints]
5645workspace = true
+27-48
crates/atproto-space/README.md
···33AT Protocol permissioned-data spaces — protocol primitives.
4455This crate implements the cryptographic and orchestration primitives from the
66-[Spaces Design Spec](https://github.com/bluesky-social/atproto/blob/main/docs/superpowers/specs/2026-04-22-permissioned-data-pds-design.md):
77-SetHash commitments, signed commits with HKDF-derived HMAC + ECDSA + per-commit
88-random IKM (for deniability), domain-separated record vs member commitments,
99-and the two-step `MemberGrant` → `SpaceCredential` JWT exchange.
66+[0016 Permissioned Data draft](https://github.com/bluesky-social/proposals/tree/main/0016-permissioned-data),
77+which is the authoritative alignment target for this crate: SetHash
88+commitments, signed commits with HKDF-derived HMAC + ECDSA + per-commit random
99+IKM (for deniability), and the two-step delegation-token → space-credential JWT
1010+exchange.
10111111-> **Status: experimental**. The Spaces Design Spec is still settling; several
1212-> primitives (notably `SetHash`) are explicitly placeholder until upstream
1313-> picks ECMH or ltHash. The current default is `XorSha256SetHash`.
1212+> **Status: experimental**. The 0016 Permissioned Data draft is still settling.
1313+> The production `SetHash` is `LtHash`, the lattice hash the spec selects (spec
1414+> § "Commit digest").
14151516## Modules
16171717-- `set_hash` — `SetHash` trait + `XorSha256SetHash` placeholder.
1818-- `set_hash_ecmh` *(feature `ecmh`)* — `EcmhSetHash` over secp256k1: scalar-mul
1919- multiset hash with property-tested homomorphic invariants. SEC1-compressed
2020- digest. Pulls in k256.
2121-- `commit` — `SpaceContext`, `Commit`, `create_commit`, `verify_commit` with
2222- HKDF + HMAC + ECDSA construction. Domain-separated `Records` vs `Members`
2323- via `CommitScope`.
1818+- `set_hash` — `SetHash` trait + `LtHash` production primitive.
1919+- `commit` — `SpaceContext`, `Commit`, `create_commit`, `verify_commit`. A
2020+ commit signs only the per-commit context (`ctx`) and binds the set-hash
2121+ digest with an HKDF-keyed HMAC, so a leaked commit is deniable (spec
2222+ § "Commit signature", lines 285-316).
2423- `space_repo` — `SpaceRepo<S, H>` orchestrator over the storage trait surface
2524 for per-(user, space) record CRUD.
2626-- `space_members` — `SpaceMembers<S, H>` orchestrator (owner-only).
2727-- `credential` — `MemberGrant` and `SpaceCredential` JWT mint/verify.
2828-- `recon` — `Reconciler` / `Sketch` traits + `oplog_catchup` baseline impl.
2929- RIBLT impl is deferred; the trait surface preserves the call sites for the
3030- eventual swap.
2525+- `space_members` — `SpaceMembers<S, H>` member-list orchestrator backing the
2626+ `simplespace` `member-list` mint policy (the spec carries no member commits).
2727+- `credential` — `DelegationToken` and `SpaceCredential` JWT mint/verify (spec
2828+ § "Access control", lines 136-251).
3129- `storage` — `SpaceRepoStorage`, `SpaceMembersStorage` traits.
3232-- `types` — `SpaceUri`, `SpaceType`, `SpaceKey` newtypes.
3030+- `types` — `SpaceUri`, `RecordUri`, `SpaceType`, `SpaceKey` newtypes.
3331- `errors` — `SpaceError` enum with `error-atproto-space-<domain>-<n>` IDs.
34323535-## Cargo features
3636-3737-| Feature | Default | Description |
3838-|---|---|---|
3939-| `ecmh` | | Build the `EcmhSetHash` impl over k256/secp256k1. |
4040-4141-## Benchmarks
4242-4343-Criterion-driven comparison of `XorSha256SetHash` vs `EcmhSetHash` is in
4444-`benches/set_hash.rs`. Run with:
4545-4646-```bash
4747-cargo bench -p atproto-space --features ecmh
4848-```
4949-5050-Three groups: `add_throughput` (1 / 100 / 1000 elements), `add_remove_round_trip`
5151-(single-element flush), and `digest_serialization` (digest + from_digest).
5252-5333## Quick start
54345535```rust,ignore
5636use atproto_space::{
5757- SpaceUri, SpaceType, SpaceKey, SpaceContext, CommitScope,
5858- XorSha256SetHash, SetHash, create_commit, verify_commit,
3737+ SpaceUri, SpaceType, SpaceKey, SpaceContext,
3838+ LtHash, SetHash, create_commit, verify_commit,
5939};
6040use atproto_identity::key::{KeyType, generate_key, identify_key};
6141···6848 SpaceKey::new("default")?,
6949);
70507171-let mut hash = XorSha256SetHash::empty();
7272-hash.add(b"app.bsky.feed.post/3jui:bafy123");
5151+let mut hash = LtHash::empty();
5252+// Each record element is `{collection}/{rkey}/{record_cid}` (spec line 270).
5353+hash.add(b"app.bsky.feed.post/3jui/bafy123");
73545555+// The signed context is the space URI + revision (spec lines 292-297); the
5656+// set-hash digest is bound by the commit's MAC, not signed directly.
7457let context = SpaceContext {
7575- space_did: "did:plc:owner".to_string(),
7676- space_type: "app.bsky.group".to_string(),
7777- space_key: "default".to_string(),
7878- user_did: "did:plc:alice".to_string(),
7979- scope: CommitScope::Records,
5858+ space: space.to_string(),
8059 rev: "3jui7kd2z2y2e".to_string(),
8160};
82618362let commit = create_commit(&hash, &context, &private_key)?;
8484-// commit.set_hash, commit.rev, commit.ikm, commit.tag, commit.sig
6363+// commit.hash, commit.ikm, commit.sig, commit.mac, commit.rev
85648665# Ok::<(), anyhow::Error>(())
8766```
+241-221
crates/atproto-space/src/commit.rs
···11//! Permissioned-data commit construction.
22//!
33+//! A signed commit summarizes the current state of a permissioned repo. It is
44+//! built to match the 0016 Permissioned Data draft (§ Commit signature):
35//!
66+//! ```text
77+//! hash := setHash.digest() // sha256 of the 2048-byte LtHash state (32 bytes)
88+//! ikm := random(32 bytes) // per-commit, fresh
99+//! ctx := encode_ctx(space, rev, ikm) // TLS-1.3-style vlv (below)
1010+//! sig := sign(ctx) // user's atproto signing key, over the full ctx
1111+//! mac := HMAC-SHA256(HKDF-SHA256(ikm, ctx), hash) // binds the repo hash to this commit's context
1212+//! commit := { hash, mac, ikm, sig, rev }
1313+//! ```
1414+//!
1515+//! `ctx` is the single context string reused for both the signature and the
1616+//! MAC's HKDF info. It uses the TLS 1.3 (§3.4) variable-length-vector encoding:
1717+//! a fixed protocol tag followed by each field length-prefixed with a
1818+//! big-endian `uint16`, per spec lines 288–297:
419//!
520//! ```text
66-//! ikm := random(32 bytes) // per-commit, fresh
77-//! hkdf_info := DAG-CBOR(SpaceContext)
88-//! hmac_key := HKDF-Extract-then-Expand(SHA256, ikm, info=hkdf_info, len=32)
99-//! tag := HMAC-SHA-256(hmac_key, set_hash || rev)
1010-//! sig_bytes := tag || rev.as_bytes()
1111-//! sig := ECDSA-low-S(user_signing_key, SHA256(sig_bytes))
1212-//! commit := { set_hash, rev, ikm, tag, sig }
2121+//! ctx = "atproto-space-v1" // fixed protocol tag, NO length prefix
2222+//! || uint16be(len(space)) || space // space URI (ats://authority/type/skey)
2323+//! || uint16be(len(rev)) || rev // commit revision (TID)
2424+//! || uint16be(len(ikm)) || ikm // per-commit nonce
1325//! ```
1426//!
1515-//! The IKM is included in the commit so a verifier with the relevant
1616-//! `SpaceContext` can recompute and check; outside that context, the IKM is
1717-//! meaningless. This is the deniability mechanism per the spec.
2727+//! Deniability: the signature covers only the random `ctx` (which binds
2828+//! `space`, `rev`, and the public `ikm` — never the repo hash), so a leaked
2929+//! commit proves nothing about repo contents. The repo hash is bound to the
3030+//! context by the *symmetric* MAC (key derived from the public `ikm`), so anyone
3131+//! holding the commit can recompute a valid MAC for any hash — authenticity of
3232+//! the *content* is not transferable (spec lines 286, 305).
1833//!
1919-//! `SpaceContext.scope` (Records vs Members) provides domain separation: a
2020-//! commit signed in one scope must fail verification in the other.
3434+//! [`verify_commit`] recomputes the MAC and compares it; a consumer that wants
3535+//! authenticity additionally calls [`verify_commit_signature`], which verifies
3636+//! `sig` over the reconstructed `ctx`.
21372238use crate::errors::{SpaceError, SpaceResult};
2339use crate::set_hash::SetHash;
···2642use hmac::{Hmac, KeyInit, Mac};
2743use rand::RngExt;
2844use serde::{Deserialize, Serialize};
2929-use sha2::{Digest, Sha256};
4545+use sha2::Sha256;
30463147type HmacSha256 = Hmac<Sha256>;
32483333-/// Scope discriminator for `SpaceContext` — provides domain separation between
3434-/// record-set commits and member-list commits.
3535-///
3636-/// **Critical security property**: a commit signed with `Records` scope must
3737-/// fail verification under `Members` scope and vice versa.
3838-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3939-#[serde(rename_all = "lowercase")]
4040-pub enum CommitScope {
4141- /// Records-set commit (per-user, per-space).
4242- Records,
4343- /// Member-list commit (owner-only).
4444- Members,
4545-}
4646-4747-impl CommitScope {
4848- /// Spec-defined string value used in the HKDF info bytes.
4949- #[must_use]
5050- pub fn as_str(self) -> &'static str {
5151- match self {
5252- CommitScope::Records => "records",
5353- CommitScope::Members => "members",
5454- }
5555- }
5656-}
4949+/// Fixed domain-separation tag prefixing the commit `ctx`.
5050+const DOMAIN_PREFIX: &[u8] = b"atproto-space-v1";
57515858-/// Context for HKDF derivation of the commit's HMAC key.
5252+/// Context bound into a commit via the signature and the MAC's HKDF info.
5953///
6060-/// Encoded as DAG-CBOR and used as the HKDF `info` parameter.
5454+/// Per the 0016 Permissioned Data draft (spec lines 288–297) the on-the-wire
5555+/// `ctx` is `[space, rev, ikm]` length-prefixed; the `ikm` is supplied per
5656+/// commit (it is part of the [`Commit`]), so this struct carries only the
5757+/// stable `[space, rev]` pair.
6158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6259pub struct SpaceContext {
6363- /// Owner DID of the space.
6464- #[serde(rename = "spaceDid")]
6565- pub space_did: String,
6666- /// Space type (NSID).
6767- #[serde(rename = "spaceType")]
6868- pub space_type: String,
6969- /// Space key.
7070- #[serde(rename = "spaceKey")]
7171- pub space_key: String,
7272- /// Committer DID — the user who wrote this commit.
7373- #[serde(rename = "userDid")]
7474- pub user_did: String,
7575- /// Scope discriminator (Records or Members).
7676- pub scope: CommitScope,
6060+ /// Full space URI (`ats://{spaceDid}/{spaceType}/{skey}`).
6161+ pub space: String,
7762 /// Rev (TID) for this commit.
7863 pub rev: String,
7964}
80658181-impl SpaceContext {
8282- /// Encode as DAG-CBOR for use as the HKDF `info` parameter.
8383- fn to_cbor(&self) -> SpaceResult<Vec<u8>> {
8484- atproto_dasl::to_vec(self).map_err(|e| SpaceError::ContextEncoding {
8585- reason: e.to_string(),
8686- })
6666+/// Encode the commit `ctx` (signature message + HKDF info).
6767+///
6868+/// `"atproto-space-v1"` followed by each field length-prefixed with a
6969+/// big-endian `uint16`, in the order `[space, rev, ikm]` (spec lines 293–297).
7070+#[must_use]
7171+pub fn encode_ctx(context: &SpaceContext, ikm: &[u8]) -> Vec<u8> {
7272+ let space = context.space.as_bytes();
7373+ let rev = context.rev.as_bytes();
7474+ let mut out = Vec::with_capacity(DOMAIN_PREFIX.len() + space.len() + rev.len() + ikm.len() + 6);
7575+ out.extend_from_slice(DOMAIN_PREFIX);
7676+ for field in [space, rev, ikm] {
7777+ out.extend_from_slice(&(field.len() as u16).to_be_bytes());
7878+ out.extend_from_slice(field);
8779 }
8080+ out
8881}
89829090-/// A signed permissioned-data commit.
8383+/// A signed permissioned-data commit (`com.atproto.space.defs#signedCommit`).
9184///
9292-/// Includes the IKM in the clear so verifiers with `SpaceContext` can
9393-/// recompute the HMAC; the IKM-randomness gives deniability outside context.
8585+/// Wire field order matches the lexicon required set `[hash, mac, ikm, sig, rev]`.
9486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9587pub struct Commit {
9696- /// SetHash digest at this commit.
9797- #[serde(rename = "setHash", with = "serde_bytes")]
9898- pub set_hash: Vec<u8>,
9999- /// Rev (TID).
100100- pub rev: String,
101101- /// Per-commit fresh IKM (32 bytes).
8888+ /// `sha256` of the LtHash state (32 bytes).
8989+ #[serde(with = "serde_bytes")]
9090+ pub hash: Vec<u8>,
9191+ /// `HMAC-SHA256` over `hash`, keyed by `HKDF-Expand(ikm, ctx)`.
9292+ #[serde(with = "serde_bytes")]
9393+ pub mac: Vec<u8>,
9494+ /// Per-commit fresh IKM (32 bytes), also bound into the `ctx`.
10295 #[serde(with = "serde_bytes")]
10396 pub ikm: Vec<u8>,
104104- /// HMAC-SHA-256 tag over `set_hash || rev` keyed by HKDF(ikm, SpaceContext).
105105- #[serde(with = "serde_bytes")]
106106- pub tag: Vec<u8>,
107107- /// ECDSA signature over SHA256(tag || rev), low-S normalized.
9797+ /// Signature over `ctx` by the user's atproto signing key.
10898 #[serde(with = "serde_bytes")]
10999 pub sig: Vec<u8>,
100100+ /// Commit revision (TID), also bound into the `ctx`.
101101+ pub rev: String,
110102}
111103112112-/// Construct a signed commit from a SetHash digest and SpaceContext.
104104+/// Construct a signed commit from a SetHash and `SpaceContext`.
113105///
114114-/// Uses a fresh per-commit IKM drawn from the OS RNG (`rand::rng()`).
106106+/// Uses a fresh per-commit IKM drawn from the OS RNG.
115107///
116108/// # Errors
117109///
118110/// - [`SpaceError::Hkdf`] — HKDF derivation failure.
119119-/// - [`SpaceError::Signature`] — signing failure (key/curve mismatch, etc.).
120120-/// - [`SpaceError::ContextEncoding`] — CBOR encoding of `SpaceContext` failed.
111111+/// - [`SpaceError::Signature`] — signing failure (e.g. a public key was given).
112112+/// - [`SpaceError::ContextEncoding`] — `rev` is empty.
121113pub fn create_commit<H: SetHash>(
122114 set_hash: &H,
123115 context: &SpaceContext,
···128120 create_commit_with_ikm(&set_hash.digest(), context, signing_key, &ikm)
129121}
130122131131-/// Like [`create_commit`] but takes an explicit IKM. For deterministic tests
132132-/// or for re-creating commits with caller-controlled randomness.
123123+/// Like [`create_commit`] but takes the commit `hash` and an explicit IKM.
124124+///
125125+/// For deterministic tests or caller-controlled randomness.
133126#[doc(hidden)]
134127pub fn create_commit_with_ikm(
135135- set_hash: &[u8],
128128+ hash: &[u8],
136129 context: &SpaceContext,
137130 signing_key: &KeyData,
138131 ikm: &[u8; 32],
139132) -> SpaceResult<Commit> {
140140- if context.rev != context.rev.trim() || context.rev.is_empty() {
133133+ if context.rev.is_empty() {
141134 return Err(SpaceError::ContextEncoding {
142142- reason: "rev must be non-empty and untrimmed".to_string(),
135135+ reason: "rev must be non-empty".to_string(),
143136 });
144137 }
145138146146- // 2. HKDF-derive HMAC key with DAG-CBOR(SpaceContext) as info.
147147- let info = context.to_cbor()?;
148148- let hkdf = Hkdf::<Sha256>::new(None, ikm);
139139+ let ctx = encode_ctx(context, ikm);
140140+ let mac = derive_mac(ikm, hash, &ctx)?;
141141+ // Per spec line 302: the signature is over the full `ctx` (space, rev, ikm)
142142+ // — never the hash — so a leaked commit is deniable.
143143+ let sig = identity_sign(signing_key, &ctx).map_err(|e| SpaceError::Signature {
144144+ reason: e.to_string(),
145145+ })?;
146146+147147+ Ok(Commit {
148148+ hash: hash.to_vec(),
149149+ mac,
150150+ ikm: ikm.to_vec(),
151151+ sig,
152152+ rev: context.rev.clone(),
153153+ })
154154+}
155155+156156+/// Derive `HMAC-SHA256(HKDF-Expand(ikm, ctx), hash)`.
157157+///
158158+/// HKDF is **expand-only** (ikm is the PRK; no extract step, no salt), per spec
159159+/// line 303 (`HKDF-SHA256(ikm, ctx)`).
160160+fn derive_mac(ikm: &[u8], hash: &[u8], ctx: &[u8]) -> SpaceResult<Vec<u8>> {
161161+ let hkdf = Hkdf::<Sha256>::from_prk(ikm).map_err(|e| SpaceError::Hkdf {
162162+ reason: format!("invalid prk: {e}"),
163163+ })?;
149164 let mut hmac_key = [0u8; 32];
150150- hkdf.expand(&info, &mut hmac_key)
165165+ hkdf.expand(ctx, &mut hmac_key)
151166 .map_err(|e| SpaceError::Hkdf {
152167 reason: e.to_string(),
153168 })?;
154154-155155- // 3. HMAC-SHA-256 over set_hash || rev.
156169 let mut mac =
157170 <HmacSha256 as KeyInit>::new_from_slice(&hmac_key).map_err(|e| SpaceError::Hkdf {
158158- reason: format!("hmac key length: {}", e),
171171+ reason: format!("hmac key length: {e}"),
159172 })?;
160160- mac.update(set_hash);
161161- mac.update(context.rev.as_bytes());
162162- let tag = mac.finalize().into_bytes();
163163-164164- // 4. ECDSA sign SHA256(tag || rev) with the user's signing key.
165165- let mut sig_input = Vec::with_capacity(tag.len() + context.rev.len());
166166- sig_input.extend_from_slice(&tag);
167167- sig_input.extend_from_slice(context.rev.as_bytes());
168168- let sig_hash = Sha256::digest(&sig_input);
169169-170170- let sig = identity_sign(signing_key, &sig_hash).map_err(|e| SpaceError::Signature {
171171- reason: e.to_string(),
172172- })?;
173173-174174- Ok(Commit {
175175- set_hash: set_hash.to_vec(),
176176- rev: context.rev.clone(),
177177- ikm: ikm.to_vec(),
178178- tag: tag.to_vec(),
179179- sig,
180180- })
173173+ mac.update(hash);
174174+ Ok(mac.finalize().into_bytes().to_vec())
181175}
182176183183-/// Verify a commit against the supplied `SpaceContext` and verifying key.
177177+/// Verify a commit's MAC against the supplied `SpaceContext`.
184178///
185185-/// Returns `Ok(())` on success. The verifying key must be the public half of
186186-/// the signing key used during `create_commit`.
179179+/// Recomputes `ctx` from `space`, `rev`, and the commit's `ikm`, then recomputes
180180+/// the MAC over `hash` and compares it constant-time (spec line 305). It does
181181+/// **not** verify the signature (see [`verify_commit_signature`]).
187182///
188183/// # Errors
189184///
190190-/// - [`SpaceError::CommitTagMismatch`] — HMAC tag does not match (commit
191191-/// tampered or wrong context).
192192-/// - [`SpaceError::CommitSignatureInvalid`] — ECDSA signature does not verify.
193193-/// - [`SpaceError::ContextEncoding`] / [`SpaceError::Hkdf`] — internal failure.
194194-///
195195-/// # Domain separation
196196-///
197197-/// A commit signed with `scope: Records` will fail verification when the
198198-/// supplied `context.scope` is `Members` (and vice versa) because the HKDF
199199-/// info bytes differ — this is the load-bearing security property.
200200-pub fn verify_commit(
201201- context: &SpaceContext,
202202- commit: &Commit,
203203- verifying_key: &KeyData,
204204-) -> SpaceResult<()> {
205205- // Cheap structural checks first.
206206- if commit.rev != context.rev {
207207- return Err(SpaceError::JwtClaimMismatch {
208208- field: "rev".to_string(),
209209- expected: context.rev.clone(),
210210- actual: commit.rev.clone(),
211211- });
212212- }
185185+/// - [`SpaceError::CommitTagMismatch`] — the MAC does not match (commit
186186+/// tampered, or wrong context).
187187+/// - [`SpaceError::Hkdf`] — `ikm` is not 32 bytes, or derivation failed.
188188+pub fn verify_commit(context: &SpaceContext, commit: &Commit) -> SpaceResult<()> {
213189 if commit.ikm.len() != 32 {
214190 return Err(SpaceError::Hkdf {
215191 reason: format!("ikm must be 32 bytes, got {}", commit.ikm.len()),
216192 });
217193 }
218218-219219- // Recompute HMAC.
220220- let info = context.to_cbor()?;
221221- let hkdf = Hkdf::<Sha256>::new(None, &commit.ikm);
194194+ let ctx = encode_ctx(context, &commit.ikm);
195195+ let mac = derive_mac(&commit.ikm, &commit.hash, &ctx)?;
196196+ if mac.len() != commit.mac.len() {
197197+ return Err(SpaceError::CommitTagMismatch);
198198+ }
199199+ // Constant-time compare via HMAC's verify path.
200200+ let hkdf = Hkdf::<Sha256>::from_prk(&commit.ikm).map_err(|e| SpaceError::Hkdf {
201201+ reason: format!("invalid prk: {e}"),
202202+ })?;
222203 let mut hmac_key = [0u8; 32];
223223- hkdf.expand(&info, &mut hmac_key)
204204+ hkdf.expand(&ctx, &mut hmac_key)
224205 .map_err(|e| SpaceError::Hkdf {
225206 reason: e.to_string(),
226207 })?;
227227-228228- let mut mac =
208208+ let mut verifier =
229209 <HmacSha256 as KeyInit>::new_from_slice(&hmac_key).map_err(|e| SpaceError::Hkdf {
230230- reason: format!("hmac key length: {}", e),
210210+ reason: format!("hmac key length: {e}"),
231211 })?;
232232- mac.update(&commit.set_hash);
233233- mac.update(commit.rev.as_bytes());
234234- mac.verify_slice(&commit.tag)
235235- .map_err(|_| SpaceError::CommitTagMismatch)?;
212212+ verifier.update(&commit.hash);
213213+ verifier
214214+ .verify_slice(&commit.mac)
215215+ .map_err(|_| SpaceError::CommitTagMismatch)
216216+}
236217237237- // Verify ECDSA over SHA256(tag || rev).
238238- let mut sig_input = Vec::with_capacity(commit.tag.len() + commit.rev.len());
239239- sig_input.extend_from_slice(&commit.tag);
240240- sig_input.extend_from_slice(commit.rev.as_bytes());
241241- let sig_hash = Sha256::digest(&sig_input);
242242-243243- identity_validate(verifying_key, &commit.sig, &sig_hash)
244244- .map_err(|_| SpaceError::CommitSignatureInvalid)?;
245245-246246- Ok(())
218218+/// Verify that a commit's `sig` was produced over its `ctx` by `verifying_key`.
219219+///
220220+/// Per spec line 305 a reader verifies `sig` against the user's signing key for
221221+/// authenticity. The `ctx` is reconstructed from `context` (`space`, `rev`) and
222222+/// the commit's `ikm`.
223223+///
224224+/// # Errors
225225+///
226226+/// Returns [`SpaceError::CommitSignatureInvalid`] if the signature does not
227227+/// verify over the reconstructed `ctx`.
228228+pub fn verify_commit_signature(
229229+ context: &SpaceContext,
230230+ commit: &Commit,
231231+ verifying_key: &KeyData,
232232+) -> SpaceResult<()> {
233233+ let ctx = encode_ctx(context, &commit.ikm);
234234+ identity_validate(verifying_key, &commit.sig, &ctx)
235235+ .map_err(|_| SpaceError::CommitSignatureInvalid)
247236}
248237249238#[cfg(test)]
250239mod tests {
251240 use super::*;
252252- use crate::set_hash::XorSha256SetHash;
241241+ use crate::set_hash::LtHash;
253242 use atproto_identity::key::{KeyType, generate_key, to_public};
254243255255- fn test_context(scope: CommitScope) -> SpaceContext {
244244+ fn test_context() -> SpaceContext {
256245 SpaceContext {
257257- space_did: "did:plc:owner".to_string(),
258258- space_type: "app.bsky.group".to_string(),
259259- space_key: "default".to_string(),
260260- user_did: "did:plc:alice".to_string(),
261261- scope,
246246+ space: "ats://did:plc:owner/app.bsky.group/default".to_string(),
262247 rev: "3jui7kd2z2y2e".to_string(),
263248 }
264249 }
···269254 (private, public)
270255 }
271256272272- fn fresh_set_hash() -> XorSha256SetHash {
273273- let mut h = XorSha256SetHash::empty();
257257+ fn fresh_set_hash() -> LtHash {
258258+ let mut h = LtHash::empty();
274259 h.add(b"app.bsky.feed.post/3jui:bafy123");
275260 h
276261 }
277262278263 #[test]
279264 fn round_trip_create_and_verify() {
280280- let (priv_key, pub_key) = test_keypair();
281281- let ctx = test_context(CommitScope::Records);
265265+ let (priv_key, _pub_key) = test_keypair();
266266+ let ctx = test_context();
282267 let h = fresh_set_hash();
283268284269 let commit = create_commit(&h, &ctx, &priv_key).unwrap();
285285- verify_commit(&ctx, &commit, &pub_key).expect("commit must verify");
270270+ assert_eq!(commit.hash, h.digest());
271271+ assert_eq!(commit.ikm.len(), 32);
272272+ verify_commit(&ctx, &commit).expect("commit MAC must verify");
273273+ }
274274+275275+ #[test]
276276+ fn signature_is_over_ctx() {
277277+ let (priv_key, pub_key) = test_keypair();
278278+ let ctx = test_context();
279279+ let h = fresh_set_hash();
280280+ let commit = create_commit(&h, &ctx, &priv_key).unwrap();
281281+ // sig verifies over the reconstructed ctx...
282282+ verify_commit_signature(&ctx, &commit, &pub_key).expect("sig over ctx must verify");
283283+ // ...and the signed message is the ctx, not the hash: an independent
284284+ // signature over ctx equals the commit's sig (ECDSA is deterministic here).
285285+ let direct = identity_sign(&priv_key, &encode_ctx(&ctx, &commit.ikm)).unwrap();
286286+ assert_eq!(direct, commit.sig);
286287 }
287288288289 #[test]
289290 fn ikm_uniqueness_across_two_commits() {
290291 let (priv_key, _) = test_keypair();
291291- let ctx = test_context(CommitScope::Records);
292292+ let ctx = test_context();
292293 let h = fresh_set_hash();
293294 let c1 = create_commit(&h, &ctx, &priv_key).unwrap();
294295 let c2 = create_commit(&h, &ctx, &priv_key).unwrap();
295296 assert_ne!(c1.ikm, c2.ikm, "IKM must be fresh per commit");
296296- assert_ne!(c1.tag, c2.tag, "tag should change with IKM");
297297- assert_ne!(c1.sig, c2.sig, "signature should change with tag");
297297+ assert_ne!(c1.mac, c2.mac, "mac should change with IKM");
298298+ assert_eq!(c1.hash, c2.hash, "hash is stable for the same set");
298299 }
299300300300- /// **Domain separation** — the load-bearing security property.
301301- ///
302302- /// A commit signed with `scope: Records` MUST fail verification when the
303303- /// verifier presents `scope: Members`.
301301+ /// Domain separation — a commit bound to one space must fail verification
302302+ /// under a different space, because `space` is part of the `ctx`.
304303 #[test]
305305- fn domain_separation_records_vs_members() {
306306- let (priv_key, pub_key) = test_keypair();
307307- let records_ctx = test_context(CommitScope::Records);
308308- let members_ctx = SpaceContext {
309309- scope: CommitScope::Members,
310310- ..records_ctx.clone()
304304+ fn domain_separation_by_space() {
305305+ let (priv_key, _) = test_keypair();
306306+ let ctx = test_context();
307307+ let other = SpaceContext {
308308+ space: "ats://did:plc:owner/app.bsky.group/other".to_string(),
309309+ ..ctx.clone()
311310 };
312311 let h = fresh_set_hash();
312312+ let commit = create_commit(&h, &ctx, &priv_key).unwrap();
313313+ assert!(matches!(
314314+ verify_commit(&other, &commit),
315315+ Err(SpaceError::CommitTagMismatch)
316316+ ));
317317+ }
313318314314- let commit = create_commit(&h, &records_ctx, &priv_key).unwrap();
315315- let result = verify_commit(&members_ctx, &commit, &pub_key);
316316- assert!(matches!(result, Err(SpaceError::CommitTagMismatch)));
319319+ #[test]
320320+ fn tampered_mac_rejected() {
321321+ let (priv_key, _) = test_keypair();
322322+ let ctx = test_context();
323323+ let mut commit = create_commit(&fresh_set_hash(), &ctx, &priv_key).unwrap();
324324+ commit.mac[0] ^= 0xff;
325325+ assert!(matches!(
326326+ verify_commit(&ctx, &commit),
327327+ Err(SpaceError::CommitTagMismatch)
328328+ ));
317329 }
318330319331 #[test]
320320- fn tampered_tag_rejected() {
321321- let (priv_key, pub_key) = test_keypair();
322322- let ctx = test_context(CommitScope::Records);
323323- let h = fresh_set_hash();
324324- let mut commit = create_commit(&h, &ctx, &priv_key).unwrap();
325325- commit.tag[0] ^= 0xff;
326326- let result = verify_commit(&ctx, &commit, &pub_key);
327327- assert!(matches!(result, Err(SpaceError::CommitTagMismatch)));
332332+ fn tampered_hash_rejected() {
333333+ let (priv_key, _) = test_keypair();
334334+ let ctx = test_context();
335335+ let mut commit = create_commit(&fresh_set_hash(), &ctx, &priv_key).unwrap();
336336+ commit.hash[0] ^= 0xff;
337337+ // mac was over the original hash → mismatch.
338338+ assert!(matches!(
339339+ verify_commit(&ctx, &commit),
340340+ Err(SpaceError::CommitTagMismatch)
341341+ ));
328342 }
329343330344 #[test]
331331- fn tampered_rev_rejected_via_mismatch() {
332332- let (priv_key, pub_key) = test_keypair();
333333- let ctx = test_context(CommitScope::Records);
334334- let h = fresh_set_hash();
335335- let mut commit = create_commit(&h, &ctx, &priv_key).unwrap();
336336- commit.rev = "different".to_string();
337337- // Field-level mismatch happens first.
338338- let result = verify_commit(&ctx, &commit, &pub_key);
339339- assert!(matches!(result, Err(SpaceError::JwtClaimMismatch { .. })));
345345+ fn wrong_rev_context_rejected() {
346346+ let (priv_key, _) = test_keypair();
347347+ let ctx = test_context();
348348+ let commit = create_commit(&fresh_set_hash(), &ctx, &priv_key).unwrap();
349349+ let wrong = SpaceContext {
350350+ rev: "3zzzzzzzzzzzz".to_string(),
351351+ ..ctx
352352+ };
353353+ assert!(matches!(
354354+ verify_commit(&wrong, &commit),
355355+ Err(SpaceError::CommitTagMismatch)
356356+ ));
340357 }
341358342359 #[test]
343343- fn tampered_set_hash_rejected() {
360360+ fn tampered_signature_rejected() {
344361 let (priv_key, pub_key) = test_keypair();
345345- let ctx = test_context(CommitScope::Records);
346346- let h = fresh_set_hash();
347347- let mut commit = create_commit(&h, &ctx, &priv_key).unwrap();
348348- commit.set_hash[0] ^= 0xff;
349349- // The HMAC was over the original set_hash; mutated set_hash → tag mismatch.
350350- let result = verify_commit(&ctx, &commit, &pub_key);
351351- assert!(matches!(result, Err(SpaceError::CommitTagMismatch)));
362362+ let ctx = test_context();
363363+ let mut commit = create_commit(&fresh_set_hash(), &ctx, &priv_key).unwrap();
364364+ commit.sig[0] ^= 0xff;
365365+ assert!(matches!(
366366+ verify_commit_signature(&ctx, &commit, &pub_key),
367367+ Err(SpaceError::CommitSignatureInvalid)
368368+ ));
352369 }
353370371371+ /// The `ctx` byte layout: tag, then uint16be-length-prefixed fields in the
372372+ /// fixed order `[space, rev, ikm]` (spec lines 293–297).
354373 #[test]
355355- fn wrong_user_context_rejected() {
356356- let (priv_key, pub_key) = test_keypair();
357357- let ctx = test_context(CommitScope::Records);
358358- let h = fresh_set_hash();
359359- let commit = create_commit(&h, &ctx, &priv_key).unwrap();
360360-361361- let wrong_ctx = SpaceContext {
362362- user_did: "did:plc:eve".to_string(),
363363- ..ctx
374374+ fn commit_ctx_layout() {
375375+ let ctx = SpaceContext {
376376+ space: "s".to_string(),
377377+ rev: "r".to_string(),
364378 };
365365- let result = verify_commit(&wrong_ctx, &commit, &pub_key);
366366- assert!(matches!(result, Err(SpaceError::CommitTagMismatch)));
379379+ let ikm = [0xABu8; 4];
380380+ let encoded = encode_ctx(&ctx, &ikm);
381381+ let mut expected = b"atproto-space-v1".to_vec();
382382+ for f in [b"s".as_slice(), b"r".as_slice(), &ikm] {
383383+ expected.extend_from_slice(&(f.len() as u16).to_be_bytes());
384384+ expected.extend_from_slice(f);
385385+ }
386386+ assert_eq!(encoded, expected);
367387 }
368388}
+342-152
crates/atproto-space/src/credential.rs
···11-//! `MemberGrant` and `SpaceCredential` JWTs.
11+//! `DelegationToken` and `SpaceCredential` JWTs (0016 Permissioned Data).
22//!
33-//! a syncing app obtains a
44-//! `SpaceCredential` via a two-step flow:
33+//! A syncing app obtains a `SpaceCredential` via a two-step flow, per the
44+//! 0016 spec "Credential flow" (README lines 232-254):
55//!
66-//! 1. App with OAuth on a member's PDS calls `getMemberGrant {space, clientId}`.
77-//! The member's PDS returns a `MemberGrant` JWT signed with the member's
88-//! atproto signing key, scoped to `clientId`, `aud=owner_did`,
99-//! `lxm=com.atproto.space.getSpaceCredential`. ~5 minute TTL.
1010-//! 2. App calls `getSpaceCredential {grant}` against the owner's PDS. Owner
1111-//! verifies the grant (resolving member's DID doc, checking signature,
1212-//! `lxm`, `clientId`, expiration), then mints a `SpaceCredential` JWT
1313-//! signed with the owner's atproto signing key. 2–4h TTL (default 3h).
66+//! 1. An app holding an OAuth session on a member's PDS calls
77+//! [`com.atproto.space.getDelegationToken`]. The member's PDS mints a
88+//! **delegation token** (spec "Delegation token", lines 147-176): a JWT with
99+//! header `typ=atproto-space-delegation+jwt`, `kid="#atproto"`, signed by
1010+//! the member's atproto signing key. Claims: `iss` (member DID),
1111+//! `aud=<spaceDid>#atproto_space_host`, `sub` (the space `ats://` URI),
1212+//! `iat`, `exp=iat+60`, `jti`. It carries no `lxm` claim and says nothing
1313+//! about the app. Single-use, default 60-second TTL.
1414+//! 2. The app presents that delegation token (in the `Authorization: Bearer`
1515+//! header) plus an optional client attestation to the space authority at
1616+//! [`com.atproto.space.getSpaceCredential`]. The authority verifies it and
1717+//! mints a **space credential** (spec "Space credential", lines 200-230): a
1818+//! JWT with header `typ=atproto-space-credential+jwt`,
1919+//! `kid="#atproto_space"`, signed by the authority's space signing key.
2020+//! Claims: `iss` (authority DID), `sub` (the space `ats://` URI),
2121+//! `client_id` (the attested app, omitted when no attestation), `iat`,
2222+//! `exp=iat+7200`, `jti`. It has no `aud`. Default 2-hour TTL.
1423//!
1515-//! Both JWTs use the same compact-form encoding: `b64url(header).b64url(payload).b64url(sig)`,
1616-//! signed with ECDSA over the user's atproto signing key (P-256 → ES256, K-256 → ES256K).
2424+//! Both JWTs use the same compact-form encoding:
2525+//! `b64url(header).b64url(payload).b64url(sig)`, signed with ECDSA over an
2626+//! atproto signing key (P-256 → ES256, K-256 → ES256K).
2727+//!
2828+//! [`com.atproto.space.getDelegationToken`]: https://atproto.com
2929+//! [`com.atproto.space.getSpaceCredential`]: https://atproto.com
17301831use crate::errors::{SpaceError, SpaceResult};
1932use crate::types::SpaceUri;
2033use atproto_identity::key::{KeyData, sign as identity_sign, validate as identity_validate};
2134use base64::{Engine as _, engine::general_purpose};
3535+use rand::RngExt;
2236use serde::{Deserialize, Serialize};
2337use std::time::{SystemTime, UNIX_EPOCH};
24382525-/// `typ` header value for MemberGrant.
2626-pub const TYP_MEMBER_GRANT: &str = "space_member_grant";
3939+/// `typ` header value for a delegation token (spec line 152).
4040+pub const TYP_DELEGATION_TOKEN: &str = "atproto-space-delegation+jwt";
27412828-/// `typ` header value for SpaceCredential.
2929-pub const TYP_SPACE_CREDENTIAL: &str = "space_credential";
4242+/// `typ` header value for a space credential (spec line 206).
4343+pub const TYP_SPACE_CREDENTIAL: &str = "atproto-space-credential+jwt";
30443131-/// `lxm` value required on MemberGrant for use at `getSpaceCredential`.
3232-pub const LXM_GET_SPACE_CREDENTIAL: &str = "com.atproto.space.getSpaceCredential";
4545+/// `kid` header value a delegation token MUST carry (spec line 162).
4646+pub const KID_DELEGATION_TOKEN: &str = "#atproto";
33473434-/// MemberGrant default TTL: 5 minutes.
3535-pub const MEMBER_GRANT_TTL_SECS: u64 = 300;
4848+/// `kid` header value a space credential MUST carry (spec line 216).
4949+pub const KID_SPACE_CREDENTIAL: &str = "#atproto_space";
36503737-/// SpaceCredential default TTL: 3 hours (within spec's 2–4h window).
3838-pub const SPACE_CREDENTIAL_TTL_SECS: u64 = 10800;
5151+/// Delegation-token default TTL: 60 seconds (spec lines 149, 169).
5252+pub const DELEGATION_TOKEN_TTL_SECS: u64 = 60;
5353+5454+/// SpaceCredential default TTL: 2 hours / 7200 seconds (spec line 223).
5555+pub const SPACE_CREDENTIAL_TTL_SECS: u64 = 7200;
5656+5757+/// The `aud` of a delegation token: the space host service fragment of the
5858+/// authority DID (`<spaceDid>#atproto_space_host`, spec line 166).
5959+#[must_use]
6060+pub fn space_host_audience(space_did: &str) -> String {
6161+ format!("{space_did}#atproto_space_host")
6262+}
39634064#[derive(Debug, Clone, Serialize, Deserialize)]
4165struct JwtHeader {
4266 alg: String,
4367 typ: String,
6868+ kid: String,
4469}
45704646-/// Decoded MemberGrant payload.
7171+/// Decoded delegation-token payload (spec lines 164-171).
4772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4848-pub struct MemberGrant {
4949- /// Issuer DID — the member.
7373+pub struct DelegationToken {
7474+ /// Issuer DID — the member (user) delegating to the app.
5075 pub iss: String,
5151- /// Audience DID — the space owner.
7676+ /// Audience — the space host service fragment
7777+ /// (`<spaceDid>#atproto_space_host`).
5278 pub aud: String,
5353- /// Space URI.
5454- pub space: String,
5555- /// OAuth `client_id` of the requesting app.
5656- #[serde(rename = "clientId")]
5757- pub client_id: String,
5858- /// Lexicon method this grant is good for.
5959- pub lxm: String,
7979+ /// Subject — the space being requested, an `ats://` URI.
8080+ pub sub: String,
6081 /// Issued-at timestamp (seconds since epoch).
6182 pub iat: u64,
6283 /// Expiration timestamp (seconds since epoch).
6384 pub exp: u64,
8585+ /// Random nonce (UUIDv4) for single-use enforcement.
8686+ pub jti: String,
6487}
65886666-/// Decoded SpaceCredential payload.
8989+/// Decoded SpaceCredential payload (spec lines 218-225).
6790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6891pub struct SpaceCredential {
6969- /// Issuer DID — the space owner.
9292+ /// Issuer DID — the space authority.
7093 pub iss: String,
7171- /// Space URI.
7272- pub space: String,
7373- /// OAuth `client_id` the credential is bound to.
7474- #[serde(rename = "clientId")]
7575- pub client_id: String,
9494+ /// Subject — the space the credential reads, an `ats://` URI.
9595+ pub sub: String,
9696+ /// Attested application identity (the verified client attestation's
9797+ /// `iss`). Omitted on the wire when the request carried no attestation
9898+ /// (spec lines 221, 228).
9999+ #[serde(skip_serializing_if = "Option::is_none", default)]
100100+ pub client_id: Option<String>,
76101 /// Issued-at timestamp.
77102 pub iat: u64,
78103 /// Expiration timestamp.
79104 pub exp: u64,
105105+ /// Random nonce (UUIDv4).
106106+ pub jti: String,
80107}
8110882109fn now_secs() -> u64 {
···86113 .as_secs()
87114}
881158989-/// Re-export of the shared `jws_alg` helper. Keeps existing call sites
9090-/// inside this module unchanged when they delegate via the alias.
9191-fn jws_alg(key: &KeyData) -> &'static str {
9292- atproto_identity::key::jws_alg(key)
116116+/// Generate a random UUIDv4-shaped nonce for the `jti` claim.
117117+///
118118+/// `jti` is an opaque nonce (it is never parsed), so we mint a v4-formatted
119119+/// string from the OS RNG without a dedicated UUID dependency.
120120+fn random_jti() -> String {
121121+ let mut b = [0u8; 16];
122122+ rand::rng().fill(&mut b);
123123+ b[6] = (b[6] & 0x0f) | 0x40; // version 4
124124+ b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
125125+ format!(
126126+ "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
127127+ b[0],
128128+ b[1],
129129+ b[2],
130130+ b[3],
131131+ b[4],
132132+ b[5],
133133+ b[6],
134134+ b[7],
135135+ b[8],
136136+ b[9],
137137+ b[10],
138138+ b[11],
139139+ b[12],
140140+ b[13],
141141+ b[14],
142142+ b[15]
143143+ )
144144+}
145145+146146+/// Resolve the JWS `alg` header for `key`, restricted to the two algorithms
147147+/// the spec permits in space-token headers (ES256 / ES256K, spec lines 161,
148148+/// 215). Other key types (P-384, Ed25519) are rejected here so the minted
149149+/// header can never carry a non-conformant `alg`.
150150+fn space_jws_alg(key: &KeyData) -> SpaceResult<&'static str> {
151151+ match atproto_identity::key::jws_alg(key) {
152152+ alg @ ("ES256" | "ES256K") => Ok(alg),
153153+ other => Err(SpaceError::Signature {
154154+ reason: format!("space tokens require an ES256 or ES256K signing key; got alg {other}"),
155155+ }),
156156+ }
93157}
9415895159fn b64url_encode(bytes: &[u8]) -> String {
···104168 })
105169}
106170107107-fn mint_jwt<P: Serialize>(typ: &str, payload: &P, signing_key: &KeyData) -> SpaceResult<String> {
171171+fn mint_jwt<P: Serialize>(
172172+ typ: &str,
173173+ kid: &str,
174174+ payload: &P,
175175+ signing_key: &KeyData,
176176+) -> SpaceResult<String> {
108177 let header = JwtHeader {
109109- alg: jws_alg(signing_key).to_string(),
178178+ alg: space_jws_alg(signing_key)?.to_string(),
110179 typ: typ.to_string(),
180180+ kid: kid.to_string(),
111181 };
112182 let header_json = serde_json::to_vec(&header).map_err(|e| SpaceError::JwtEncoding {
113183 reason: e.to_string(),
···131201fn verify_jwt<P: for<'de> Deserialize<'de>>(
132202 token: &str,
133203 expected_typ: &str,
204204+ expected_kid: &str,
134205 verifying_key: &KeyData,
135206) -> SpaceResult<P> {
136207 let parts: Vec<&str> = token.split('.').collect();
···152223 actual: header.typ,
153224 });
154225 }
155155- let expected_alg = jws_alg(verifying_key);
226226+ if header.kid != expected_kid {
227227+ return Err(SpaceError::JwtClaimMismatch {
228228+ field: "kid".to_string(),
229229+ expected: expected_kid.to_string(),
230230+ actual: header.kid,
231231+ });
232232+ }
233233+ let expected_alg = space_jws_alg(verifying_key)?;
156234 if header.alg != expected_alg {
157235 return Err(SpaceError::JwtClaimMismatch {
158236 field: "alg".to_string(),
···182260 Ok(())
183261}
184262185185-/// Mint a MemberGrant signed by the member's atproto signing key.
263263+/// Mint a delegation token signed by the member's atproto signing key.
264264+///
265265+/// The token's `aud` is set to the space host service fragment
266266+/// (`<spaceDid>#atproto_space_host`) and `sub` to the space `ats://` URI, per
267267+/// spec lines 166-167. The header carries `kid="#atproto"`.
186268///
187269/// # Errors
188270///
189189-/// Returns [`SpaceError::JwtEncoding`] / [`SpaceError::Signature`] on failure.
190190-pub fn create_member_grant(
271271+/// Returns [`SpaceError::JwtEncoding`] / [`SpaceError::Signature`] on failure,
272272+/// including when `member_signing_key` is not an ES256/ES256K key.
273273+pub fn create_delegation_token(
191274 member_did: &str,
192192- owner_did: &str,
193275 space: &SpaceUri,
194194- client_id: &str,
195276 member_signing_key: &KeyData,
196277 ttl_secs: u64,
197278) -> SpaceResult<String> {
198279 let iat = now_secs();
199280 let exp = iat + ttl_secs;
200200- let payload = MemberGrant {
281281+ let payload = DelegationToken {
201282 iss: member_did.to_string(),
202202- aud: owner_did.to_string(),
203203- space: space.to_string(),
204204- client_id: client_id.to_string(),
205205- lxm: LXM_GET_SPACE_CREDENTIAL.to_string(),
283283+ aud: space_host_audience(&space.space_did),
284284+ sub: space.to_string(),
206285 iat,
207286 exp,
287287+ jti: random_jti(),
208288 };
209209- mint_jwt(TYP_MEMBER_GRANT, &payload, member_signing_key)
289289+ mint_jwt(
290290+ TYP_DELEGATION_TOKEN,
291291+ KID_DELEGATION_TOKEN,
292292+ &payload,
293293+ member_signing_key,
294294+ )
210295}
211296212212-/// Verify a MemberGrant against the member's verifying key and expected claims.
297297+/// Verify a delegation token against the member's verifying key and expected
298298+/// claims.
213299///
214214-/// Checks: signature, `typ`, `alg`, `aud=owner_did`, `space`, `lxm`, `clientId`, `exp`.
300300+/// Checks: signature, header `typ`/`kid`/`alg`, `aud` (the space host service
301301+/// fragment of the authority DID), `sub` (the space URI), and `exp`. The
302302+/// caller is responsible for enforcing single-use via `jti`.
215303///
216304/// # Errors
217305///
218306/// Returns the relevant `SpaceError` on any check failure.
219219-pub fn verify_member_grant(
307307+pub fn verify_delegation_token(
220308 token: &str,
221221- expected_owner_did: &str,
309309+ expected_authority_did: &str,
222310 expected_space: &SpaceUri,
223223- expected_client_id: &str,
224311 member_verifying_key: &KeyData,
225225-) -> SpaceResult<MemberGrant> {
226226- let payload: MemberGrant = verify_jwt(token, TYP_MEMBER_GRANT, member_verifying_key)?;
312312+) -> SpaceResult<DelegationToken> {
313313+ let payload: DelegationToken = verify_jwt(
314314+ token,
315315+ TYP_DELEGATION_TOKEN,
316316+ KID_DELEGATION_TOKEN,
317317+ member_verifying_key,
318318+ )?;
227319228228- if payload.aud != expected_owner_did {
320320+ let expected_aud = space_host_audience(expected_authority_did);
321321+ if payload.aud != expected_aud {
229322 return Err(SpaceError::JwtClaimMismatch {
230323 field: "aud".to_string(),
231231- expected: expected_owner_did.to_string(),
324324+ expected: expected_aud,
232325 actual: payload.aud,
233326 });
234327 }
235235- let expected_space_str = expected_space.to_string();
236236- if payload.space != expected_space_str {
237237- return Err(SpaceError::JwtClaimMismatch {
238238- field: "space".to_string(),
239239- expected: expected_space_str,
240240- actual: payload.space,
241241- });
242242- }
243243- if payload.lxm != LXM_GET_SPACE_CREDENTIAL {
244244- return Err(SpaceError::JwtClaimMismatch {
245245- field: "lxm".to_string(),
246246- expected: LXM_GET_SPACE_CREDENTIAL.to_string(),
247247- actual: payload.lxm,
248248- });
249249- }
250250- if payload.client_id != expected_client_id {
328328+ let expected_sub = expected_space.to_string();
329329+ if payload.sub != expected_sub {
251330 return Err(SpaceError::JwtClaimMismatch {
252252- field: "clientId".to_string(),
253253- expected: expected_client_id.to_string(),
254254- actual: payload.client_id,
331331+ field: "sub".to_string(),
332332+ expected: expected_sub,
333333+ actual: payload.sub,
255334 });
256335 }
257336 check_exp(payload.exp)?;
258337 Ok(payload)
259338}
260339261261-/// Mint a SpaceCredential signed by the space owner's atproto signing key.
340340+/// Mint a space credential signed by the space authority's `#atproto_space`
341341+/// signing key.
342342+///
343343+/// `client_id` is the attested application identity (the verified client
344344+/// attestation's `iss`); pass `None` when the request carried no attestation,
345345+/// in which case the claim is omitted (spec lines 221, 228). The header
346346+/// carries `kid="#atproto_space"` and the payload has no `aud`.
347347+///
348348+/// # Errors
349349+///
350350+/// Returns [`SpaceError::JwtEncoding`] / [`SpaceError::Signature`] on failure.
262351pub fn create_space_credential(
263263- owner_did: &str,
352352+ authority_did: &str,
264353 space: &SpaceUri,
265265- client_id: &str,
266266- owner_signing_key: &KeyData,
354354+ client_id: Option<&str>,
355355+ authority_signing_key: &KeyData,
267356 ttl_secs: u64,
268357) -> SpaceResult<String> {
269358 let iat = now_secs();
270359 let exp = iat + ttl_secs;
271360 let payload = SpaceCredential {
272272- iss: owner_did.to_string(),
273273- space: space.to_string(),
274274- client_id: client_id.to_string(),
361361+ iss: authority_did.to_string(),
362362+ sub: space.to_string(),
363363+ client_id: client_id.map(str::to_string),
275364 iat,
276365 exp,
366366+ jti: random_jti(),
277367 };
278278- mint_jwt(TYP_SPACE_CREDENTIAL, &payload, owner_signing_key)
368368+ mint_jwt(
369369+ TYP_SPACE_CREDENTIAL,
370370+ KID_SPACE_CREDENTIAL,
371371+ &payload,
372372+ authority_signing_key,
373373+ )
279374}
280375281281-/// Verify a SpaceCredential against the owner's verifying key and expected claims.
376376+/// Verify a space credential against the authority's verifying key and
377377+/// expected claims.
378378+///
379379+/// Checks: signature, header `typ`/`kid`/`alg`, `iss` (the authority DID),
380380+/// `sub` (the space URI), and `exp`.
381381+///
382382+/// # Errors
383383+///
384384+/// Returns the relevant `SpaceError` on any check failure.
282385pub fn verify_space_credential(
283386 token: &str,
284284- expected_owner_did: &str,
387387+ expected_authority_did: &str,
285388 expected_space: &SpaceUri,
286286- owner_verifying_key: &KeyData,
389389+ authority_verifying_key: &KeyData,
287390) -> SpaceResult<SpaceCredential> {
288288- let payload: SpaceCredential = verify_jwt(token, TYP_SPACE_CREDENTIAL, owner_verifying_key)?;
391391+ let payload: SpaceCredential = verify_jwt(
392392+ token,
393393+ TYP_SPACE_CREDENTIAL,
394394+ KID_SPACE_CREDENTIAL,
395395+ authority_verifying_key,
396396+ )?;
289397290290- if payload.iss != expected_owner_did {
398398+ if payload.iss != expected_authority_did {
291399 return Err(SpaceError::JwtClaimMismatch {
292400 field: "iss".to_string(),
293293- expected: expected_owner_did.to_string(),
401401+ expected: expected_authority_did.to_string(),
294402 actual: payload.iss,
295403 });
296404 }
297297- let expected_space_str = expected_space.to_string();
298298- if payload.space != expected_space_str {
405405+ let expected_sub = expected_space.to_string();
406406+ if payload.sub != expected_sub {
299407 return Err(SpaceError::JwtClaimMismatch {
300300- field: "space".to_string(),
301301- expected: expected_space_str,
302302- actual: payload.space,
408408+ field: "sub".to_string(),
409409+ expected: expected_sub,
410410+ actual: payload.sub,
303411 });
304412 }
305413 check_exp(payload.exp)?;
···327435 }
328436329437 #[test]
330330- fn member_grant_round_trip() {
438438+ fn delegation_token_round_trip() {
331439 let (member_priv, member_pub) = keypair();
332440 let space = test_space();
333333- let token = create_member_grant(
441441+ let token = create_delegation_token(
334442 "did:plc:alice",
335335- "did:plc:owner",
336443 &space,
337337- "https://app.example/client-metadata.json",
338444 &member_priv,
339339- MEMBER_GRANT_TTL_SECS,
445445+ DELEGATION_TOKEN_TTL_SECS,
340446 )
341447 .unwrap();
342448343343- let payload = verify_member_grant(
344344- &token,
345345- "did:plc:owner",
449449+ let payload =
450450+ verify_delegation_token(&token, "did:plc:owner", &space, &member_pub).unwrap();
451451+ assert_eq!(payload.iss, "did:plc:alice");
452452+ assert_eq!(payload.aud, "did:plc:owner#atproto_space_host");
453453+ assert_eq!(payload.sub, space.to_string());
454454+ }
455455+456456+ #[test]
457457+ fn delegation_token_header_is_spec_exact() {
458458+ let (member_priv, _) = keypair();
459459+ let space = test_space();
460460+ let token = create_delegation_token(
461461+ "did:plc:alice",
346462 &space,
347347- "https://app.example/client-metadata.json",
348348- &member_pub,
463463+ &member_priv,
464464+ DELEGATION_TOKEN_TTL_SECS,
349465 )
350466 .unwrap();
351351- assert_eq!(payload.iss, "did:plc:alice");
352352- assert_eq!(payload.aud, "did:plc:owner");
467467+ let header_b64 = token.split('.').next().unwrap();
468468+ let header: serde_json::Value =
469469+ serde_json::from_slice(&b64url_decode(header_b64).unwrap()).unwrap();
470470+ assert_eq!(header["typ"], "atproto-space-delegation+jwt");
471471+ assert_eq!(header["kid"], "#atproto");
472472+ assert_eq!(header["alg"], "ES256");
353473 }
354474355475 #[test]
356356- fn member_grant_wrong_aud_rejected() {
357357- let (member_priv, member_pub) = keypair();
476476+ fn delegation_token_has_no_lxm_or_client_id() {
477477+ let (member_priv, _) = keypair();
358478 let space = test_space();
359359- let token = create_member_grant(
479479+ let token = create_delegation_token(
360480 "did:plc:alice",
361361- "did:plc:owner",
362481 &space,
363363- "client",
364482 &member_priv,
365365- MEMBER_GRANT_TTL_SECS,
483483+ DELEGATION_TOKEN_TTL_SECS,
366484 )
367485 .unwrap();
368368- let result =
369369- verify_member_grant(&token, "did:plc:other-owner", &space, "client", &member_pub);
370370- assert!(matches!(result, Err(SpaceError::JwtClaimMismatch { .. })));
486486+ let payload_b64 = token.split('.').nth(1).unwrap();
487487+ let payload: serde_json::Value =
488488+ serde_json::from_slice(&b64url_decode(payload_b64).unwrap()).unwrap();
489489+ assert!(payload.get("lxm").is_none());
490490+ assert!(payload.get("clientId").is_none());
491491+ assert!(payload.get("client_id").is_none());
492492+ assert!(payload.get("space").is_none());
493493+ assert!(payload.get("sub").is_some());
371494 }
372495373496 #[test]
374374- fn member_grant_wrong_client_rejected() {
375375- let (member_priv, member_pub) = keypair();
497497+ fn delegation_token_default_ttl_is_60s() {
498498+ let (member_priv, _) = keypair();
376499 let space = test_space();
377377- let token = create_member_grant(
500500+ let token = create_delegation_token(
378501 "did:plc:alice",
379379- "did:plc:owner",
380502 &space,
381381- "client-A",
382503 &member_priv,
383383- MEMBER_GRANT_TTL_SECS,
504504+ DELEGATION_TOKEN_TTL_SECS,
384505 )
385506 .unwrap();
386386- let result = verify_member_grant(&token, "did:plc:owner", &space, "client-B", &member_pub);
387387- assert!(matches!(result, Err(SpaceError::JwtClaimMismatch { .. })));
507507+ let payload_b64 = token.split('.').nth(1).unwrap();
508508+ let payload: DelegationToken =
509509+ serde_json::from_slice(&b64url_decode(payload_b64).unwrap()).unwrap();
510510+ assert_eq!(payload.exp - payload.iat, 60);
388511 }
389512390513 #[test]
391391- fn member_grant_expired_rejected() {
514514+ fn delegation_token_wrong_authority_rejected() {
392515 let (member_priv, member_pub) = keypair();
393516 let space = test_space();
394394- let token = create_member_grant(
517517+ let token = create_delegation_token(
395518 "did:plc:alice",
396396- "did:plc:owner",
397519 &space,
398398- "client",
399520 &member_priv,
400400- 0,
521521+ DELEGATION_TOKEN_TTL_SECS,
401522 )
402523 .unwrap();
403403- // Sleep one second to force expiration.
524524+ let result = verify_delegation_token(&token, "did:plc:other-owner", &space, &member_pub);
525525+ assert!(matches!(result, Err(SpaceError::JwtClaimMismatch { .. })));
526526+ }
527527+528528+ #[test]
529529+ fn delegation_token_expired_rejected() {
530530+ let (member_priv, member_pub) = keypair();
531531+ let space = test_space();
532532+ let token = create_delegation_token("did:plc:alice", &space, &member_priv, 0).unwrap();
404533 std::thread::sleep(std::time::Duration::from_millis(1100));
405405- let result = verify_member_grant(&token, "did:plc:owner", &space, "client", &member_pub);
534534+ let result = verify_delegation_token(&token, "did:plc:owner", &space, &member_pub);
406535 assert!(matches!(result, Err(SpaceError::JwtExpired { .. })));
407536 }
408537409538 #[test]
410410- fn member_grant_tampered_payload_rejected() {
539539+ fn delegation_token_tampered_payload_rejected() {
411540 let (member_priv, member_pub) = keypair();
412541 let space = test_space();
413413- let token = create_member_grant(
542542+ let token = create_delegation_token(
414543 "did:plc:alice",
415415- "did:plc:owner",
416544 &space,
417417- "client",
418545 &member_priv,
419419- MEMBER_GRANT_TTL_SECS,
546546+ DELEGATION_TOKEN_TTL_SECS,
420547 )
421548 .unwrap();
422422- // Flip a character in the payload portion.
423549 let mut parts: Vec<String> = token.split('.').map(String::from).collect();
424550 parts[1] = parts[1].chars().rev().collect::<String>();
425551 let tampered = parts.join(".");
426426- let result = verify_member_grant(&tampered, "did:plc:owner", &space, "client", &member_pub);
552552+ let result = verify_delegation_token(&tampered, "did:plc:owner", &space, &member_pub);
427553 assert!(result.is_err());
428554 }
429555430556 #[test]
431431- fn space_credential_round_trip() {
557557+ fn space_credential_round_trip_with_client_id() {
432558 let (owner_priv, owner_pub) = keypair();
433559 let space = test_space();
434560 let token = create_space_credential(
435561 "did:plc:owner",
436562 &space,
437437- "client",
563563+ Some("https://app.example/client-metadata.json"),
438564 &owner_priv,
439565 SPACE_CREDENTIAL_TTL_SECS,
440566 )
441567 .unwrap();
442568 let payload = verify_space_credential(&token, "did:plc:owner", &space, &owner_pub).unwrap();
443569 assert_eq!(payload.iss, "did:plc:owner");
570570+ assert_eq!(payload.sub, space.to_string());
571571+ assert_eq!(
572572+ payload.client_id.as_deref(),
573573+ Some("https://app.example/client-metadata.json")
574574+ );
575575+ }
576576+577577+ #[test]
578578+ fn space_credential_header_is_spec_exact() {
579579+ let (owner_priv, _) = keypair();
580580+ let space = test_space();
581581+ let token = create_space_credential(
582582+ "did:plc:owner",
583583+ &space,
584584+ None,
585585+ &owner_priv,
586586+ SPACE_CREDENTIAL_TTL_SECS,
587587+ )
588588+ .unwrap();
589589+ let header_b64 = token.split('.').next().unwrap();
590590+ let header: serde_json::Value =
591591+ serde_json::from_slice(&b64url_decode(header_b64).unwrap()).unwrap();
592592+ assert_eq!(header["typ"], "atproto-space-credential+jwt");
593593+ assert_eq!(header["kid"], "#atproto_space");
594594+ }
595595+596596+ #[test]
597597+ fn space_credential_omits_client_id_when_absent() {
598598+ let (owner_priv, _) = keypair();
599599+ let space = test_space();
600600+ let token = create_space_credential(
601601+ "did:plc:owner",
602602+ &space,
603603+ None,
604604+ &owner_priv,
605605+ SPACE_CREDENTIAL_TTL_SECS,
606606+ )
607607+ .unwrap();
608608+ let payload_b64 = token.split('.').nth(1).unwrap();
609609+ let payload: serde_json::Value =
610610+ serde_json::from_slice(&b64url_decode(payload_b64).unwrap()).unwrap();
611611+ assert!(payload.get("client_id").is_none());
612612+ assert!(payload.get("clientId").is_none());
613613+ assert!(payload.get("aud").is_none());
614614+ assert_eq!(payload["sub"], space.to_string());
615615+ }
616616+617617+ #[test]
618618+ fn space_credential_uses_snake_case_client_id() {
619619+ let (owner_priv, _) = keypair();
620620+ let space = test_space();
621621+ let token = create_space_credential(
622622+ "did:plc:owner",
623623+ &space,
624624+ Some("https://app.example/cm"),
625625+ &owner_priv,
626626+ SPACE_CREDENTIAL_TTL_SECS,
627627+ )
628628+ .unwrap();
629629+ let payload_b64 = token.split('.').nth(1).unwrap();
630630+ let payload: serde_json::Value =
631631+ serde_json::from_slice(&b64url_decode(payload_b64).unwrap()).unwrap();
632632+ assert_eq!(payload["client_id"], "https://app.example/cm");
633633+ assert!(payload.get("clientId").is_none());
444634 }
445635446636 #[test]
···455645 let token = create_space_credential(
456646 "did:plc:owner",
457647 &space,
458458- "client",
648648+ None,
459649 &owner_priv,
460650 SPACE_CREDENTIAL_TTL_SECS,
461651 )
+9-1
crates/atproto-space/src/errors.rs
···2222 value: String,
2323 },
24242525- /// error-atproto-space-types-3: invalid space key (must be non-empty, no slashes).
2525+ /// error-atproto-space-types-3: invalid space key (must satisfy `rkey` syntax:
2626+ /// 1-512 bytes, charset `[A-Za-z0-9._:~-]`, not `.` or `..`).
2627 #[error("error-atproto-space-types-3 invalid space key: {value}")]
2728 InvalidSpaceKey {
2829 /// The value that was not a valid space key.
···158159 since: Option<String>,
159160 /// Earliest rev still retained.
160161 earliest: Option<String>,
162162+ },
163163+164164+ /// error-atproto-space-storage-3: malformed oplog cursor token.
165165+ #[error("error-atproto-space-storage-3 invalid oplog cursor: {token}")]
166166+ InvalidCursor {
167167+ /// The cursor token that failed to parse.
168168+ token: String,
161169 },
162170}
163171
+25-23
crates/atproto-space/src/lib.rs
···11//! AT Protocol permissioned-data spaces — primitives.
22//!
33//! This crate implements the protocol primitives from the
44-//! [Spaces Design Spec](https://github.com/bluesky-social/atproto/blob/main/docs/superpowers/specs/2026-04-22-permissioned-data-pds-design.md):
44+//! [0016 Permissioned Data][spec] draft, which is the authoritative alignment
55+//! target for this crate:
56//!
66-//! - **`SetHash`** trait + `XorSha256SetHash` placeholder + `EcmhSetHash` (production target).
77-//! - **`Commit`** — HKDF-derived HMAC + ECDSA-signed commit over a `SetHash` digest with
88-//! per-commit random IKM for deniability and `scope` domain separation.
99-//! - **`SpaceRepo`** / **`SpaceMembers`** — orchestrators over storage trait surfaces.
1010-//! - **`MemberGrant`** / **`SpaceCredential`** — JWT shapes for the two-step credential flow.
77+//! - **`SetHash`** trait + **`LtHash`** (the production primitive).
88+//! - **`Commit`** — a signed commit (`com.atproto.space.defs#signedCommit`):
99+//! `mac = HMAC(HKDF(ikm, ctx), hash)` binds the repo hash to the per-commit
1010+//! context, while `sig` covers only `ctx` (the space URI, rev, and per-commit
1111+//! random `ikm`) — never the hash — so a leaked commit is deniable (spec
1212+//! lines 285-316).
1313+//! - **`SpaceRepo`** — per-(user, space) record orchestrator over the storage
1414+//! trait surface. **`SpaceMembers`** — the `simplespace` member-list
1515+//! orchestrator (the spec carries no member commits or member sync).
1616+//! - **`DelegationToken`** / **`SpaceCredential`** — JWT shapes for the two-step credential flow.
1117//!
1218//! The crate is **server-agnostic** — it does no IO directly and has no network
1319//! dependencies. It composes with `atproto-pds` (and AppView consumers) via the
···1521//!
1622//! # Status
1723//!
1818-//! Experimental. The Spaces Design Spec is still settling. Several primitives
1919-//! (notably the `SetHash` algorithm) are explicitly marked as "placeholder until
2020-//! upstream picks ECMH or ltHash" by the spec itself.
2424+//! Experimental. 0016 is a draft; details may still change upstream.
2125//!
2226//! # References
2327//!
2424-//! - [Spaces Design Spec](https://github.com/bluesky-social/atproto/blob/main/docs/superpowers/specs/2026-04-22-permissioned-data-pds-design.md)
2525-//! - Design document: see `` §15.
2828+//! - [0016 Permissioned Data][spec]
2929+//!
3030+//! [spec]: https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md
26312732#![forbid(unsafe_code)]
2833#![warn(missing_docs)]
···3035pub mod commit;
3136pub mod credential;
3237pub mod errors;
3333-pub mod recon;
3438pub mod set_hash;
3535-#[cfg(feature = "ecmh")]
3636-pub mod set_hash_ecmh;
3739pub mod space_members;
3840pub mod space_repo;
3941pub mod storage;
4042pub mod types;
41434244// Re-exports for the canonical public API surface.
4343-pub use commit::{Commit, CommitScope, SpaceContext, create_commit, verify_commit};
4545+pub use commit::{
4646+ Commit, SpaceContext, create_commit, encode_ctx, verify_commit, verify_commit_signature,
4747+};
4448pub use credential::{
4545- MemberGrant, SpaceCredential, create_member_grant, create_space_credential,
4646- verify_member_grant, verify_space_credential,
4949+ DelegationToken, SpaceCredential, create_delegation_token, create_space_credential,
5050+ verify_delegation_token, verify_space_credential,
4751};
4852pub use errors::SpaceError;
4949-pub use set_hash::{SetHash, XorSha256SetHash};
5050-#[cfg(feature = "ecmh")]
5151-pub use set_hash_ecmh::EcmhSetHash;
5353+pub use set_hash::{LtHash, SetHash};
5254pub use space_members::{MemberOp, MemberOpAction, SpaceMembers};
5355pub use space_repo::{Op, OpAction, PreparedCommit, SpaceRepo};
5456pub use storage::{
5555- MemberPage, MemberRow, MemberState, OplogEntry, OplogPage, RecordPage, RecordRow, RepoState,
5656- SpaceMembersStorage, SpaceRepoStorage,
5757+ MemberPage, MemberRow, MemberState, OplogCursor, OplogEntry, OplogPage, RecordPage, RecordRow,
5858+ RepoState, SpaceMembersStorage, SpaceRepoStorage,
5759};
5858-pub use types::{SpaceKey, SpaceType, SpaceUri};
6060+pub use types::{RecordUri, SpaceKey, SpaceType, SpaceUri};
+166-136
crates/atproto-space/src/set_hash.rs
···11//! Set hash primitives for permissioned-data spaces.
22//!
33//! The [`SetHash`] trait abstracts over a homomorphic, order-independent set
44-//! commitment. Two impls are planned:
44+//! commitment. The production primitive is [`LtHash`], per the
55+//! [0016 Permissioned Data][spec] draft (§ "Commit digest", lines 263-282):
56//!
66-//! - [`XorSha256SetHash`] — XOR-of-SHA256 placeholder, matches the Spaces
77-//! Design Spec's current default. Add and remove are both XOR (self-inverse),
88-//! so add-then-remove reverts to the empty digest.
99-//! - `EcmhSetHash` — production target via the first-party `ecmh-rs` crate.
1010-//! Implemented in Phase 8 conditional on upstream picking ECMH (vs. ltHash).
77+//! - [`LtHash`] — the **production** primitive. A homomorphic lattice hash
88+//! (BLAKE3-XOF over a 2048-byte / 1024-lane `u16` state, add/sub mod 2^16).
99+//! The carried commitment is `sha256(state)`. Quantum-secure per the spec.
1110//!
1211//! All impls share the same algebraic invariants:
1313-//! - Order-independent: add(a); add(b) == add(b); add(a)
1414-//! - Inverse: add(x); remove(x) returns to the prior digest
1515-//! - Composable: digests can be compared by byte equality
1212+//! - Order-independent: `add(a); add(b) == add(b); add(a)`
1313+//! - Inverse: `add(x); remove(x)` returns to the prior state
1414+//! - Composable: states/digests compare by byte equality
1615//!
1717-//! Different impls produce **different digest values** — a SetHash from one
1818-//! impl is not comparable with another. The upstream picks one global default;
1919-//! deployments mirror it.
1616+//! Two byte forms are distinguished, because for `LtHash` they differ:
1717+//! - [`SetHash::state_bytes`] — the full lattice **state**, persisted by a repo
1818+//! host and rehydrated via [`SetHash::from_state_bytes`] (2048 bytes for
1919+//! `LtHash`).
2020+//! - [`SetHash::digest`] — the 32-byte **commitment** carried in a
2121+//! [`crate::commit::Commit`] (`hash` field). For `LtHash` this is
2222+//! `sha256(state_bytes())`.
2323+//!
2424+//! [spec]: https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md
20252126use crate::errors::{SpaceError, SpaceResult};
2227use sha2::{Digest, Sha256};
23282929+/// Number of `u16` lanes in an [`LtHash`] state (per the reference).
3030+const LANES: usize = 1024;
3131+/// Byte length of an [`LtHash`] state (`LANES * 2`).
3232+const STATE_BYTES: usize = LANES * 2;
3333+2434/// Trait for set-hash commitment primitives.
2535///
2636/// Implementations must satisfy the algebraic invariants documented at the
2727-/// crate level: order-independence, add/remove inverse, byte-equality of digests.
3737+/// crate level: order-independence, add/remove inverse, byte-equality of
3838+/// states.
2839pub trait SetHash: Sized + Clone + Send + Sync {
2940 /// Construct an empty set hash.
3041 fn empty() -> Self;
···4051 /// membership data structure if you need that.
4152 fn remove(&mut self, element: &[u8]);
42534343- /// Get the digest as raw bytes.
4444- fn digest(&self) -> Vec<u8>;
5454+ /// The persistable lattice **state** bytes.
5555+ ///
5656+ /// A repo host stores this and rehydrates via [`SetHash::from_state_bytes`].
5757+ /// For [`LtHash`] this is the full 2048-byte state, **not** the 32-byte
5858+ /// commitment.
5959+ fn state_bytes(&self) -> Vec<u8>;
45604646- /// Reconstruct from a serialized digest.
6161+ /// Reconstruct from previously persisted [`SetHash::state_bytes`].
4762 ///
4863 /// # Errors
4964 ///
5050- /// Returns [`SpaceError::SetHashCodec`] if the bytes do not deserialize
5151- /// for this impl's expected digest size or shape.
5252- fn from_digest(bytes: &[u8]) -> SpaceResult<Self>;
6565+ /// Returns [`SpaceError::SetHashCodec`] if the bytes are not the expected
6666+ /// length for this impl's state.
6767+ fn from_state_bytes(bytes: &[u8]) -> SpaceResult<Self>;
6868+6969+ /// The 32-byte commitment digest carried in a [`crate::commit::Commit`].
7070+ ///
7171+ /// For [`LtHash`] this is `sha256(state_bytes())`.
7272+ fn digest(&self) -> Vec<u8>;
5373}
54745555-/// XOR-of-SHA256 set hash — the Spaces Design Spec's current placeholder primitive.
7575+/// Homomorphic lattice set hash ([LtHash]), the production primitive.
5676///
5757-/// Hashes each element with SHA-256, then XOR-folds the 32-byte digest into
5858-/// the running accumulator. Add and remove are both XOR (self-inverse), so
5959-/// the algebra is naturally homomorphic over multiset deltas.
7777+/// State is a fixed 2048-byte buffer interpreted as 1024 little-endian `u16`
7878+/// lanes. An element is expanded to 2048 bytes with BLAKE3 in XOF mode and its
7979+/// 1024 lanes are added into (or subtracted from) the state, modulo 2^16. The
8080+/// commitment [`digest`](SetHash::digest) is `sha256` of the 2048-byte state.
8181+///
8282+/// This follows the 0016 spec's LtHash construction (§ "Commit digest", lines
8383+/// 263-282) exactly, so digests are comparable across implementations.
6084///
6161-/// **Not cryptographically strong** — XOR-of-hash is forgeable by anyone who
6262-/// can choose `~2^128` element pairs (birthday-bound). The Spec marks this as
6363-/// "to be replaced by ECMH or ltHash before production."
8585+/// [LtHash]: https://eprint.iacr.org/2019/227
6486#[derive(Debug, Clone, PartialEq, Eq)]
6565-pub struct XorSha256SetHash([u8; 32]);
8787+pub struct LtHash {
8888+ /// 1024 little-endian `u16` lanes. Arithmetic wraps mod 2^16.
8989+ lanes: [u16; LANES],
9090+}
66916767-impl XorSha256SetHash {
6868- /// Construct from raw 32-byte digest.
6969- #[must_use]
7070- pub fn from_bytes(bytes: [u8; 32]) -> Self {
7171- Self(bytes)
7272- }
7373-7474- /// Get the digest as a fixed-size array.
7575- #[must_use]
7676- pub fn as_bytes(&self) -> &[u8; 32] {
7777- &self.0
9292+impl LtHash {
9393+ /// Expand an element to 1024 `u16` lanes via BLAKE3 in XOF mode.
9494+ fn expand(element: &[u8]) -> [u16; LANES] {
9595+ let mut buf = [0u8; STATE_BYTES];
9696+ let mut hasher = blake3::Hasher::new();
9797+ hasher.update(element);
9898+ hasher.finalize_xof().fill(&mut buf);
9999+ let mut lanes = [0u16; LANES];
100100+ for (i, lane) in lanes.iter_mut().enumerate() {
101101+ *lane = u16::from_le_bytes([buf[2 * i], buf[2 * i + 1]]);
102102+ }
103103+ lanes
78104 }
79105}
801068181-impl SetHash for XorSha256SetHash {
107107+impl SetHash for LtHash {
82108 fn empty() -> Self {
8383- Self([0u8; 32])
109109+ Self {
110110+ lanes: [0u16; LANES],
111111+ }
84112 }
8511386114 fn add(&mut self, element: &[u8]) {
8787- let mut hasher = Sha256::new();
8888- hasher.update(element);
8989- let h = hasher.finalize();
9090- for (i, b) in h.iter().enumerate() {
9191- self.0[i] ^= *b;
115115+ let other = Self::expand(element);
116116+ for (lane, add) in self.lanes.iter_mut().zip(other.iter()) {
117117+ *lane = lane.wrapping_add(*add);
92118 }
93119 }
9412095121 fn remove(&mut self, element: &[u8]) {
9696- // XOR is self-inverse: remove == add.
9797- self.add(element);
122122+ let other = Self::expand(element);
123123+ for (lane, sub) in self.lanes.iter_mut().zip(other.iter()) {
124124+ *lane = lane.wrapping_sub(*sub);
125125+ }
98126 }
99127100100- fn digest(&self) -> Vec<u8> {
101101- self.0.to_vec()
128128+ fn state_bytes(&self) -> Vec<u8> {
129129+ let mut out = vec![0u8; STATE_BYTES];
130130+ for (i, lane) in self.lanes.iter().enumerate() {
131131+ let [lo, hi] = lane.to_le_bytes();
132132+ out[2 * i] = lo;
133133+ out[2 * i + 1] = hi;
134134+ }
135135+ out
102136 }
103137104104- fn from_digest(bytes: &[u8]) -> SpaceResult<Self> {
105105- if bytes.len() != 32 {
138138+ fn from_state_bytes(bytes: &[u8]) -> SpaceResult<Self> {
139139+ if bytes.len() != STATE_BYTES {
106140 return Err(SpaceError::SetHashCodec {
107107- reason: format!("expected 32 bytes, got {}", bytes.len()),
141141+ reason: format!(
142142+ "LtHash state must be {STATE_BYTES} bytes, got {}",
143143+ bytes.len()
144144+ ),
108145 });
109146 }
110110- let mut arr = [0u8; 32];
111111- arr.copy_from_slice(bytes);
112112- Ok(Self(arr))
147147+ let mut lanes = [0u16; LANES];
148148+ for (i, lane) in lanes.iter_mut().enumerate() {
149149+ *lane = u16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]);
150150+ }
151151+ Ok(Self { lanes })
152152+ }
153153+154154+ fn digest(&self) -> Vec<u8> {
155155+ Sha256::digest(self.state_bytes()).to_vec()
113156 }
114157}
115158116116-/// Format the spec-defined element bytes for a record entry in the SetHash.
159159+/// Format the element bytes for a record entry in the SetHash.
117160///
118118-/// records are hashed as
119119-/// `<collection>/<rkey>:<cid>` (UTF-8 bytes).
161161+/// Each record maps to one element, the UTF-8 bytes of
162162+/// `{collection}/{rkey}/{record_cid}` — three slash-joined components, per the
163163+/// 0016 spec (§ "Commit digest", line 270). The slash before the CID is
164164+/// load-bearing: a spec-conformant peer's digest only agrees with this one when
165165+/// the element byte strings match exactly.
120166#[must_use]
121167pub fn record_element_bytes(collection: &str, rkey: &str, cid: &str) -> Vec<u8> {
122122- format!("{collection}/{rkey}:{cid}").into_bytes()
168168+ format!("{collection}/{rkey}/{cid}").into_bytes()
123169}
124170125125-/// Format the spec-defined element bytes for a member entry in the SetHash.
171171+/// Format the element bytes for a member entry in the SetHash.
126172///
127127-/// members are hashed as
128128-/// the DID's UTF-8 bytes.
173173+/// Members are hashed as the bare DID's UTF-8 bytes. Used by the `simplespace`
174174+/// member-list orchestrator's local digest; the spec carries no member commits.
129175#[must_use]
130176pub fn member_element_bytes(did: &str) -> Vec<u8> {
131177 did.as_bytes().to_vec()
···134180#[cfg(test)]
135181mod tests {
136182 use super::*;
137137- use proptest::prelude::*;
183183+184184+ // ----- LtHash -----
138185139139- /// Helper: apply a sequence of adds and check the digest after permutation.
140140- fn digest_after_adds(elements: &[&[u8]]) -> Vec<u8> {
141141- let mut h = XorSha256SetHash::empty();
142142- for e in elements {
143143- h.add(e);
144144- }
145145- h.digest()
186186+ /// Empty state is 2048 zero bytes; its commitment is `sha256(2048 zeros)`.
187187+ #[test]
188188+ fn lthash_empty_state_and_digest() {
189189+ let h = LtHash::empty();
190190+ assert_eq!(h.state_bytes(), vec![0u8; STATE_BYTES]);
191191+ // Known-answer: sha256 of 2048 zero bytes — the empty repo's state is
192192+ // all zeroes per the spec (line 280), so its commitment is
193193+ // `sha256(zeros)`, NOT 32 zero bytes.
194194+ let expected =
195195+ hex_to_vec("e5a00aa9991ac8a5ee3109844d84a55583bd20572ad3ffcd42792f3c36b183ad");
196196+ assert_eq!(h.digest(), expected);
197197+ assert_eq!(h.digest().len(), 32);
146198 }
147199148200 #[test]
149149- fn empty_digest_is_zero() {
150150- assert_eq!(XorSha256SetHash::empty().digest(), vec![0u8; 32]);
201201+ fn lthash_add_remove_inverse() {
202202+ let mut h = LtHash::empty();
203203+ let base = h.state_bytes();
204204+ h.add(b"app.bsky.feed.post/3jui:bafy123");
205205+ assert_ne!(h.state_bytes(), base);
206206+ h.remove(b"app.bsky.feed.post/3jui:bafy123");
207207+ assert_eq!(h.state_bytes(), base);
151208 }
152209153210 #[test]
154154- fn add_remove_inverse() {
155155- let mut h = XorSha256SetHash::empty();
156156- h.add(b"alice");
157157- h.remove(b"alice");
158158- assert_eq!(h.digest(), vec![0u8; 32]);
211211+ fn lthash_order_independence() {
212212+ let mut a = LtHash::empty();
213213+ a.add(b"one");
214214+ a.add(b"two");
215215+ let mut b = LtHash::empty();
216216+ b.add(b"two");
217217+ b.add(b"one");
218218+ assert_eq!(a.state_bytes(), b.state_bytes());
219219+ assert_eq!(a.digest(), b.digest());
159220 }
160221161222 #[test]
162162- fn order_independence_two_elements() {
163163- let d1 = digest_after_adds(&[b"a", b"b"]);
164164- let d2 = digest_after_adds(&[b"b", b"a"]);
165165- assert_eq!(d1, d2);
223223+ fn lthash_state_round_trip() {
224224+ let mut h = LtHash::empty();
225225+ h.add(b"x");
226226+ h.add(b"y");
227227+ let state = h.state_bytes();
228228+ assert_eq!(state.len(), STATE_BYTES);
229229+ let h2 = LtHash::from_state_bytes(&state).unwrap();
230230+ assert_eq!(h, h2);
231231+ assert_eq!(h.digest(), h2.digest());
166232 }
167233168234 #[test]
169169- fn add_b_then_remove_a_equals_just_b() {
170170- let mut h1 = XorSha256SetHash::empty();
171171- h1.add(b"a");
172172- h1.add(b"b");
173173- h1.remove(b"a");
174174-175175- let mut h2 = XorSha256SetHash::empty();
176176- h2.add(b"b");
177177-178178- assert_eq!(h1.digest(), h2.digest());
235235+ fn lthash_from_state_bad_length_rejected() {
236236+ assert!(LtHash::from_state_bytes(&[0u8; 2047]).is_err());
237237+ assert!(LtHash::from_state_bytes(&[0u8; 32]).is_err());
238238+ assert!(LtHash::from_state_bytes(&[0u8; STATE_BYTES]).is_ok());
179239 }
180240241241+ /// Lane arithmetic wraps mod 2^16: adding the same element 65536 times
242242+ /// returns to the empty state.
181243 #[test]
182182- fn from_digest_round_trip() {
183183- let mut h = XorSha256SetHash::empty();
184184- h.add(b"x");
185185- h.add(b"y");
186186- let bytes = h.digest();
187187- let h2 = XorSha256SetHash::from_digest(&bytes).unwrap();
188188- assert_eq!(h, h2);
244244+ fn lthash_lane_wraparound() {
245245+ let mut h = LtHash::empty();
246246+ for _ in 0..65_536u32 {
247247+ h.add(b"wrap");
248248+ }
249249+ assert_eq!(h.state_bytes(), vec![0u8; STATE_BYTES]);
189250 }
190251191191- #[test]
192192- fn from_digest_bad_length_rejected() {
193193- assert!(XorSha256SetHash::from_digest(&[0u8; 31]).is_err());
194194- assert!(XorSha256SetHash::from_digest(&[0u8; 33]).is_err());
252252+ fn hex_to_vec(s: &str) -> Vec<u8> {
253253+ (0..s.len())
254254+ .step_by(2)
255255+ .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
256256+ .collect()
195257 }
196258197259 #[test]
198260 fn record_element_format() {
261261+ // Spec line 270: `{collection}/{rkey}/{record_cid}` — slash before CID.
199262 let bytes = record_element_bytes("app.bsky.feed.post", "3jui", "bafy...");
200200- assert_eq!(bytes, b"app.bsky.feed.post/3jui:bafy...");
263263+ assert_eq!(bytes, b"app.bsky.feed.post/3jui/bafy...");
201264 }
202265203266 #[test]
204267 fn member_element_format() {
205268 let bytes = member_element_bytes("did:plc:alice");
206269 assert_eq!(bytes, b"did:plc:alice");
207207- }
208208-209209- proptest! {
210210- /// Property: any permutation of N adds yields the same digest.
211211- #[test]
212212- fn permutation_invariance(items in prop::collection::vec(any::<u32>(), 0..16)) {
213213- let bytes_vec: Vec<Vec<u8>> = items.iter().map(|n| n.to_be_bytes().to_vec()).collect();
214214- let mut h1 = XorSha256SetHash::empty();
215215- for b in &bytes_vec {
216216- h1.add(b);
217217- }
218218- // Permute by reversing.
219219- let mut h2 = XorSha256SetHash::empty();
220220- for b in bytes_vec.iter().rev() {
221221- h2.add(b);
222222- }
223223- prop_assert_eq!(h1.digest(), h2.digest());
224224- }
225225-226226- /// Property: add(x) followed by remove(x) is identity for any x and any prior state.
227227- #[test]
228228- fn add_remove_is_identity(prior in prop::collection::vec(any::<u32>(), 0..8), x: u64) {
229229- let prior_bytes: Vec<Vec<u8>> = prior.iter().map(|n| n.to_be_bytes().to_vec()).collect();
230230- let x_bytes = x.to_be_bytes().to_vec();
231231- let mut h1 = XorSha256SetHash::empty();
232232- for b in &prior_bytes {
233233- h1.add(b);
234234- }
235235- let baseline = h1.digest();
236236- h1.add(&x_bytes);
237237- h1.remove(&x_bytes);
238238- prop_assert_eq!(h1.digest(), baseline);
239239- }
240270 }
241271}
+11-48
crates/atproto-space/src/space_members.rs
···22//!
33//! Mirrors `SpaceRepo` in shape but operates over the owner's `space_member`
44//! commitment. Add and Remove are the two op kinds; the SetHash is over DID
55-//! UTF-8 bytes per the Spaces Design Spec.
55+//! UTF-8 bytes per the 0016 Permissioned Data draft.
6677use crate::errors::{SpaceError, SpaceResult};
88use crate::set_hash::{SetHash, member_element_bytes};
99use crate::storage::{
1010- MemberChange, MemberPage, MemberRow, MemberState, OplogEntry, OplogPage, PreparedCommitMembers,
1010+ MemberChange, MemberPage, MemberRow, MemberState, OplogEntry, PreparedCommitMembers,
1111 SpaceMembersStorage,
1212};
1313use crate::types::SpaceUri;
···9494 pub async fn format_commit(&self, ops: &[MemberOp]) -> SpaceResult<PreparedMemberCommit<H>> {
9595 let state = self.storage.current_state(&self.space).await?;
9696 let mut set_hash = match state.set_hash {
9797- Some(bytes) => H::from_digest(&bytes)?,
9797+ Some(bytes) => H::from_state_bytes(&bytes)?,
9898 None => H::empty(),
9999 };
100100···143143 });
144144 }
145145146146- let new_digest = set_hash.digest();
146146+ // Persist the full lattice state (rehydrated via `from_state_bytes`).
147147+ let new_state = set_hash.state_bytes();
147148 Ok(PreparedMemberCommit {
148149 set_hash,
149150 rev: rev.clone(),
150151 storage_commit: PreparedCommitMembers {
151151- new_set_hash: new_digest,
152152+ new_set_hash: new_state,
152153 rev,
153154 member_changes,
154155 oplog_entries,
···162163 .apply_commit(&self.space, prepared.storage_commit)
163164 .await
164165 }
165165-166166- /// Read member-oplog entries since the given rev.
167167- pub async fn read_oplog(&self, since: Option<&str>, limit: u32) -> SpaceResult<OplogPage> {
168168- self.storage.read_oplog(&self.space, since, limit).await
169169- }
170166}
171167172168/// In-memory `SpaceMembersStorage` for testing.
···184180 struct Inner {
185181 members: BTreeMap<(SpaceUri, String), MemberRow>,
186182 states: BTreeMap<SpaceUri, MemberState>,
187187- oplog: BTreeMap<(SpaceUri, String, u32), OplogEntry>,
188183 }
189184190185 impl InMemorySpaceMembersStorage {
···194189 inner: Mutex::new(Inner {
195190 members: BTreeMap::new(),
196191 states: BTreeMap::new(),
197197- oplog: BTreeMap::new(),
198192 }),
199193 }
200194 }
···274268 }
275269 }
276270 }
277277- for entry in commit.oplog_entries {
278278- inner
279279- .oplog
280280- .insert((space.clone(), entry.rev.clone(), entry.idx), entry);
281281- }
271271+ // The 0016 Permissioned Data draft has no member-list oplog read
272272+ // path, so `commit.oplog_entries` is not retained in this test
273273+ // store; only the member set + state commitment are updated.
282274 inner.states.insert(
283275 space.clone(),
284276 MemberState {
···288280 );
289281 Ok(())
290282 }
291291-292292- async fn read_oplog(
293293- &self,
294294- space: &SpaceUri,
295295- since: Option<&str>,
296296- limit: u32,
297297- ) -> SpaceResult<OplogPage> {
298298- let inner = self.inner.lock().unwrap();
299299- let mut ops: Vec<OplogEntry> = inner
300300- .oplog
301301- .iter()
302302- .filter(|((s, rev, _), _)| {
303303- s == space
304304- && match since {
305305- Some(cur) => rev.as_str() > cur,
306306- None => true,
307307- }
308308- })
309309- .map(|(_, e)| e.clone())
310310- .collect();
311311- ops.sort_by(|a, b| (a.rev.as_str(), a.idx).cmp(&(b.rev.as_str(), b.idx)));
312312- ops.truncate(limit as usize);
313313- let state = inner
314314- .states
315315- .get(space)
316316- .cloned()
317317- .unwrap_or_else(MemberState::empty);
318318- Ok(OplogPage { ops, state })
319319- }
320283 }
321284}
322285···324287mod tests {
325288 use super::memory::InMemorySpaceMembersStorage;
326289 use super::*;
327327- use crate::set_hash::XorSha256SetHash;
290290+ use crate::set_hash::LtHash;
328291 use crate::types::{SpaceKey, SpaceType};
329292330293 fn test_space() -> SpaceUri {
···335298 )
336299 }
337300338338- type TestMembers = SpaceMembers<InMemorySpaceMembersStorage, XorSha256SetHash>;
301301+ type TestMembers = SpaceMembers<InMemorySpaceMembersStorage, LtHash>;
339302340303 #[tokio::test]
341304 async fn add_then_remove() {
+72-12
crates/atproto-space/src/space_repo.rs
···1010use crate::errors::{SpaceError, SpaceResult};
1111use crate::set_hash::{SetHash, record_element_bytes};
1212use crate::storage::{
1313- OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow, RepoState,
1414- SpaceRepoStorage,
1313+ OplogCursor, OplogEntry, OplogPage, PreparedCommitRecords, RecordChange, RecordPage, RecordRow,
1414+ RepoState, SpaceRepoStorage,
1515};
1616use crate::types::SpaceUri;
1717use atproto_record::tid::Tid;
···127127 // Load current state to derive the new SetHash incrementally.
128128 let state = self.storage.current_state(&self.space).await?;
129129 let mut set_hash = match state.set_hash {
130130- Some(bytes) => H::from_digest(&bytes)?,
130130+ Some(bytes) => H::from_state_bytes(&bytes)?,
131131 None => H::empty(),
132132 };
133133···258258 }
259259 }
260260261261- let new_digest = set_hash.digest();
261261+ // The persisted value is the full lattice STATE (2048 bytes for
262262+ // LtHash), rehydrated via `from_state_bytes`. The 32-byte commitment
263263+ // (`sha256(state)`) is computed separately by `create_commit`.
264264+ let new_state = set_hash.state_bytes();
262265 Ok(PreparedCommit {
263266 set_hash,
264267 rev: rev.clone(),
265268 storage_commit: PreparedCommitRecords {
266266- new_set_hash: new_digest,
269269+ new_set_hash: new_state,
267270 rev,
268271 record_changes,
269272 oplog_entries,
···278281 .await
279282 }
280283281281- /// Read oplog entries since the given rev (exclusive). `None` = from start.
282282- pub async fn read_oplog(&self, since: Option<&str>, limit: u32) -> SpaceResult<OplogPage> {
284284+ /// Read oplog entries strictly after the given `(rev, idx)` cursor.
285285+ /// `None` = from start.
286286+ pub async fn read_oplog(
287287+ &self,
288288+ since: Option<&OplogCursor>,
289289+ limit: u32,
290290+ ) -> SpaceResult<OplogPage> {
283291 self.storage.read_oplog(&self.space, since, limit).await
284292 }
285293}
···436444 async fn read_oplog(
437445 &self,
438446 space: &SpaceUri,
439439- since: Option<&str>,
447447+ since: Option<&OplogCursor>,
440448 limit: u32,
441449 ) -> SpaceResult<OplogPage> {
442450 let inner = self.inner.lock().unwrap();
443451 let mut ops: Vec<OplogEntry> = inner
444452 .oplog
445453 .iter()
446446- .filter(|((s, rev, _), _)| {
454454+ .filter(|((s, rev, idx), _)| {
447455 s == space
448456 && match since {
449449- Some(cur) => rev.as_str() > cur,
457457+ Some(cur) => (rev.as_str(), *idx) > (cur.rev.as_str(), cur.idx),
450458 None => true,
451459 }
452460 })
···468476mod tests {
469477 use super::memory::InMemorySpaceRepoStorage;
470478 use super::*;
471471- use crate::set_hash::XorSha256SetHash;
479479+ use crate::set_hash::LtHash;
472480 use crate::types::{SpaceKey, SpaceType};
473481474482 fn test_space() -> SpaceUri {
···479487 )
480488 }
481489482482- type TestRepo = SpaceRepo<InMemorySpaceRepoStorage, XorSha256SetHash>;
490490+ type TestRepo = SpaceRepo<InMemorySpaceRepoStorage, LtHash>;
483491484492 #[tokio::test]
485493 async fn create_then_read() {
···617625 assert_eq!(page.ops[0].rev, page.ops[1].rev, "shared rev");
618626 assert_eq!(page.ops[0].idx, 0);
619627 assert_eq!(page.ops[1].idx, 1);
628628+ }
629629+630630+ /// Regression: a single atomic batch (all ops share one rev) larger than the
631631+ /// page `limit` must page fully via the `(rev, idx)` cursor without dropping
632632+ /// the batch's tail. A bare-rev cursor would advance past the whole rev after
633633+ /// page one, permanently skipping ops `limit..N`.
634634+ #[tokio::test]
635635+ async fn batch_larger_than_limit_pages_fully() {
636636+ let space = test_space();
637637+ let repo: TestRepo = SpaceRepo::new(space, InMemorySpaceRepoStorage::new());
638638+639639+ const N: usize = 7;
640640+ const LIMIT: u32 = 3;
641641+642642+ // One commit, N creates -> N oplog entries sharing a single rev.
643643+ let ops: Vec<Op> = (0..N)
644644+ .map(|i| Op {
645645+ action: OpAction::Create,
646646+ collection: "c".to_string(),
647647+ rkey: format!("k{i}"),
648648+ cid: Some(format!("cid{i}")),
649649+ value: Some(vec![]),
650650+ })
651651+ .collect();
652652+ let prepared = repo.format_commit(&ops).await.unwrap();
653653+ repo.apply_commit(prepared).await.unwrap();
654654+655655+ // Page through with the (rev, idx) cursor until caught up.
656656+ let mut seen: Vec<(String, u32)> = Vec::new();
657657+ let mut cursor: Option<OplogCursor> = None;
658658+ loop {
659659+ let page = repo.read_oplog(cursor.as_ref(), LIMIT).await.unwrap();
660660+ for op in &page.ops {
661661+ seen.push((op.rev.clone(), op.idx));
662662+ }
663663+ let caught_up = (page.ops.len() as u32) < LIMIT;
664664+ cursor = page
665665+ .ops
666666+ .last()
667667+ .map(|o| OplogCursor::new(o.rev.clone(), o.idx));
668668+ if caught_up {
669669+ break;
670670+ }
671671+ }
672672+673673+ // All N ops seen exactly once, in order, all within the same rev.
674674+ assert_eq!(seen.len(), N, "all batch ops delivered, none skipped");
675675+ let rev = &seen[0].0;
676676+ for (i, (r, idx)) in seen.iter().enumerate() {
677677+ assert_eq!(r, rev, "all ops share the batch rev");
678678+ assert_eq!(*idx as usize, i, "idx is dense and monotonic");
679679+ }
620680 }
621681}
+97-10
crates/atproto-space/src/storage.rs
···66//! `fjall` Cargo feature). In-memory impls live in [`crate::space_repo::memory`]
77//! and [`crate::space_members::memory`] for testing.
8899-use crate::errors::SpaceResult;
99+use crate::errors::{SpaceError, SpaceResult};
1010use crate::types::SpaceUri;
1111use async_trait::async_trait;
1212use serde::{Deserialize, Serialize};
1313+1414+/// A `(rev, idx)`-granular oplog cursor.
1515+///
1616+/// The oplog `since` cursor must be `(rev, idx)`-granular rather than
1717+/// rev-granular: an atomic batch (entries sharing a `rev`) can exceed a single
1818+/// page's `limit`, so paging by bare `rev` would skip the batch's tail. The
1919+/// cursor names the last op delivered, and the next page resumes strictly after
2020+/// it (`(rev, idx) > (cursor.rev, cursor.idx)`).
2121+#[derive(Debug, Clone, PartialEq, Eq)]
2222+pub struct OplogCursor {
2323+ /// Rev (TID) of the last delivered op.
2424+ pub rev: String,
2525+ /// Index of the last delivered op within its `rev` batch.
2626+ pub idx: u32,
2727+}
2828+2929+impl OplogCursor {
3030+ /// Construct a cursor from a `rev` and `idx`.
3131+ #[must_use]
3232+ pub fn new(rev: String, idx: u32) -> Self {
3333+ Self { rev, idx }
3434+ }
3535+3636+ /// Encode as an opaque wire token `"<rev>__<idx>"`.
3737+ #[must_use]
3838+ pub fn to_token(&self) -> String {
3939+ format!("{}__{}", self.rev, self.idx)
4040+ }
4141+4242+ /// Decode an opaque wire token `"<rev>__<idx>"`.
4343+ ///
4444+ /// Returns [`SpaceError::InvalidCursor`] if the token is malformed.
4545+ pub fn from_token(token: &str) -> SpaceResult<Self> {
4646+ let (rev, idx) = token
4747+ .rsplit_once("__")
4848+ .ok_or_else(|| SpaceError::InvalidCursor {
4949+ token: token.to_string(),
5050+ })?;
5151+ let idx: u32 = idx.parse().map_err(|_| SpaceError::InvalidCursor {
5252+ token: token.to_string(),
5353+ })?;
5454+ if rev.is_empty() {
5555+ return Err(SpaceError::InvalidCursor {
5656+ token: token.to_string(),
5757+ });
5858+ }
5959+ Ok(Self {
6060+ rev: rev.to_string(),
6161+ idx,
6262+ })
6363+ }
6464+}
13651466/// Current per-space record commitment.
1567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
···215267 commit: PreparedCommitRecords,
216268 ) -> SpaceResult<()>;
217269218218- /// Read oplog entries since the given rev (exclusive). `None` = from the start.
270270+ /// Read oplog entries strictly after the given `(rev, idx)` cursor. `None` =
271271+ /// from the start.
272272+ ///
273273+ /// The predicate is `(rev, idx) > (since.rev, since.idx)`, ordered by
274274+ /// `(rev, idx)`. This keeps atomic batches (entries sharing a `rev`)
275275+ /// coherent across paging even when a batch exceeds `limit`.
219276 ///
220277 /// If `since` predates the retained range, returns [`SpaceError::OplogGap`].
221278 async fn read_oplog(
222279 &self,
223280 space: &SpaceUri,
224224- since: Option<&str>,
281281+ since: Option<&OplogCursor>,
225282 limit: u32,
226283 ) -> SpaceResult<OplogPage>;
227284}
···249306 space: &SpaceUri,
250307 commit: PreparedCommitMembers,
251308 ) -> SpaceResult<()>;
309309+}
310310+311311+#[cfg(test)]
312312+mod tests {
313313+ use super::*;
252314253253- /// Read member-oplog entries since the given rev.
254254- async fn read_oplog(
255255- &self,
256256- space: &SpaceUri,
257257- since: Option<&str>,
258258- limit: u32,
259259- ) -> SpaceResult<OplogPage>;
315315+ #[test]
316316+ fn oplog_cursor_token_round_trip() {
317317+ let cur = OplogCursor::new("3kabc".to_string(), 5);
318318+ let token = cur.to_token();
319319+ assert_eq!(token, "3kabc__5");
320320+ assert_eq!(OplogCursor::from_token(&token).unwrap(), cur);
321321+ }
322322+323323+ #[test]
324324+ fn oplog_cursor_token_round_trip_idx_zero() {
325325+ let cur = OplogCursor::new("3kabc".to_string(), 0);
326326+ assert_eq!(OplogCursor::from_token(&cur.to_token()).unwrap(), cur);
327327+ }
328328+329329+ #[test]
330330+ fn oplog_cursor_rejects_malformed() {
331331+ // Missing separator.
332332+ assert!(matches!(
333333+ OplogCursor::from_token("3kabc"),
334334+ Err(SpaceError::InvalidCursor { .. })
335335+ ));
336336+ // Non-numeric idx.
337337+ assert!(matches!(
338338+ OplogCursor::from_token("3kabc__x"),
339339+ Err(SpaceError::InvalidCursor { .. })
340340+ ));
341341+ // Empty rev.
342342+ assert!(matches!(
343343+ OplogCursor::from_token("__3"),
344344+ Err(SpaceError::InvalidCursor { .. })
345345+ ));
346346+ }
260347}
+190-19
crates/atproto-space/src/types.rs
···11//! Core types for permissioned-data spaces.
22//!
33-//! `SpaceUri`, `SpaceType`, `SpaceKey` are wrapper newtypes around strings
44-//! that validate at construction time. They are abstracted so the URI scheme
55-//! (`ats://` per the Spaces Design Spec, provisional) can change without
33+//! `SpaceUri`, `SpaceType`, `SpaceKey`, `RecordUri` are wrapper newtypes around
44+//! strings that validate at construction time. They are abstracted so the URI
55+//! scheme (`ats://` per the 0016 Permissioned Data draft) can change without
66//! callers needing to update.
7788use crate::errors::{SpaceError, SpaceResult};
99use serde::{Deserialize, Serialize};
1010use std::fmt;
11111212-/// URI scheme for permissioned data spaces (provisional per Spaces Design Spec).
1212+/// URI scheme for permissioned data spaces (per the 0016 Permissioned Data draft).
1313pub const ATS_SCHEME: &str = "ats://";
14141515/// A space type — an NSID describing the space modality (e.g., `app.bsky.group`).
···4444 }
4545}
46464747-/// A space key — an arbitrary identifier scoped under (owner, type).
4747+/// Maximum `skey` length in UTF-8 bytes (per `com.atproto.simplespace.createSpace`).
4848+pub const SPACE_KEY_MAX_BYTES: usize = 512;
4949+5050+/// A space key — an arbitrary identifier scoped under (authority, type).
4851///
4949-/// Cannot contain `/` (would corrupt the URI segment structure) and cannot be empty.
5252+/// Per the 0016 Permissioned Data draft (line 134), an `skey` has a maximum
5353+/// length of 512 bytes and the same syntax requirements as an `rkey`: the
5454+/// characters are restricted to `[A-Za-z0-9._:~-]`, and the reserved values
5555+/// `.` and `..` are disallowed.
5056#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
5157#[serde(transparent)]
5258pub struct SpaceKey(String);
53595460impl SpaceKey {
5555- /// Construct a new `SpaceKey`. Validates non-empty and slash-free.
6161+ /// Construct a new `SpaceKey`. Validates that the value is non-empty, within
6262+ /// the 512-byte length cap, restricted to the record-key character set
6363+ /// `[A-Za-z0-9._:~-]`, and is not a reserved value (`.` or `..`).
5664 ///
5765 /// # Errors
5866 ///
5967 /// Returns [`SpaceError::InvalidSpaceKey`] if validation fails.
6068 pub fn new(value: impl Into<String>) -> SpaceResult<Self> {
6169 let value = value.into();
6262- if value.is_empty() || value.contains('/') {
7070+ if value.len() > SPACE_KEY_MAX_BYTES || !is_valid_record_key(&value) {
6371 return Err(SpaceError::InvalidSpaceKey { value });
6472 }
6573 Ok(Self(value))
···7886 }
7987}
80888181-/// A space URI: `ats://<owner-did>/<space-type>/<space-key>`.
8989+/// A space URI: `ats://<authority-did>/<space-type>/<space-key>`.
8290///
8391/// The components are validated at construction. The full URI is round-trip
8492/// stable through `Display` and `parse`.
8593#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
8694#[serde(into = "String", try_from = "String")]
8795pub struct SpaceUri {
8888- /// DID of the space owner.
8989- pub owner_did: String,
9696+ /// Space authority DID.
9797+ pub space_did: String,
9098 /// Space type (NSID).
9199 pub space_type: SpaceType,
92100 /// Space key.
···9510396104impl SpaceUri {
97105 /// Construct a new `SpaceUri` from validated components.
9898- pub fn new(owner_did: String, space_type: SpaceType, space_key: SpaceKey) -> Self {
106106+ pub fn new(space_did: String, space_type: SpaceType, space_key: SpaceKey) -> Self {
99107 Self {
100100- owner_did,
108108+ space_did,
101109 space_type,
102110 space_key,
103111 }
···114122 .strip_prefix(ATS_SCHEME)
115123 .ok_or_else(|| SpaceError::InvalidSpaceUri { uri: s.to_string() })?;
116124117117- // Split into 3 parts: owner_did, space_type, space_key.
118118- // owner_did itself may contain `:` but no `/`, so we split on `/`.
125125+ // Split into 3 parts: space_did, space_type, space_key.
126126+ // space_did itself may contain `:` but no `/`, so we split on `/`.
119127 let mut parts = stripped.splitn(3, '/');
120120- let owner_did = parts
128128+ let space_did = parts
121129 .next()
122130 .filter(|s| !s.is_empty())
123131 .ok_or_else(|| SpaceError::InvalidSpaceUri { uri: s.to_string() })?
···131139 .filter(|s| !s.is_empty())
132140 .ok_or_else(|| SpaceError::InvalidSpaceUri { uri: s.to_string() })?;
133141134134- if !owner_did.starts_with("did:") {
142142+ if !space_did.starts_with("did:") {
135143 return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() });
136144 }
137145138146 Ok(Self {
139139- owner_did,
147147+ space_did,
140148 space_type: SpaceType::new(space_type_str)?,
141149 space_key: SpaceKey::new(space_key_str)?,
142150 })
···148156 write!(
149157 f,
150158 "{}{}/{}/{}",
151151- ATS_SCHEME, self.owner_did, self.space_type, self.space_key
159159+ ATS_SCHEME, self.space_did, self.space_type, self.space_key
152160 )
153161 }
154162}
···175183 }
176184}
177185186186+/// A permissioned **record** URI of six components:
187187+/// `ats://<spaceDid>/<spaceType>/<skey>/<authorDid>/<collection>/<rkey>`.
188188+///
189189+/// The first three components are the [`SpaceUri`]; the remaining three
190190+/// identify a record authored by `author_did` within that space. All six
191191+/// segments are required to identify a permissioned record (the space does not
192192+/// colocate records — each author's records live in their own permissioned
193193+/// repo, so the author DID is part of the address).
194194+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
195195+pub struct RecordUri {
196196+ /// The space the record belongs to (first three URI segments).
197197+ pub space: SpaceUri,
198198+ /// DID of the record's author.
199199+ pub author_did: String,
200200+ /// Record collection (NSID).
201201+ pub collection: String,
202202+ /// Record key.
203203+ pub rkey: String,
204204+}
205205+206206+impl RecordUri {
207207+ /// Construct a record URI from its parts.
208208+ #[must_use]
209209+ pub fn new(space: SpaceUri, author_did: String, collection: String, rkey: String) -> Self {
210210+ Self {
211211+ space,
212212+ author_did,
213213+ collection,
214214+ rkey,
215215+ }
216216+ }
217217+218218+ /// Parse from the canonical six-segment wire form.
219219+ ///
220220+ /// # Errors
221221+ ///
222222+ /// Returns [`SpaceError::InvalidSpaceUri`] if the URI does not have exactly
223223+ /// six non-empty segments or a component fails validation.
224224+ pub fn parse(s: &str) -> SpaceResult<Self> {
225225+ let stripped = s
226226+ .strip_prefix(ATS_SCHEME)
227227+ .ok_or_else(|| SpaceError::InvalidSpaceUri { uri: s.to_string() })?;
228228+ // A record URI has EXACTLY six segments; a 7th `/` (or any extra
229229+ // segment) is rejected rather than absorbed into the rkey.
230230+ let parts: Vec<&str> = stripped.split('/').collect();
231231+ if parts.len() != 6 || parts.iter().any(|p| p.is_empty()) {
232232+ return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() });
233233+ }
234234+ let space = SpaceUri::new(
235235+ {
236236+ if !parts[0].starts_with("did:") {
237237+ return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() });
238238+ }
239239+ parts[0].to_string()
240240+ },
241241+ SpaceType::new(parts[1])?,
242242+ SpaceKey::new(parts[2])?,
243243+ );
244244+ if !parts[3].starts_with("did:") {
245245+ return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() });
246246+ }
247247+ // The collection segment is typed as an NSID by the spec (Addressing,
248248+ // line 73).
249249+ if !is_valid_nsid(parts[4]) {
250250+ return Err(SpaceError::InvalidSpaceUri { uri: s.to_string() });
251251+ }
252252+ Ok(Self {
253253+ space,
254254+ author_did: parts[3].to_string(),
255255+ collection: parts[4].to_string(),
256256+ rkey: parts[5].to_string(),
257257+ })
258258+ }
259259+}
260260+261261+impl fmt::Display for RecordUri {
262262+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263263+ write!(
264264+ f,
265265+ "{}/{}/{}/{}",
266266+ self.space, self.author_did, self.collection, self.rkey
267267+ )
268268+ }
269269+}
270270+271271+/// Record-key syntax validation per AT Protocol (the `rkey` baseline the 0016
272272+/// draft references for `skey`, line 134): 1-512 characters drawn from
273273+/// `[A-Za-z0-9._:~-]`, and not the reserved values `.` or `..`.
274274+fn is_valid_record_key(s: &str) -> bool {
275275+ if s.is_empty() || s.len() > SPACE_KEY_MAX_BYTES {
276276+ return false;
277277+ }
278278+ if s == "." || s == ".." {
279279+ return false;
280280+ }
281281+ s.chars()
282282+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '~' | ':'))
283283+}
284284+178285/// Minimal NSID validation per AT Protocol — at least three dot-separated segments,
179286/// each non-empty alphanumeric/hyphen-only.
180287fn is_valid_nsid(s: &str) -> bool {
···218325 }
219326220327 #[test]
328328+ fn space_key_valid_rkey_charset() {
329329+ // Full record-key charset is accepted.
330330+ assert!(SpaceKey::new("key-with-hyphens").is_ok());
331331+ assert!(SpaceKey::new("key_with_underscores").is_ok());
332332+ assert!(SpaceKey::new("key~with~tildes").is_ok());
333333+ assert!(SpaceKey::new("key:with:colons").is_ok());
334334+ assert!(SpaceKey::new("example.com").is_ok());
335335+ assert!(SpaceKey::new("self").is_ok());
336336+ }
337337+338338+ #[test]
221339 fn space_key_invalid() {
222340 assert!(SpaceKey::new("").is_err());
223341 assert!(SpaceKey::new("with/slash").is_err());
342342+ // Reserved record-key values.
343343+ assert!(SpaceKey::new(".").is_err());
344344+ assert!(SpaceKey::new("..").is_err());
345345+ // Out-of-charset values rejected (rkey syntax, spec line 134).
346346+ assert!(SpaceKey::new("has space").is_err());
347347+ assert!(SpaceKey::new("a@b").is_err());
348348+ assert!(SpaceKey::new("emoji\u{1f600}").is_err());
349349+ }
350350+351351+ #[test]
352352+ fn space_key_length_cap() {
353353+ assert!(SpaceKey::new("a".repeat(SPACE_KEY_MAX_BYTES)).is_ok());
354354+ assert!(SpaceKey::new("a".repeat(SPACE_KEY_MAX_BYTES + 1)).is_err());
355355+ }
356356+357357+ #[test]
358358+ fn record_uri_round_trip() {
359359+ let s = "ats://did:plc:auth/app.bsky.group/default/did:plc:alice/app.bsky.feed.post/3jui";
360360+ let parsed = RecordUri::parse(s).unwrap();
361361+ assert_eq!(parsed.author_did, "did:plc:alice");
362362+ assert_eq!(parsed.collection, "app.bsky.feed.post");
363363+ assert_eq!(parsed.rkey, "3jui");
364364+ assert_eq!(parsed.space.space_did, "did:plc:auth");
365365+ assert_eq!(parsed.to_string(), s);
366366+ }
367367+368368+ #[test]
369369+ fn record_uri_parse_failures() {
370370+ // 3-segment (space only) is not a record URI.
371371+ assert!(RecordUri::parse("ats://did:plc:auth/app.bsky.group/default").is_err());
372372+ // author segment must be a DID.
373373+ assert!(
374374+ RecordUri::parse(
375375+ "ats://did:plc:auth/app.bsky.group/default/alice/app.bsky.feed.post/k"
376376+ )
377377+ .is_err()
378378+ );
379379+ // wrong scheme.
380380+ assert!(RecordUri::parse("https://x/y/z/a/b/c").is_err());
381381+ // collection segment must be a valid NSID (spec line 73).
382382+ assert!(
383383+ RecordUri::parse(
384384+ "ats://did:plc:auth/app.bsky.group/default/did:plc:alice/notanNSID/rk"
385385+ )
386386+ .is_err()
387387+ );
388388+ // a 7th segment is rejected rather than absorbed into the rkey.
389389+ assert!(
390390+ RecordUri::parse(
391391+ "ats://did:plc:auth/app.bsky.group/default/did:plc:alice/app.bsky.feed.post/rk/extra"
392392+ )
393393+ .is_err()
394394+ );
224395 }
225396226397 #[test]
···11[package]
22name = "atproto-xrpcs-helloworld"
33-version = "0.15.0-alpha.1"
33+version = "0.15.0-alpha.2"
44description = "Complete example implementation of an AT Protocol XRPC service with DID web functionality and JWT authentication"
55homepage = "https://tangled.org/ngerakines.me/atproto-crates"
66documentation = "https://docs.rs/atproto-xrpcs-helloworld"
+1-1
crates/atproto-xrpcs/Cargo.toml
···11[package]
22name = "atproto-xrpcs"
33-version = "0.15.0-alpha.1"
33+version = "0.15.0-alpha.2"
44description = "Core building blocks for implementing AT Protocol XRPC services with JWT authorization"
55homepage = "https://tangled.org/ngerakines.me/atproto-crates"
66documentation = "https://docs.rs/atproto-xrpcs"