diff --git a/src/lua/context.rs b/src/lua/context.rs index 6492500..1cab0c7 100644 --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -271,7 +271,7 @@ mod tests { let lua = create_sandbox().unwrap(); let params = HashMap::new(); let space = SpaceContext { - space: "ats://did:plc:owner/com.example.forum/main".into(), + space: "at://did:plc:owner/space/com.example.forum/main".into(), space_id: "space-123".into(), did: "did:plc:owner".into(), authority_did: "did:plc:owner".into(), @@ -292,7 +292,7 @@ mod tests { let space_table: mlua::Table = globals.get("space").unwrap(); assert_eq!( space_table.get::("space").unwrap(), - "ats://did:plc:owner/com.example.forum/main" + "at://did:plc:owner/space/com.example.forum/main" ); assert_eq!(space_table.get::("space_id").unwrap(), "space-123"); assert_eq!(space_table.get::("did").unwrap(), "did:plc:owner"); diff --git a/src/spaces/auth.rs b/src/spaces/auth.rs index cfeaf7d..7b4abdc 100644 --- a/src/spaces/auth.rs +++ b/src/spaces/auth.rs @@ -42,7 +42,10 @@ pub async fn issue_credential( let claims = SpaceCredentialClaims { iss: space.authority_did.clone(), - sub: format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey), + sub: format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ), iat: now, exp, jti: make_jti(), @@ -80,7 +83,10 @@ async fn check_mint_policy( "space mint_policy is managing-app but managing_app_did is not set".into(), ) })?; - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); let granted = check_user_access_with_managing_app( http, managing_app, diff --git a/src/spaces/car.rs b/src/spaces/car.rs new file mode 100644 index 0000000..9d7404e --- /dev/null +++ b/src/spaces/car.rs @@ -0,0 +1,246 @@ +use cid::Cid; +use sha2::{Digest, Sha256}; + +use crate::error::AppError; +use crate::spaces::commit::SignedCommit; +use crate::spaces::types::SpaceRecord; + +const SHA2_256: u64 = 0x12; +const DAG_CBOR: u64 = 0x71; +const RAW: u64 = 0x55; + +fn unsigned_varint(mut value: u64) -> Vec { + let mut buf = Vec::new(); + loop { + let mut byte = (value & 0x7F) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + buf.push(byte); + if value == 0 { + break; + } + } + buf +} + +fn make_cid(codec: u64, block: &[u8]) -> Cid { + let digest = Sha256::digest(block); + let mut mh_bytes = Vec::with_capacity(34); + mh_bytes.push(SHA2_256 as u8); + mh_bytes.push(32u8); + mh_bytes.extend_from_slice(&digest); + let mh = cid::multihash::Multihash::<64>::from_bytes(&mh_bytes).expect("valid multihash"); + Cid::new_v1(codec, mh) +} + +// DAG-CBOR CID link: CBOR tag 42 wrapping bytes prefixed with 0x00 (multibase identity) +fn cid_link(cid: &Cid) -> ciborium::Value { + let mut bytes = vec![0x00u8]; // multibase identity prefix + bytes.extend_from_slice(&cid.to_bytes()); + ciborium::Value::Tag(42, Box::new(ciborium::Value::Bytes(bytes))) +} + +fn encode_cbor(value: &ciborium::Value) -> Result, AppError> { + let mut buf = Vec::new(); + ciborium::into_writer(value, &mut buf) + .map_err(|e| AppError::Internal(format!("CBOR encoding failed: {e}")))?; + Ok(buf) +} + +fn write_car_block(out: &mut Vec, cid: &Cid, block: &[u8]) { + let cid_bytes = cid.to_bytes(); + let section_len = cid_bytes.len() + block.len(); + out.extend(unsigned_varint(section_len as u64)); + out.extend_from_slice(&cid_bytes); + out.extend_from_slice(block); +} + +pub fn serialize_repo(commit: &SignedCommit, records: &[SpaceRecord]) -> Result, AppError> { + // Build record blocks sorted by collection/rkey + let mut indexed: Vec<(&SpaceRecord, Vec, Cid)> = records + .iter() + .map(|r| { + let block = serde_json::to_vec(&r.record).unwrap_or_default(); + let cid = make_cid(RAW, &block); + (r, block, cid) + }) + .collect(); + indexed.sort_by(|a, b| { + let ka = format!("{}/{}", a.0.collection, a.0.rkey); + let kb = format!("{}/{}", b.0.collection, b.0.rkey); + ka.cmp(&kb) + }); + + // Build index: sorted map of "collection/rkey" -> CID link + let index_pairs: Vec<(ciborium::Value, ciborium::Value)> = indexed + .iter() + .map(|(r, _, cid)| { + let key = format!("{}/{}", r.collection, r.rkey); + (ciborium::Value::Text(key), cid_link(cid)) + }) + .collect(); + let index_cbor = ciborium::Value::Map(index_pairs); + let index_block = encode_cbor(&index_cbor)?; + let index_cid = make_cid(DAG_CBOR, &index_block); + + // Build signed commit block + let commit_cbor = ciborium::Value::Map(vec![ + ( + ciborium::Value::Text("ver".into()), + ciborium::Value::Integer(commit.ver.into()), + ), + ( + ciborium::Value::Text("hash".into()), + ciborium::Value::Bytes(commit.hash.to_vec()), + ), + ( + ciborium::Value::Text("ikm".into()), + ciborium::Value::Bytes(commit.ikm.to_vec()), + ), + ( + ciborium::Value::Text("sig".into()), + ciborium::Value::Bytes(commit.sig.clone()), + ), + ( + ciborium::Value::Text("mac".into()), + ciborium::Value::Bytes(commit.mac.to_vec()), + ), + ( + ciborium::Value::Text("rev".into()), + ciborium::Value::Text(commit.rev.clone()), + ), + ]); + let commit_block = encode_cbor(&commit_cbor)?; + let commit_cid = make_cid(DAG_CBOR, &commit_block); + + // Build CAR v1 header + let header_cbor = ciborium::Value::Map(vec![ + ( + ciborium::Value::Text("version".into()), + ciborium::Value::Integer(1.into()), + ), + ( + ciborium::Value::Text("roots".into()), + ciborium::Value::Array(vec![cid_link(&commit_cid), cid_link(&index_cid)]), + ), + ]); + let header_block = encode_cbor(&header_cbor)?; + + // Assemble CAR + let mut car = Vec::new(); + + // Header (varint-length-prefixed) + car.extend(unsigned_varint(header_block.len() as u64)); + car.extend_from_slice(&header_block); + + // Commit block + write_car_block(&mut car, &commit_cid, &commit_block); + + // Index block + write_car_block(&mut car, &index_cid, &index_block); + + // Record blocks in sorted order + for (_, block, cid) in &indexed { + write_car_block(&mut car, cid, block); + } + + Ok(car) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_commit() -> SignedCommit { + SignedCommit { + ver: 1, + hash: [0u8; 32], + ikm: [0u8; 32], + sig: vec![0u8; 64], + mac: [0u8; 32], + rev: "3k2rev1".to_string(), + } + } + + #[test] + fn serialize_empty_repo() { + let commit = test_commit(); + let car = serialize_repo(&commit, &[]).unwrap(); + assert!(!car.is_empty()); + // CAR starts with a varint-prefixed header — must be more than 10 bytes + assert!(car.len() > 10); + } + + #[test] + fn serialize_repo_with_records() { + let commit = SignedCommit { + ver: 1, + hash: [0xAA; 32], + ikm: [0xBB; 32], + sig: vec![0xCC; 64], + mac: [0xDD; 32], + rev: "3k2rev1".to_string(), + }; + + let records = + vec![ + SpaceRecord { + uri: "at://did:plc:abc/space/com.example.forum/main/did:plc:user/com.example.post/1" + .into(), + space_id: "space1".into(), + author_did: "did:plc:user".into(), + collection: "com.example.post".into(), + rkey: "1".into(), + record: serde_json::json!({"text": "hello"}), + cid: "bafyreiabc".into(), + indexed_at: "2026-01-01T00:00:00Z".into(), + }, + SpaceRecord { + uri: "at://did:plc:abc/space/com.example.forum/main/did:plc:user/com.example.post/2" + .into(), + space_id: "space1".into(), + author_did: "did:plc:user".into(), + collection: "com.example.post".into(), + rkey: "2".into(), + record: serde_json::json!({"text": "world"}), + cid: "bafyreixyz".into(), + indexed_at: "2026-01-01T00:00:01Z".into(), + }, + ]; + + let car = serialize_repo(&commit, &records).unwrap(); + assert!(car.len() > 100); + } + + #[test] + fn records_sorted_in_output() { + let commit = test_commit(); + let records = vec![ + SpaceRecord { + uri: "u1".into(), + space_id: "s".into(), + author_did: "d".into(), + collection: "com.example.b".into(), + rkey: "1".into(), + record: serde_json::json!({"n": 2}), + cid: "c2".into(), + indexed_at: "2026-01-01".into(), + }, + SpaceRecord { + uri: "u2".into(), + space_id: "s".into(), + author_did: "d".into(), + collection: "com.example.a".into(), + rkey: "1".into(), + record: serde_json::json!({"n": 1}), + cid: "c1".into(), + indexed_at: "2026-01-01".into(), + }, + ]; + // Should not panic, and produces valid output + let car = serialize_repo(&commit, &records).unwrap(); + assert!(car.len() > 100); + } +} diff --git a/src/spaces/commit.rs b/src/spaces/commit.rs index 1248d53..92cce61 100644 --- a/src/spaces/commit.rs +++ b/src/spaces/commit.rs @@ -6,6 +6,7 @@ use sha2::Sha256; use crate::error::AppError; pub struct SignedCommit { + pub ver: u32, pub hash: [u8; 32], pub ikm: [u8; 32], pub sig: Vec, @@ -13,13 +14,15 @@ pub struct SignedCommit { pub rev: String, } -pub fn build_context(space_uri: &str, rev: &str, ikm: &[u8; 32]) -> Vec { +pub fn build_context(space_uri: &str, author_did: &str, rev: &str, ikm: &[u8; 32]) -> Vec { let tag = b"atproto-space-v1"; let space_bytes = space_uri.as_bytes(); + let author_bytes = author_did.as_bytes(); let rev_bytes = rev.as_bytes(); - let mut ctx = - Vec::with_capacity(tag.len() + 2 + space_bytes.len() + 2 + rev_bytes.len() + 2 + 32); + let mut ctx = Vec::with_capacity( + tag.len() + 2 + space_bytes.len() + 2 + author_bytes.len() + 2 + rev_bytes.len() + 2 + 32, + ); ctx.extend_from_slice(tag); @@ -27,6 +30,9 @@ pub fn build_context(space_uri: &str, rev: &str, ikm: &[u8; 32]) -> Vec { ctx.extend_from_slice(&(space_bytes.len() as u16).to_be_bytes()); ctx.extend_from_slice(space_bytes); + ctx.extend_from_slice(&(author_bytes.len() as u16).to_be_bytes()); + ctx.extend_from_slice(author_bytes); + ctx.extend_from_slice(&(rev_bytes.len() as u16).to_be_bytes()); ctx.extend_from_slice(rev_bytes); @@ -39,15 +45,16 @@ pub fn build_context(space_uri: &str, rev: &str, ikm: &[u8; 32]) -> Vec { pub fn sign_commit( hash: &[u8; 32], space_uri: &str, + author_did: &str, rev: &str, signing_key: &SigningKey, ) -> Result { let mut ikm = [0u8; 32]; rand::RngCore::fill_bytes(&mut rand::rng(), &mut ikm); - let ctx = build_context(space_uri, rev, &ikm); + let ctx = build_context(space_uri, author_did, rev, &ikm); - // sig covers space + rev + ikm, NOT the hash — prevents rebroadcast proof + // sig covers space + author + rev + ikm, NOT the hash — prevents rebroadcast proof let sig: Signature = signing_key.sign(&ctx); // mac = HMAC-SHA256(HKDF-SHA256(ikm, ctx), hash) @@ -62,6 +69,7 @@ pub fn sign_commit( let mac: [u8; 32] = mac_hasher.finalize().into_bytes().into(); Ok(SignedCommit { + ver: 1, hash: *hash, ikm, sig: sig.to_bytes().to_vec(), @@ -73,9 +81,10 @@ pub fn sign_commit( pub fn verify_commit( commit: &SignedCommit, space_uri: &str, + author_did: &str, verifying_key: &VerifyingKey, ) -> Result<(), AppError> { - let ctx = build_context(space_uri, &commit.rev, &commit.ikm); + let ctx = build_context(space_uri, author_did, &commit.rev, &commit.ikm); let sig = Signature::from_bytes(commit.sig.as_slice().into()) .map_err(|_| AppError::Auth("invalid commit signature format".into()))?; @@ -114,7 +123,8 @@ mod tests { #[test] fn context_string_format() { let ctx = build_context( - "ats://did:plc:abc/com.example.forum/main", + "at://did:plc:abc/space/com.example.forum/main", + "did:plc:testuser", "3k2abc", &[0xAA; 32], ); @@ -124,32 +134,84 @@ mod tests { #[test] fn context_includes_all_fields() { - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; + let author = "did:plc:testuser"; let rev = "3k2abc"; let ikm = [0xBB; 32]; - let ctx = build_context(space, rev, &ikm); + let ctx = build_context(space, author, rev, &ikm); - // Context must contain the space URI, rev, and ikm + // Context must contain the space URI, author, rev, and ikm assert!(ctx.windows(space.len()).any(|w| w == space.as_bytes())); + assert!(ctx.windows(author.len()).any(|w| w == author.as_bytes())); assert!(ctx.windows(rev.len()).any(|w| w == rev.as_bytes())); assert!(ctx.windows(32).any(|w| w == ikm)); } + #[test] + fn context_includes_author_did() { + let space = "at://did:plc:abc/space/com.example.forum/main"; + let author = "did:plc:user1"; + let rev = "3k2abc"; + let ikm = [0xBB; 32]; + let ctx = build_context(space, author, rev, &ikm); + + assert!(ctx.starts_with(b"atproto-space-v1")); + assert!(ctx.windows(author.len()).any(|w| w == author.as_bytes())); + + // Author must appear after space and before rev in the byte stream + let space_pos = ctx + .windows(space.len()) + .position(|w| w == space.as_bytes()) + .unwrap(); + let author_pos = ctx + .windows(author.len()) + .position(|w| w == author.as_bytes()) + .unwrap(); + let rev_pos = ctx + .windows(rev.len()) + .position(|w| w == rev.as_bytes()) + .unwrap(); + assert!(space_pos < author_pos); + assert!(author_pos < rev_pos); + } + #[test] fn sign_and_verify_roundtrip() { let sk = test_signing_key(); let vk = *sk.verifying_key(); let hash = [0xCC; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let commit = sign_commit(&hash, space, "3k2rev1", &sk).unwrap(); + let commit = sign_commit(&hash, space, "did:plc:testuser", "3k2rev1", &sk).unwrap(); assert_eq!(commit.hash, hash); assert_eq!(commit.rev, "3k2rev1"); assert_eq!(commit.mac.len(), 32); assert!(!commit.sig.is_empty()); + assert_eq!(commit.ver, 1); - assert!(verify_commit(&commit, space, &vk).is_ok()); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_ok()); + } + + #[test] + fn commit_has_version() { + let sk = test_signing_key(); + let hash = [0xCC; 32]; + let space = "at://did:plc:abc/space/com.example.forum/main"; + let commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); + assert_eq!(commit.ver, 1); + } + + #[test] + fn verify_rejects_wrong_author() { + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let hash = [0xAA; 32]; + let space = "at://did:plc:abc/space/com.example.forum/main"; + + let commit = sign_commit(&hash, space, "did:plc:user1", "rev1", &sk).unwrap(); + assert!(verify_commit(&commit, space, "did:plc:user1", &vk).is_ok()); + assert!(verify_commit(&commit, space, "did:plc:user2", &vk).is_err()); } #[test] @@ -161,10 +223,10 @@ mod tests { let vk2 = *sk2.verifying_key(); let hash = [0xDD; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let commit = sign_commit(&hash, space, "rev1", &sk1).unwrap(); - assert!(verify_commit(&commit, space, &vk2).is_err()); + let commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk1).unwrap(); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk2).is_err()); } #[test] @@ -172,11 +234,11 @@ mod tests { let sk = test_signing_key(); let vk = *sk.verifying_key(); let hash = [0xEE; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let mut commit = sign_commit(&hash, space, "rev1", &sk).unwrap(); + let mut commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); commit.hash[0] ^= 0xFF; // tamper - assert!(verify_commit(&commit, space, &vk).is_err()); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_err()); } #[test] @@ -187,29 +249,38 @@ mod tests { let commit = sign_commit( &hash, - "ats://did:plc:abc/com.example.forum/main", + "at://did:plc:abc/space/com.example.forum/main", + "did:plc:user", "rev1", &sk, ) .unwrap(); - assert!(verify_commit(&commit, "ats://did:plc:xyz/com.example.forum/other", &vk).is_err()); + assert!( + verify_commit( + &commit, + "at://did:plc:xyz/space/com.example.forum/other", + "did:plc:user", + &vk + ) + .is_err() + ); } #[test] fn different_ikm_per_commit() { let sk = test_signing_key(); let hash = [0xAA; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let c1 = sign_commit(&hash, space, "rev1", &sk).unwrap(); - let c2 = sign_commit(&hash, space, "rev1", &sk).unwrap(); + let c1 = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); + let c2 = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); // Each call generates fresh ikm assert_ne!(c1.ikm, c2.ikm); // But both verify let vk = *sk.verifying_key(); - assert!(verify_commit(&c1, space, &vk).is_ok()); - assert!(verify_commit(&c2, space, &vk).is_ok()); + assert!(verify_commit(&c1, space, "did:plc:testuser", &vk).is_ok()); + assert!(verify_commit(&c2, space, "did:plc:testuser", &vk).is_ok()); } #[test] @@ -217,11 +288,11 @@ mod tests { let sk = test_signing_key(); let vk = *sk.verifying_key(); let hash = [0xCC; 32]; - let space = "ats://did:plc:abc/com.example.forum/main"; + let space = "at://did:plc:abc/space/com.example.forum/main"; - let mut commit = sign_commit(&hash, space, "rev1", &sk).unwrap(); - assert!(verify_commit(&commit, space, &vk).is_ok()); + let mut commit = sign_commit(&hash, space, "did:plc:testuser", "rev1", &sk).unwrap(); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_ok()); commit.mac[0] ^= 0xFF; - assert!(verify_commit(&commit, space, &vk).is_err()); + assert!(verify_commit(&commit, space, "did:plc:testuser", &vk).is_err()); } } diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs index 9252c55..beb02f2 100644 --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -50,7 +50,7 @@ pub fn peek_credential_sub(token: &str) -> Option { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DelegationTokenClaims { pub iss: String, // User DID - pub sub: String, // Space URI (ats://...) + pub sub: String, // Space URI (at://...) pub aud: String, // Space host (did#atproto_space_host) pub iat: u64, pub exp: u64, @@ -156,7 +156,7 @@ pub fn verify_delegation_token( #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceCredentialClaims { pub iss: String, // Space authority DID - pub sub: String, // Space URI (ats://...) + pub sub: String, // Space URI (at://...) pub iat: u64, pub exp: u64, pub jti: String, // Random nonce @@ -362,7 +362,7 @@ mod tests { .as_secs(); SpaceCredentialClaims { iss: "did:plc:spaceowner".into(), - sub: "ats://did:plc:spaceowner/com.example.forum/main".into(), + sub: "at://did:plc:spaceowner/space/com.example.forum/main".into(), iat: now, exp: now + DEFAULT_CREDENTIAL_TTL_SECS, jti: make_jti(), @@ -420,7 +420,7 @@ mod tests { .as_secs(); let claims = SpaceCredentialClaims { iss: "did:plc:owner".into(), - sub: "ats://did:plc:owner/com.example.test/main".into(), + sub: "at://did:plc:owner/space/com.example.test/main".into(), iat: now - 7200, exp: now - 3600, jti: make_jti(), @@ -451,7 +451,7 @@ mod tests { .as_secs(); DelegationTokenClaims { iss: "did:plc:member".into(), - sub: "ats://did:plc:space/com.example.forum/main".into(), + sub: "at://did:plc:space/space/com.example.forum/main".into(), aud: "did:plc:space#atproto_space_host".into(), iat: now, exp: now + DELEGATION_TOKEN_TTL_SECS, @@ -509,7 +509,7 @@ mod tests { .as_secs(); let claims = DelegationTokenClaims { iss: "did:plc:member".into(), - sub: "ats://did:plc:space/com.example.forum/main".into(), + sub: "at://did:plc:space/space/com.example.forum/main".into(), aud: "did:plc:space#atproto_space_host".into(), iat: now - 120, exp: now - 60, @@ -575,7 +575,7 @@ mod tests { let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); assert_eq!( peek_credential_sub(&token).as_deref(), - Some("ats://did:plc:spaceowner/com.example.forum/main") + Some("at://did:plc:spaceowner/space/com.example.forum/main") ); } diff --git a/src/spaces/db.rs b/src/spaces/db.rs index baf4ff4..adb4473 100644 --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -122,12 +122,12 @@ pub async fn list_spaces_for_user( let sql = if decoded_cursor.is_some() { adapt_sql( - "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? AND (sm.created_at > ? OR (sm.created_at = ? AND ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) > ?)) ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", + "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? AND (sm.created_at > ? OR (sm.created_at = ? AND ('at://' || s.did || '/space/' || s.type_nsid || '/' || s.skey) > ?)) ORDER BY sm.created_at ASC, ('at://' || s.did || '/space/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", backend, ) } else { adapt_sql( - "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", + "SELECT s.did, s.authority_did, s.type_nsid, s.skey, sm.created_at FROM happyview_space_members sm JOIN happyview_spaces s ON s.id = sm.space_id WHERE sm.member_did = ? ORDER BY sm.created_at ASC, ('at://' || s.did || '/space/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", backend, ) }; @@ -147,7 +147,7 @@ pub async fn list_spaces_for_user( .into_iter() .map( |(space_did, authority_did, type_nsid, skey, created_at)| SpaceView { - uri: format!("ats://{}/{}/{}", space_did, type_nsid, skey), + uri: format!("at://{}/space/{}/{}", space_did, type_nsid, skey), is_owner: authority_did == did, created_at, }, @@ -529,6 +529,27 @@ pub async fn list_space_records( Ok((records, next_cursor)) } +pub async fn list_all_space_records( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM happyview_space_records WHERE space_id = ? AND author_did = ? ORDER BY collection, rkey", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .bind(space_id) + .bind(author_did) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list all space records: {e}")))?; + + rows.into_iter().map(parse_record_row).collect() +} + pub async fn insert_space_record( pool: &sqlx::AnyPool, backend: DatabaseBackend, diff --git a/src/spaces/integration_tests.rs b/src/spaces/integration_tests.rs index 1494fa6..c5afda8 100644 --- a/src/spaces/integration_tests.rs +++ b/src/spaces/integration_tests.rs @@ -41,13 +41,13 @@ mod tests { let sk = test_signing_key(); let vk = *sk.verifying_key(); - let space_uri = "ats://did:plc:abc/com.example.forum/main"; + let space_uri = "at://did:plc:abc/space/com.example.forum/main"; let rev = "3k2rev1"; - let commit = sign_commit(&hash_after_ab, space_uri, rev, &sk).unwrap(); + let commit = sign_commit(&hash_after_ab, space_uri, "did:plc:testuser", rev, &sk).unwrap(); assert_eq!(commit.hash, hash_after_ab); assert_eq!(commit.rev, rev); - assert!(verify_commit(&commit, space_uri, &vk).is_ok()); + assert!(verify_commit(&commit, space_uri, "did:plc:testuser", &vk).is_ok()); } /// Remove a record — hash must change back toward the previous state. @@ -76,9 +76,9 @@ mod tests { let sk = test_signing_key(); let vk = *sk.verifying_key(); - let space_uri = "ats://did:plc:abc/com.example.forum/main"; - let commit = sign_commit(&hash_one, space_uri, "3k2rev2", &sk).unwrap(); - assert!(verify_commit(&commit, space_uri, &vk).is_ok()); + let space_uri = "at://did:plc:abc/space/com.example.forum/main"; + let commit = sign_commit(&hash_one, space_uri, "did:plc:testuser", "3k2rev2", &sk).unwrap(); + assert!(verify_commit(&commit, space_uri, "did:plc:testuser", &vk).is_ok()); } /// Commit signed for one hash must not verify against a different hash. @@ -94,13 +94,13 @@ mod tests { let sk = test_signing_key(); let vk = *sk.verifying_key(); - let space_uri = "ats://did:plc:abc/com.example.forum/main"; + let space_uri = "at://did:plc:abc/space/com.example.forum/main"; - let commit_a = sign_commit(&hash_a, space_uri, "rev1", &sk).unwrap(); + let commit_a = sign_commit(&hash_a, space_uri, "did:plc:testuser", "rev1", &sk).unwrap(); // Tamper: swap in hash_b let mut tampered = commit_a; tampered.hash = hash_b; - assert!(verify_commit(&tampered, space_uri, &vk).is_err()); + assert!(verify_commit(&tampered, space_uri, "did:plc:testuser", &vk).is_err()); } // ----------------------------------------------------------------------- @@ -138,7 +138,7 @@ mod tests { // Step 1: member signs delegation token let delegation = DelegationTokenClaims { iss: "did:plc:member".into(), - sub: "ats://did:plc:space/com.example.forum/main".into(), + sub: "at://did:plc:space/space/com.example.forum/main".into(), aud: "did:plc:space#atproto_space_host".into(), iat: now, exp: now + DELEGATION_TOKEN_TTL_SECS, @@ -154,7 +154,7 @@ mod tests { assert_eq!(verified_delegation.iss, "did:plc:member"); assert_eq!( verified_delegation.sub, - "ats://did:plc:space/com.example.forum/main" + "at://did:plc:space/space/com.example.forum/main" ); // Step 3: space host issues a space credential (using P-256 key) @@ -175,7 +175,7 @@ mod tests { ); assert_eq!( peek_credential_sub(&credential).as_deref(), - Some("ats://did:plc:space/com.example.forum/main") + Some("at://did:plc:space/space/com.example.forum/main") ); // Step 4: verify credential @@ -183,7 +183,7 @@ mod tests { assert_eq!(verified_cred.iss, "did:plc:space"); assert_eq!( verified_cred.sub, - "ats://did:plc:space/com.example.forum/main" + "at://did:plc:space/space/com.example.forum/main" ); } @@ -196,7 +196,7 @@ mod tests { let delegation = DelegationTokenClaims { iss: "did:plc:member".into(), - sub: "ats://did:plc:space/com.example.forum/main".into(), + sub: "at://did:plc:space/space/com.example.forum/main".into(), aud: "did:plc:space#atproto_space_host".into(), iat: now - 120, exp: now - 60, // already expired @@ -240,6 +240,7 @@ mod tests { rkey: "3k2abc".into(), cid: Some("bafyreiabc".into()), prev: None, + value: None, created_at: "2026-01-01T00:00:00Z".into(), }; @@ -266,6 +267,7 @@ mod tests { rkey: "3k2abc".into(), cid: None, prev: Some("bafyreiabc".into()), + value: None, created_at: "2026-01-01T00:00:01Z".into(), }; @@ -499,11 +501,11 @@ mod tests { } let qs = serde_json::json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "cid": "bafyreiabc123" }); let q: BlobQuery = serde_json::from_value(qs).unwrap(); - assert_eq!(q.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(q.space, "at://did:plc:abc/space/com.example.forum/main"); assert_eq!(q.cid, "bafyreiabc123"); } @@ -540,7 +542,7 @@ mod tests { let claims = SpaceCredentialClaims { iss: "did:plc:owner".into(), - sub: "ats://did:plc:owner/com.example.forum/main".into(), + sub: "at://did:plc:owner/space/com.example.forum/main".into(), iat: now, exp: now + DEFAULT_CREDENTIAL_TTL_SECS, jti: make_jti(), @@ -562,7 +564,7 @@ mod tests { let claims = SpaceCredentialClaims { iss: "did:plc:owner".into(), - sub: "ats://did:plc:owner/com.example.forum/main".into(), + sub: "at://did:plc:owner/space/com.example.forum/main".into(), iat: now, exp: now + DEFAULT_CREDENTIAL_TTL_SECS, jti: make_jti(), diff --git a/src/spaces/members.rs b/src/spaces/members.rs index 477df80..c4deef5 100644 --- a/src/spaces/members.rs +++ b/src/spaces/members.rs @@ -87,13 +87,13 @@ fn resolve_members_recursive<'a>( /// Resolve a delegation member entry to the target space ID. /// -/// Delegation entries store either an ats:// URI or a space ID directly. +/// Delegation entries store either an at:// URI or a space ID directly. async fn resolve_delegation_target( pool: &sqlx::AnyPool, backend: DatabaseBackend, member: &SpaceMember, ) -> Result, AppError> { - if member.did.starts_with("ats://") { + if member.did.starts_with("at://") { let uri = SpaceUri::parse(&member.did)?; let space = db::get_space_by_address(pool, backend, &uri.did, &uri.type_nsid, &uri.skey).await?; diff --git a/src/spaces/mod.rs b/src/spaces/mod.rs index c7b5c11..4b96a6e 100644 --- a/src/spaces/mod.rs +++ b/src/spaces/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod car; pub mod client_attestation; pub mod commit; pub mod credential; @@ -18,10 +19,10 @@ mod integration_tests; use crate::error::AppError; use std::fmt; -/// A parsed `ats://` URI for addressing permissioned data. +/// A parsed `at://` URI for addressing permissioned data. /// -/// Full form: `ats:///////` -/// Space-only form: `ats:////` +/// Full form: `at:///space/////` +/// Space-only form: `at:///space//` #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SpaceUri { pub did: String, @@ -34,39 +35,61 @@ pub struct SpaceUri { impl SpaceUri { pub fn parse(uri: &str) -> Result { - let stripped = uri - .strip_prefix("ats://") - .ok_or_else(|| AppError::BadRequest("SpaceUri must start with ats://".into()))?; + // Rewrite legacy ats:// URIs (ats://did/type/skey) to at://did/space/type/skey + let normalized; + let stripped = if let Some(ats_rest) = uri.strip_prefix("ats://") { + let ats_parts: Vec<&str> = ats_rest.split('/').collect(); + if ats_parts.len() >= 3 { + normalized = format!("at://{}/space/{}", ats_parts[0], ats_parts[1..].join("/")); + normalized + .strip_prefix("at://") + .expect("just constructed with at:// prefix") + } else { + return Err(AppError::BadRequest( + "ats:// URI requires at least did/type/skey".into(), + )); + } + } else { + uri.strip_prefix("at://") + .ok_or_else(|| AppError::BadRequest("SpaceUri must start with at://".into()))? + }; let parts: Vec<&str> = stripped.split('/').collect(); - if parts.len() < 3 { + // Must have at least: did/space/type_nsid/skey (4 segments) + if parts.len() < 4 { + return Err(AppError::BadRequest( + "SpaceUri requires at least did/space/type_nsid/skey".into(), + )); + } + + if parts[1] != "space" { return Err(AppError::BadRequest( - "SpaceUri requires at least did/type_nsid/skey".into(), + "SpaceUri must have 'space' as the second path segment".into(), )); } - if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { + if parts[0].is_empty() || parts[2].is_empty() || parts[3].is_empty() { return Err(AppError::BadRequest( "SpaceUri components must not be empty".into(), )); } let did = parts[0].to_string(); - let type_nsid = parts[1].to_string(); - let skey = parts[2].to_string(); + let type_nsid = parts[2].to_string(); + let skey = parts[3].to_string(); - let (user_did, collection, rkey) = if parts.len() >= 6 { + let (user_did, collection, rkey) = if parts.len() >= 7 { ( - Some(parts[3].to_string()), Some(parts[4].to_string()), Some(parts[5].to_string()), + Some(parts[6].to_string()), ) - } else if parts.len() == 3 { + } else if parts.len() == 4 { (None, None, None) } else { return Err(AppError::BadRequest( - "SpaceUri must have 3 components (space) or 6 components (record)".into(), + "SpaceUri must have 4 components (space) or 7 components (record)".into(), )); }; @@ -81,7 +104,7 @@ impl SpaceUri { } pub fn space_uri(&self) -> String { - format!("ats://{}/{}/{}", self.did, self.type_nsid, self.skey) + format!("at://{}/space/{}/{}", self.did, self.type_nsid, self.skey) } pub fn is_record_uri(&self) -> bool { @@ -95,7 +118,11 @@ impl SpaceUri { impl fmt::Display for SpaceUri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "ats://{}/{}/{}", self.did, self.type_nsid, self.skey)?; + write!( + f, + "at://{}/space/{}/{}", + self.did, self.type_nsid, self.skey + )?; if let (Some(user), Some(col), Some(rkey)) = (&self.user_did, &self.collection, &self.rkey) { write!(f, "/{}/{}/{}", user, col, rkey)?; @@ -110,7 +137,7 @@ mod tests { #[test] fn parse_space_uri() { - let uri = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main").unwrap(); + let uri = SpaceUri::parse("at://did:plc:abc123/space/com.example.forum/main").unwrap(); assert_eq!(uri.did, "did:plc:abc123"); assert_eq!(uri.type_nsid, "com.example.forum"); assert_eq!(uri.skey, "main"); @@ -122,7 +149,7 @@ mod tests { #[test] fn parse_record_uri() { let uri = SpaceUri::parse( - "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", + "at://did:plc:abc123/space/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", ) .unwrap(); assert_eq!(uri.did, "did:plc:abc123"); @@ -147,7 +174,7 @@ mod tests { }; assert_eq!( uri.to_string(), - "ats://did:plc:abc123/com.example.forum/main" + "at://did:plc:abc123/space/com.example.forum/main" ); } @@ -163,53 +190,86 @@ mod tests { }; assert_eq!( uri.to_string(), - "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc" + "at://did:plc:abc123/space/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc" ); } #[test] fn space_uri_extracts_space_part() { let uri = SpaceUri::parse( - "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", + "at://did:plc:abc123/space/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", ) .unwrap(); assert_eq!( uri.space_uri(), - "ats://did:plc:abc123/com.example.forum/main" + "at://did:plc:abc123/space/com.example.forum/main" + ); + } + + #[test] + fn rewrite_ats_space_uri() { + let uri = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main").unwrap(); + assert_eq!(uri.did, "did:plc:abc123"); + assert_eq!(uri.type_nsid, "com.example.forum"); + assert_eq!(uri.skey, "main"); + assert!(uri.is_space_uri()); + assert_eq!( + uri.to_string(), + "at://did:plc:abc123/space/com.example.forum/main" ); } #[test] - fn reject_at_scheme() { + fn rewrite_ats_record_uri() { + let uri = SpaceUri::parse( + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", + ) + .unwrap(); + assert_eq!(uri.did, "did:plc:abc123"); + assert_eq!(uri.type_nsid, "com.example.forum"); + assert_eq!(uri.skey, "main"); + assert_eq!(uri.user_did.as_deref(), Some("did:plc:user1")); + assert!(uri.is_record_uri()); + } + + #[test] + fn reject_ats_too_few_segments() { + let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum"); + assert!(result.is_err()); + } + + #[test] + fn reject_missing_space_segment() { let result = SpaceUri::parse("at://did:plc:abc123/com.example.forum/main"); assert!(result.is_err()); } #[test] fn reject_too_few_components() { - let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum"); + let result = SpaceUri::parse("at://did:plc:abc123/space/com.example.forum"); assert!(result.is_err()); } #[test] fn reject_wrong_component_count() { - let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main/did:plc:user1"); + let result = + SpaceUri::parse("at://did:plc:abc123/space/com.example.forum/main/did:plc:user1"); assert!(result.is_err()); } #[test] fn reject_empty_components() { - let result = SpaceUri::parse("ats:///com.example.forum/main"); + let result = SpaceUri::parse("at:///space/com.example.forum/main"); assert!(result.is_err()); } #[test] fn roundtrip_parse_display() { - let original = "ats://did:plc:abc123/com.example.forum/main"; + let original = "at://did:plc:abc123/space/com.example.forum/main"; let uri = SpaceUri::parse(original).unwrap(); assert_eq!(uri.to_string(), original); - let original_record = "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc"; + let original_record = "at://did:plc:abc123/space/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc"; let uri = SpaceUri::parse(original_record).unwrap(); assert_eq!(uri.to_string(), original_record); } diff --git a/src/spaces/oplog.rs b/src/spaces/oplog.rs index 65cd745..2beb320 100644 --- a/src/spaces/oplog.rs +++ b/src/spaces/oplog.rs @@ -91,6 +91,82 @@ pub async fn list_ops( rkey: r.7, cid: r.8, prev: r.9, + value: None, + created_at: r.10, + }) + }) + .collect() +} + +pub async fn list_ops_with_values( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: &str, + since_rev: Option<&str>, + limit: i64, +) -> Result, AppError> { + let sql = if since_rev.is_some() { + adapt_sql( + "SELECT o.id, o.space_id, o.author_did, o.rev, o.idx, o.action, o.collection, o.rkey, o.cid, o.prev, o.created_at, r.record FROM happyview_space_record_oplog o LEFT JOIN happyview_space_records r ON r.space_id = o.space_id AND r.author_did = o.author_did AND r.collection = o.collection AND r.rkey = o.rkey AND r.cid = o.cid WHERE o.space_id = ? AND o.author_did = ? AND o.rev > ? ORDER BY o.rev, o.idx LIMIT ?", + backend, + ) + } else { + adapt_sql( + "SELECT o.id, o.space_id, o.author_did, o.rev, o.idx, o.action, o.collection, o.rkey, o.cid, o.prev, o.created_at, r.record FROM happyview_space_record_oplog o LEFT JOIN happyview_space_records r ON r.space_id = o.space_id AND r.author_did = o.author_did AND r.collection = o.collection AND r.rkey = o.rkey AND r.cid = o.cid WHERE o.space_id = ? AND o.author_did = ? ORDER BY o.rev, o.idx LIMIT ?", + backend, + ) + }; + + type OplogWithValueRow = ( + String, + String, + String, + String, + i32, + String, + String, + String, + Option, + Option, + String, + Option, + ); + + let mut query = sqlx::query_as::<_, OplogWithValueRow>(&sql) + .bind(space_id) + .bind(author_did); + if let Some(rev) = since_rev { + query = query.bind(rev); + } + query = query.bind(limit); + + let rows = query.fetch_all(pool).await.map_err(|e| { + AppError::Internal(format!("failed to list oplog entries with values: {e}")) + })?; + + rows.into_iter() + .map(|r| { + let action = OplogAction::parse(&r.5) + .ok_or_else(|| AppError::Internal(format!("invalid oplog action: {}", r.5)))?; + let value = r + .11 + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| AppError::Internal(format!("failed to parse record value: {e}")))?; + Ok(OplogEntry { + id: r.0, + space_id: r.1, + author_did: r.2, + rev: r.3, + idx: r.4, + action, + collection: r.6, + rkey: r.7, + cid: r.8, + prev: r.9, + value, created_at: r.10, }) }) diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index a4cbf1f..3e34a28 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -24,7 +24,14 @@ use crate::spaces::{SpaceUri, db, members, notifications, oplog}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct RepoStateQuery { +struct LatestCommitQuery { + space: String, + did: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetRepoQuery { space: String, did: String, } @@ -36,6 +43,7 @@ struct ListRepoOpsQuery { did: String, limit: Option, cursor: Option, + exclude_values: Option, } #[derive(Deserialize)] @@ -217,10 +225,15 @@ pub fn space_routes() -> Router { &format!("/xrpc/{PROTO_NS}.space.listRecords"), get(list_records), ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getLatestCommit"), + get(get_latest_commit), + ) .route( &format!("/xrpc/{PROTO_NS}.space.getRepoState"), - get(get_repo_state), + get(get_latest_commit), ) + .route(&format!("/xrpc/{PROTO_NS}.space.getRepo"), get(get_repo)) .route( &format!("/xrpc/{PROTO_NS}.space.listRepoOps"), get(list_repo_ops), @@ -409,7 +422,10 @@ async fn require_membership( space_credential: Option<&str>, ) -> Result { if let Some(token) = space_credential { - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); match crate::spaces::credential::verify_external_credential( token, &state.http, @@ -490,7 +506,10 @@ async fn get_space( } } - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); let simplespace_config = serde_json::json!({ "$type": "com.atproto.simplespace.defs#spaceConfig", "mintPolicy": space.mint_policy, @@ -561,7 +580,7 @@ async fn create_record( let rkey = generate_tid(); let cid = content_cid(&input.record); let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, rkey ); @@ -609,7 +628,7 @@ async fn put_record( let cid = content_cid(&input.record); let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, input.rkey ); @@ -653,7 +672,7 @@ async fn delete_record( let space = resolve_space(&state, &input.space).await?; let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, input.collection, input.rkey ); @@ -722,7 +741,7 @@ async fn apply_writes( let rkey = rkey.unwrap_or_else(generate_tid); let cid = content_cid(&value); let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); let record = SpaceRecord { @@ -749,7 +768,7 @@ async fn apply_writes( } => { let cid = content_cid(&value); let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); let record = SpaceRecord { @@ -784,7 +803,7 @@ async fn apply_writes( swap_record, } => { let record_uri = format!( - "ats://{}/{}/{}/{}/{}/{}", + "at://{}/space/{}/{}/{}/{}/{}", space.did, space.type_nsid, space.skey, did, collection, rkey ); if let Some(swap_cid) = swap_record { @@ -1007,7 +1026,7 @@ async fn accept_invite( db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?; let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; - let space_uri = space.map(|s| format!("ats://{}/{}/{}", s.did, s.type_nsid, s.skey)); + let space_uri = space.map(|s| format!("at://{}/space/{}/{}", s.did, s.type_nsid, s.skey)); let mut response = Json(serde_json::json!({ "uri": space_uri, @@ -1095,7 +1114,10 @@ async fn get_delegation_token( .as_secs(); let exp = now + crate::spaces::credential::DELEGATION_TOKEN_TTL_SECS; - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); let space_host = format!("{}#atproto_space_host", space.did); let delegation_claims = crate::spaces::credential::DelegationTokenClaims { iss: did, @@ -1122,10 +1144,10 @@ async fn get_delegation_token( // Protocol endpoint implementations // --------------------------------------------------------------------------- -async fn get_repo_state( +async fn get_latest_commit( State(state): State, claims: XrpcClaims, - Query(params): Query, + Query(params): Query, ) -> Result { let did = require_auth_or_credential(&state, &claims).await?; let space = resolve_space(&state, ¶ms.space).await?; @@ -1149,6 +1171,7 @@ async fn get_repo_state( "rev": repo_state.rev, "commit": repo_state.hash.as_ref().map(|h| { serde_json::json!({ + "ver": 1, "hash": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(h), "ikm": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.ikm.as_deref().unwrap_or_default()), "sig": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.sig.as_deref().unwrap_or_default()), @@ -1159,6 +1182,62 @@ async fn get_repo_state( }))) } +async fn get_repo( + State(state): State, + claims: XrpcClaims, + Query(params): Query, +) -> Result { + let did = require_auth_or_credential(&state, &claims).await?; + let space = resolve_space(&state, ¶ms.space).await?; + let has_credential = claims.space_credential.is_some(); + let membership = require_membership( + &state, + &space, + &did, + false, + claims.space_credential.as_deref(), + ) + .await?; + + let read_access = SpaceReadAccess::from_space_access(membership); + check_read_access(&did, ¶ms.did, read_access, has_credential)?; + + let repo_state = + db::get_or_create_repo_state(&state.db, state.db_backend, &space.id, ¶ms.did).await?; + let records = + db::list_all_space_records(&state.db, state.db_backend, &space.id, ¶ms.did).await?; + + let commit = crate::spaces::commit::SignedCommit { + ver: 1, + hash: repo_state + .hash + .as_deref() + .and_then(|h| <[u8; 32]>::try_from(h).ok()) + .unwrap_or([0u8; 32]), + ikm: repo_state + .ikm + .as_deref() + .and_then(|i| <[u8; 32]>::try_from(i).ok()) + .unwrap_or([0u8; 32]), + sig: repo_state.sig.unwrap_or_default(), + mac: repo_state + .mac + .as_deref() + .and_then(|m| <[u8; 32]>::try_from(m).ok()) + .unwrap_or([0u8; 32]), + rev: repo_state.rev.unwrap_or_default(), + }; + + let car_bytes = crate::spaces::car::serialize_repo(&commit, &records)?; + + Ok(( + StatusCode::OK, + [(axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car")], + car_bytes, + ) + .into_response()) +} + async fn list_repo_ops( State(state): State, claims: XrpcClaims, @@ -1180,15 +1259,29 @@ async fn list_repo_ops( check_read_access(&did, ¶ms.did, read_access, has_credential)?; let limit = params.limit.unwrap_or(100).min(1000); - let ops = oplog::list_ops( - &state.db, - state.db_backend, - &space.id, - ¶ms.did, - params.cursor.as_deref(), - limit, - ) - .await?; + let exclude_values = params.exclude_values.unwrap_or(false); + + let ops = if exclude_values { + oplog::list_ops( + &state.db, + state.db_backend, + &space.id, + ¶ms.did, + params.cursor.as_deref(), + limit, + ) + .await? + } else { + oplog::list_ops_with_values( + &state.db, + state.db_backend, + &space.id, + ¶ms.did, + params.cursor.as_deref(), + limit, + ) + .await? + }; Ok(Json(serde_json::json!({ "ops": ops }))) } @@ -1416,12 +1509,12 @@ mod tests { #[test] fn deserialize_create_record_input() { let input: CreateRecordInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "collection": "com.example.forum.post", "record": { "text": "hello" } })) .unwrap(); - assert_eq!(input.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(input.space, "at://did:plc:abc/space/com.example.forum/main"); assert_eq!(input.collection, "com.example.forum.post"); assert_eq!(input.record["text"], "hello"); } @@ -1429,7 +1522,7 @@ mod tests { #[test] fn deserialize_put_record_with_swap() { let input: PutRecordInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { "text": "updated" }, @@ -1442,7 +1535,7 @@ mod tests { #[test] fn deserialize_put_record_without_swap() { let input: PutRecordInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "record": { "text": "hello" } @@ -1454,7 +1547,7 @@ mod tests { #[test] fn deserialize_delete_record_with_swap() { let input: DeleteRecordInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "collection": "com.example.forum.post", "rkey": "3k2abc", "swapRecord": "bafyrei456" @@ -1552,7 +1645,7 @@ mod tests { #[test] fn deserialize_apply_writes_input() { let input: ApplyWritesInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "swapCommit": "tid123", "writes": [ { @@ -1568,7 +1661,7 @@ mod tests { ] })) .unwrap(); - assert_eq!(input.space, "ats://did:plc:abc/com.example.forum/main"); + assert_eq!(input.space, "at://did:plc:abc/space/com.example.forum/main"); assert_eq!(input.swap_commit.as_deref(), Some("tid123")); assert_eq!(input.writes.len(), 2); } @@ -1576,7 +1669,7 @@ mod tests { #[test] fn deserialize_apply_writes_without_swap_commit() { let input: ApplyWritesInput = serde_json::from_value(json!({ - "space": "ats://did:plc:abc/com.example.forum/main", + "space": "at://did:plc:abc/space/com.example.forum/main", "writes": [ { "action": "create", diff --git a/src/spaces/simplespace.rs b/src/spaces/simplespace.rs index e77603f..d285233 100644 --- a/src/spaces/simplespace.rs +++ b/src/spaces/simplespace.rs @@ -288,7 +288,10 @@ async fn create_space( }; db::add_member(&state.db, state.db_backend, &member).await?; - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); let body = serde_json::json!({ "uri": space_uri, }); @@ -342,7 +345,10 @@ async fn update_space( db::update_space(&state.db, state.db_backend, &space).await?; - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let space_uri = format!( + "at://{}/space/{}/{}", + space.did, space.type_nsid, space.skey + ); Ok(Json(serde_json::json!({ "uri": space_uri, "space": space, diff --git a/src/spaces/types.rs b/src/spaces/types.rs index a804264..622c536 100644 --- a/src/spaces/types.rs +++ b/src/spaces/types.rs @@ -126,6 +126,8 @@ pub struct OplogEntry { pub rkey: String, pub cid: Option, pub prev: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, pub created_at: String, } diff --git a/tests/spaces_db.rs b/tests/spaces_db.rs index 5e3ed51..345d923 100644 --- a/tests/spaces_db.rs +++ b/tests/spaces_db.rs @@ -307,6 +307,7 @@ async fn oplog_append_and_list() { } else { None }, + value: None, created_at: now_rfc3339(), }; oplog::append_op(&pool, backend, &entry) @@ -356,6 +357,7 @@ async fn oplog_list_with_since_rev_cursor() { rkey: format!("item-{i}"), cid: Some(format!("bafy{i}")), prev: None, + value: None, created_at: now_rfc3339(), }; oplog::append_op(&pool, backend, &entry) diff --git a/web/tests/e2e/spaces.spec.ts b/web/tests/e2e/spaces.spec.ts index 0125bf3..82cea4b 100644 --- a/web/tests/e2e/spaces.spec.ts +++ b/web/tests/e2e/spaces.spec.ts @@ -57,7 +57,7 @@ test.describe("Spaces API", () => { } const createBody = await createResp.json() expect(createBody).toHaveProperty("uri") - expect(createBody.uri).toMatch(/^ats:\/\//) + expect(createBody.uri).toMatch(/^at:\/\/.+\/space\//) createdSpaceUri = createBody.uri const listResp = await page.request.get(