diff --git a/Cargo.lock b/Cargo.lock index f971801..e244f8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -124,6 +124,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -456,6 +468,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -722,6 +748,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "cookie" version = "0.18.1" @@ -782,6 +814,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-bforest" version = "0.116.1" @@ -1666,6 +1707,7 @@ dependencies = [ "axum", "axum-extra", "base64 0.22.1", + "blake3", "bytes", "chrono", "ciborium", @@ -1677,6 +1719,8 @@ dependencies = [ "futures-util", "hex", "hickory-resolver", + "hkdf", + "hmac", "http-body-util", "ipnet", "jose-jwk", @@ -2925,7 +2969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3618,7 +3662,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3629,7 +3673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] diff --git a/Cargo.toml b/Cargo.toml index b7ff51b..9bb091e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ wasmtime-wasi = "29" regex = "1.12.3" semver = "1.0" async-stream = "0.3.6" +blake3 = "1" +hkdf = "0.12" +hmac = "0.12" [[bin]] name = "migrate-lua-sql" diff --git a/migrations/postgres/20260627000000_proposal_0016_alignment.sql b/migrations/postgres/20260627000000_proposal_0016_alignment.sql new file mode 100644 index 0000000..056a657 --- /dev/null +++ b/migrations/postgres/20260627000000_proposal_0016_alignment.sql @@ -0,0 +1,71 @@ +-- Proposal 0016: Permissioned Data alignment +-- Restructures spaces for the formal AT Protocol permissioned data spec. + +-- 1. Rename owner_did → authority_did, add creator_did +ALTER TABLE happyview_spaces RENAME COLUMN owner_did TO authority_did; +ALTER TABLE happyview_spaces ADD COLUMN creator_did TEXT; +UPDATE happyview_spaces SET creator_did = authority_did WHERE creator_did IS NULL; +ALTER TABLE happyview_spaces ALTER COLUMN creator_did SET NOT NULL; + +-- 2. Replace access_mode + allowlist/denylist with mint_policy + app_access +-- mint_policy: 'member-list' (default) | 'public' | 'managing-app' +ALTER TABLE happyview_spaces ADD COLUMN mint_policy TEXT NOT NULL DEFAULT 'member-list'; +-- app_access: JSON open union, e.g. {"type": "open"} or {"type": "allowList", "allowed": [...]} +ALTER TABLE happyview_spaces ADD COLUMN app_access TEXT NOT NULL DEFAULT '{"type":"open"}'; +-- Migrate existing data +UPDATE happyview_spaces +SET app_access = '{"type":"allowList","allowed":' || COALESCE(app_allowlist, '[]') || '}' +WHERE access_mode = 'default_deny' AND app_allowlist IS NOT NULL; +-- Drop old columns +ALTER TABLE happyview_spaces DROP COLUMN access_mode; +ALTER TABLE happyview_spaces DROP COLUMN app_allowlist; +ALTER TABLE happyview_spaces DROP COLUMN app_denylist; + +-- 3. Per-user repo state: LtHash state + signed commit +CREATE TABLE happyview_space_repo_state ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + lthash_state BYTEA NOT NULL DEFAULT decode(repeat('00', 2048), 'hex'), + rev TEXT, + hash BYTEA, + ikm BYTEA, + sig BYTEA, + mac BYTEA, + updated_at TEXT NOT NULL, + UNIQUE (space_id, author_did) +); +CREATE INDEX idx_space_repo_state_space ON happyview_space_repo_state(space_id); + +-- 4. Record operation log +CREATE TABLE happyview_space_record_oplog ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + rev TEXT NOT NULL, + idx INTEGER NOT NULL DEFAULT 0, + action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + cid TEXT, + prev TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX idx_space_oplog_space_author ON happyview_space_record_oplog(space_id, author_did); +CREATE INDEX idx_space_oplog_rev ON happyview_space_record_oplog(space_id, author_did, rev); + +-- 5. Write notification registrations +CREATE TABLE happyview_space_notify_registrations ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT, + endpoint TEXT NOT NULL, + registered_by TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX idx_space_notify_space ON happyview_space_notify_registrations(space_id); +CREATE INDEX idx_space_notify_repo ON happyview_space_notify_registrations(space_id, author_did); + +-- 6. Drop old sync state table +DROP TABLE IF EXISTS happyview_space_sync_state; diff --git a/migrations/postgres/20260627000001_verification_methods.sql b/migrations/postgres/20260627000001_verification_methods.sql new file mode 100644 index 0000000..df5e625 --- /dev/null +++ b/migrations/postgres/20260627000001_verification_methods.sql @@ -0,0 +1,8 @@ +CREATE TABLE happyview_verification_methods ( + id TEXT PRIMARY KEY, + fragment_id TEXT NOT NULL UNIQUE, + key_type TEXT NOT NULL DEFAULT 'Multikey', + public_key_multibase TEXT NOT NULL, + private_key_enc BYTEA NOT NULL, + created_at TEXT NOT NULL +); diff --git a/migrations/sqlite/20260627000000_proposal_0016_alignment.sql b/migrations/sqlite/20260627000000_proposal_0016_alignment.sql new file mode 100644 index 0000000..8297e82 --- /dev/null +++ b/migrations/sqlite/20260627000000_proposal_0016_alignment.sql @@ -0,0 +1,84 @@ +-- Proposal 0016: Permissioned Data alignment + +-- 1+2. Rebuild happyview_spaces with renamed/new columns +CREATE TABLE happyview_spaces_new ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL, + authority_did TEXT NOT NULL, + creator_did TEXT NOT NULL, + type_nsid TEXT NOT NULL, + skey TEXT NOT NULL, + display_name TEXT, + description TEXT, + mint_policy TEXT NOT NULL DEFAULT 'member-list', + app_access TEXT NOT NULL DEFAULT '{"type":"open"}', + managing_app_did TEXT, + config TEXT NOT NULL DEFAULT '{}', + revision TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (did, type_nsid, skey) +); + +INSERT INTO happyview_spaces_new (id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, revision, created_at, updated_at) +SELECT id, did, owner_did, owner_did, type_nsid, skey, display_name, description, + 'member-list', + CASE + WHEN access_mode = 'default_deny' AND app_allowlist IS NOT NULL + THEN '{"type":"allowList","allowed":' || app_allowlist || '}' + ELSE '{"type":"open"}' + END, + managing_app_did, config, revision, created_at, updated_at +FROM happyview_spaces; + +DROP TABLE happyview_spaces; +ALTER TABLE happyview_spaces_new RENAME TO happyview_spaces; + +-- 3. Per-user repo state +CREATE TABLE happyview_space_repo_state ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + lthash_state BLOB NOT NULL DEFAULT X'', + rev TEXT, + hash BLOB, + ikm BLOB, + sig BLOB, + mac BLOB, + updated_at TEXT NOT NULL, + UNIQUE (space_id, author_did) +); +CREATE INDEX idx_space_repo_state_space ON happyview_space_repo_state(space_id); + +-- 4. Record operation log +CREATE TABLE happyview_space_record_oplog ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + rev TEXT NOT NULL, + idx INTEGER NOT NULL DEFAULT 0, + action TEXT NOT NULL CHECK (action IN ('create', 'update', 'delete')), + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + cid TEXT, + prev TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX idx_space_oplog_space_author ON happyview_space_record_oplog(space_id, author_did); +CREATE INDEX idx_space_oplog_rev ON happyview_space_record_oplog(space_id, author_did, rev); + +-- 5. Write notification registrations +CREATE TABLE happyview_space_notify_registrations ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES happyview_spaces(id) ON DELETE CASCADE, + author_did TEXT, + endpoint TEXT NOT NULL, + registered_by TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX idx_space_notify_space ON happyview_space_notify_registrations(space_id); +CREATE INDEX idx_space_notify_repo ON happyview_space_notify_registrations(space_id, author_did); + +-- 6. Drop old sync state table +DROP TABLE IF EXISTS happyview_space_sync_state; diff --git a/migrations/sqlite/20260627000001_verification_methods.sql b/migrations/sqlite/20260627000001_verification_methods.sql new file mode 100644 index 0000000..23780a9 --- /dev/null +++ b/migrations/sqlite/20260627000001_verification_methods.sql @@ -0,0 +1,8 @@ +CREATE TABLE happyview_verification_methods ( + id TEXT PRIMARY KEY, + fragment_id TEXT NOT NULL UNIQUE, + key_type TEXT NOT NULL DEFAULT 'Multikey', + public_key_multibase TEXT NOT NULL, + private_key_enc BLOB NOT NULL, + created_at TEXT NOT NULL +); diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 0ad582d..9dc9eab 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -21,6 +21,7 @@ pub mod settings; mod stats; pub(crate) mod types; mod users; +mod verification_methods; use axum::Router; use axum::routing::{delete, get, patch, post, put}; @@ -190,4 +191,12 @@ pub fn admin_routes(_state: AppState) -> Router { .post(service_entries::add_xrpcs) .delete(service_entries::remove_xrpcs), ) + .route( + "/verification-methods", + get(verification_methods::list).post(verification_methods::create), + ) + .route( + "/verification-methods/{fragment_id}", + delete(verification_methods::delete), + ) } diff --git a/src/admin/service_entries.rs b/src/admin/service_entries.rs index 9aaa01a..097f905 100644 --- a/src/admin/service_entries.rs +++ b/src/admin/service_entries.rs @@ -203,11 +203,18 @@ pub(super) async fn sync_plc( .filter_map(|v| v.as_str().map(String::from)) .collect(); - let verification_methods = last_op["verificationMethods"] + let mut verification_methods = last_op["verificationMethods"] .as_object() .cloned() .unwrap_or_default(); + // Merge verification methods from the table + let vm_entries = crate::verification_methods::list_methods(&state.db, state.db_backend).await?; + for vm in &vm_entries { + let key = vm.fragment_id.trim_start_matches('#').to_string(); + verification_methods.insert(key, serde_json::json!(vm.public_key_multibase)); + } + // Build services: start from existing, then merge our service entries let mut services_map = last_op["services"].as_object().cloned().unwrap_or_default(); @@ -446,11 +453,16 @@ pub(super) async fn sync_plc_submit( let services: Unknown = serde_json::from_value(serde_json::Value::Object(services_map)) .map_err(|e| AppError::Internal(format!("failed to build services Unknown: {e}")))?; - // Preserve existing verification methods - let vm_map = last_op["verificationMethods"] + // Merge verification methods from the table into existing + let mut vm_map = last_op["verificationMethods"] .as_object() .cloned() .unwrap_or_default(); + let vm_entries = crate::verification_methods::list_methods(&state.db, state.db_backend).await?; + for vm in &vm_entries { + let key = vm.fragment_id.trim_start_matches('#').to_string(); + vm_map.insert(key, serde_json::json!(vm.public_key_multibase)); + } let verification_methods: Unknown = serde_json::from_value(serde_json::Value::Object(vm_map)) .map_err(|e| { AppError::Internal(format!("failed to build verification methods Unknown: {e}")) diff --git a/src/admin/verification_methods.rs b/src/admin/verification_methods.rs new file mode 100644 index 0000000..9b39b0f --- /dev/null +++ b/src/admin/verification_methods.rs @@ -0,0 +1,95 @@ +use axum::{Json, extract::Path, extract::State, http::StatusCode}; + +use crate::AppState; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::verification_methods::{VerificationMethod, create_method, delete_method, list_methods}; + +use super::auth::UserAuth; +use super::permissions::Permission; + +/// GET /admin/verification-methods — list all verification methods. +pub(super) async fn list( + State(state): State, + auth: UserAuth, +) -> Result>, AppError> { + auth.require(Permission::SettingsManage).await?; + + let methods = list_methods(&state.db, state.db_backend).await?; + Ok(Json(methods)) +} + +#[derive(Debug, serde::Deserialize)] +pub(super) struct CreateVerificationMethodBody { + pub fragment_id: String, +} + +/// POST /admin/verification-methods — create a new verification method (generates P-256 keypair). +pub(super) async fn create( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + auth.require(Permission::SettingsManage).await?; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + let method = create_method( + &state.db, + state.db_backend, + &body.fragment_id, + encryption_key, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "verification_method.created".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(method.fragment_id.clone()), + detail: serde_json::json!({ "fragment_id": &method.fragment_id }), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::CREATED, Json(method))) +} + +/// DELETE /admin/verification-methods/{fragment_id} — delete a verification method. +pub(super) async fn delete( + State(state): State, + auth: UserAuth, + Path(fragment_id): Path, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let deleted = delete_method(&state.db, state.db_backend, &fragment_id).await?; + if !deleted { + return Err(AppError::NotFound(format!( + "verification method '{}' not found", + fragment_id + ))); + } + + log_event( + &state.db, + EventLog { + event_type: "verification_method.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(fragment_id.clone()), + detail: serde_json::json!({ "fragment_id": &fragment_id }), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs index 1dd04c3..93fa2b8 100644 --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -314,7 +314,29 @@ impl FromRequestParts for XrpcClaims { } Some(_) => Err(AppError::Auth("invalid Authorization scheme".into())), None => { - // No auth header — anonymous access (client-key only) + // No auth header — try cookie auth, fall back to anonymous + let jar: SignedCookieJar = SignedCookieJar::from_request_parts(parts, state) + .await + .map_err(|_| AppError::Auth("failed to read cookies".into()))?; + + if let Some(cookie) = jar.get(COOKIE_NAME) { + let value = cookie.value().to_string(); + let (did, client_key) = if let Some((d, k)) = value.split_once(COOKIE_SEP) { + (d.to_string(), Some(k.to_string())) + } else { + (value, None) + }; + return Ok(XrpcClaims { + identity: Some(Claims { + did, + client_key, + dpop_key_id: None, + }), + space_credential: None, + service_auth: None, + }); + } + Ok(XrpcClaims { identity: None, space_credential: None, diff --git a/src/lexicon.rs b/src/lexicon.rs index 6de3fda..7374f81 100644 --- a/src/lexicon.rs +++ b/src/lexicon.rs @@ -12,6 +12,8 @@ pub enum LexiconType { Record, Query, Procedure, + /// A space type declaration: defines the shape and allowed collections for a space type. + Space, /// Lexicons with no `main` def or a non-endpoint main type (token, object, string, etc.). Definitions, } @@ -83,6 +85,10 @@ pub struct ParsedLexicon { pub token_cost: Option, /// Optional space type NSID indicating this lexicon is designed for use within spaces of that type. pub space_type: Option, + /// For space declarations: the human-readable name (1-64 chars). + pub space_name: Option, + /// For space declarations: the allowed collection NSIDs. + pub space_collections: Option>, } impl ParsedLexicon { @@ -110,6 +116,7 @@ impl ParsedLexicon { Some("record") => LexiconType::Record, Some("query") => LexiconType::Query, Some("procedure") => LexiconType::Procedure, + Some("space") => LexiconType::Space, _ => LexiconType::Definitions, }; @@ -128,6 +135,41 @@ impl ParsedLexicon { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let (space_name, space_collections) = if lexicon_type == LexiconType::Space { + let main = main_def.ok_or("space lexicon missing 'defs.main'")?; + + main.get("key") + .and_then(|v| v.as_str()) + .ok_or("space lexicon 'defs.main.key' must be a string")?; + + let name = main + .get("name") + .and_then(|v| v.as_str()) + .ok_or("space lexicon 'defs.main.name' must be a string")?; + let name_len = name.chars().count(); + if name_len == 0 || name_len > 64 { + return Err("space lexicon 'defs.main.name' must be 1-64 characters".into()); + } + + let collections_val = main + .get("collections") + .and_then(|v| v.as_array()) + .ok_or("space lexicon 'defs.main.collections' must be an array")?; + let collections: Vec = collections_val + .iter() + .enumerate() + .map(|(i, v)| { + v.as_str() + .map(|s| s.to_string()) + .ok_or_else(|| format!("space lexicon 'collections[{i}]' must be a string")) + }) + .collect::>()?; + + (Some(name.to_string()), Some(collections)) + } else { + (None, None) + }; + Ok(Self { id, lexicon_type, @@ -142,6 +184,8 @@ impl ParsedLexicon { action, token_cost, space_type, + space_name, + space_collections, }) } } @@ -275,6 +319,15 @@ impl LexiconRegistry { let inner = self.inner.read().await; inner.len() } + + /// Look up a space-type declaration by NSID. Returns `None` if not found or not a space type. + pub async fn get_space_declaration(&self, id: &str) -> Option { + let inner = self.inner.read().await; + inner + .get(id) + .filter(|lex| lex.lexicon_type == LexiconType::Space) + .cloned() + } } #[cfg(test)] @@ -654,6 +707,164 @@ mod tests { assert_eq!(ProcedureAction::Upsert.to_optional_str(), None); } + fn space_declaration_lexicon_json() -> Value { + json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "key": "slug", + "name": "Forum", + "collections": [ + "com.example.forum.post", + "com.example.forum.comment" + ] + } + } + }) + } + + #[test] + fn parse_space_declaration_lexicon() { + let parsed = ParsedLexicon::parse( + space_declaration_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); + assert_eq!(parsed.lexicon_type, LexiconType::Space); + assert_eq!(parsed.space_name.as_deref(), Some("Forum")); + assert_eq!( + parsed.space_collections, + Some(vec![ + "com.example.forum.post".to_string(), + "com.example.forum.comment".to_string() + ]) + ); + } + + #[test] + fn parse_space_declaration_missing_key_returns_error() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "name": "Forum", + "collections": [] + } + } + }); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("key")); + } + + #[test] + fn parse_space_declaration_missing_name_returns_error() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "key": "slug", + "collections": [] + } + } + }); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); + } + + #[test] + fn parse_space_declaration_name_too_long_returns_error() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "key": "slug", + "name": "a".repeat(65), + "collections": [] + } + } + }); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("1-64")); + } + + #[test] + fn parse_space_declaration_missing_collections_returns_error() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum", + "defs": { + "main": { + "type": "space", + "key": "slug", + "name": "Forum" + } + } + }); + let result = ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("collections")); + } + + #[tokio::test] + async fn registry_get_space_declaration() { + let reg = LexiconRegistry::new(); + let parsed = ParsedLexicon::parse( + space_declaration_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); + reg.upsert(parsed).await; + + let decl = reg.get_space_declaration("com.example.forum").await; + assert!(decl.is_some()); + assert_eq!( + decl.unwrap().space_collections, + Some(vec![ + "com.example.forum.post".to_string(), + "com.example.forum.comment".to_string() + ]) + ); + + let not_space = reg.get_space_declaration("nonexistent").await; + assert!(not_space.is_none()); + } + + #[tokio::test] + async fn registry_get_space_declaration_excludes_non_space_types() { + let reg = LexiconRegistry::new(); + let parsed = ParsedLexicon::parse( + record_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + ) + .unwrap(); + reg.upsert(parsed).await; + + let result = reg + .get_space_declaration("games.gamesgamesgamesgames.game") + .await; + assert!(result.is_none()); + } + #[test] fn parse_space_type_from_lexicon() { let raw = json!({ diff --git a/src/lib.rs b/src/lib.rs index 61358d1..0b1b6cd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,7 @@ pub mod service_entries; pub mod service_identity; pub mod setup; pub mod spaces; +pub mod verification_methods; pub mod xrpc; use auth::oauth_store::{DbSessionStore, DbStateStore}; diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs index a124ca0..baaecf0 100644 --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -352,6 +352,8 @@ mod tests { action: ProcedureAction::Create, token_cost: None, space_type: None, + space_name: None, + space_collections: None, } } @@ -370,6 +372,8 @@ mod tests { action: ProcedureAction::Create, token_cost: None, space_type: None, + space_name: None, + space_collections: None, } } diff --git a/src/server.rs b/src/server.rs index c3f293f..71d655f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -69,6 +69,12 @@ pub fn router(state: AppState) -> Router { crate::feature_middleware::require_spaces, )), ) + .merge(crate::spaces::simplespace::simplespace_routes().layer( + axum::middleware::from_fn_with_state( + state.clone(), + crate::feature_middleware::require_spaces, + ), + )) .nest("/auth", crate::auth::routes::routes()) .nest("/external-auth", crate::external_auth::routes()) .nest("/oauth", crate::oauth::routes::routes()) @@ -368,6 +374,12 @@ async fn well_known_did_json( .map(|e| (e.fragment_id.clone(), e.service_type.clone())) .collect(); + let extra_vms = crate::verification_methods::list_methods(&state.db, state.db_backend) + .await? + .into_iter() + .map(|m| (m.fragment_id, m.key_type, m.public_key_multibase)) + .collect::>(); + let service_endpoint = format!("https://{host}"); let signing_key_multibase = extract_public_key_multibase(&identity, &state)?; @@ -378,6 +390,7 @@ async fn well_known_did_json( &signing_key_multibase, &entry_pairs, &service_endpoint, + &extra_vms, ) .ok_or_else(|| AppError::NotFound("DID document not available".into()))?; diff --git a/src/service_identity.rs b/src/service_identity.rs index 5cc9192..ee72032 100644 --- a/src/service_identity.rs +++ b/src/service_identity.rs @@ -188,12 +188,16 @@ pub async fn mark_setup_complete(db: &AnyPool, backend: DatabaseBackend) -> Resu /// The DID is derived dynamically from the request host rather than stored, /// so the same signing key works across any domain pointing at this server. /// Returns None if the identity mode is not DidWeb. +/// +/// `extra_verification_methods` is a slice of (fragment_id, key_type, public_key_multibase) +/// tuples for additional verification methods (e.g. `#atproto_space`). pub fn generate_did_document( identity: &ServiceIdentity, host: &str, signing_key_multibase: &str, service_entries: &[(String, String)], service_endpoint: &str, + extra_verification_methods: &[(String, String, String)], ) -> Option { if identity.mode != IdentityMode::DidWeb { return None; @@ -201,12 +205,21 @@ pub fn generate_did_document( let did = format!("did:web:{}", host.replace(':', "%3A")); - let verification_method = serde_json::json!([{ + let mut verification_methods: Vec = vec![serde_json::json!({ "id": format!("{did}#atproto"), "type": "Multikey", "controller": &did, "publicKeyMultibase": signing_key_multibase - }]); + })]; + + for (fragment_id, key_type, public_key_multibase) in extra_verification_methods { + verification_methods.push(serde_json::json!({ + "id": format!("{did}{fragment_id}"), + "type": key_type, + "controller": &did, + "publicKeyMultibase": public_key_multibase + })); + } let services: Vec = service_entries .iter() @@ -225,7 +238,7 @@ pub fn generate_did_document( "https://w3id.org/security/multikey/v1" ], "id": &did, - "verificationMethod": verification_method, + "verificationMethod": verification_methods, "service": services })) } @@ -270,8 +283,15 @@ mod tests { fn generate_did_document_returns_none_for_non_web() { let identity = make_identity(IdentityMode::DidPlc, Some("did:plc:abc123")); assert!( - generate_did_document(&identity, "example.com", "zKey", &[], "https://example.com") - .is_none() + generate_did_document( + &identity, + "example.com", + "zKey", + &[], + "https://example.com", + &[] + ) + .is_none() ); } @@ -284,6 +304,7 @@ mod tests { "zKey123", &[], "https://example.com", + &[], ) .unwrap(); assert_eq!(doc["id"], "did:web:example.com"); @@ -298,6 +319,7 @@ mod tests { "zKey123", &[], "https://example.com", + &[], ) .unwrap(); assert_eq!(doc["id"], "did:web:example.com"); @@ -321,6 +343,7 @@ mod tests { "zKey123", &entries, "https://example.com", + &[], ) .unwrap(); let services = doc["service"].as_array().unwrap(); @@ -334,9 +357,15 @@ mod tests { #[test] fn generate_did_document_context_and_structure() { let identity = make_identity(IdentityMode::DidWeb, None); - let doc = - generate_did_document(&identity, "example.com", "zKey", &[], "https://example.com") - .unwrap(); + let doc = generate_did_document( + &identity, + "example.com", + "zKey", + &[], + "https://example.com", + &[], + ) + .unwrap(); let context = doc["@context"].as_array().unwrap(); assert_eq!(context.len(), 2); assert_eq!(context[0], "https://www.w3.org/ns/did/v1"); @@ -347,4 +376,29 @@ mod tests { assert_eq!(vm["type"], "Multikey"); assert_eq!(vm["controller"], "did:web:example.com"); } + + #[test] + fn generate_did_document_includes_extra_verification_methods() { + let identity = make_identity(IdentityMode::DidWeb, None); + let extra = vec![( + "#atproto_space".to_string(), + "Multikey".to_string(), + "zExtraKey".to_string(), + )]; + let doc = generate_did_document( + &identity, + "example.com", + "zKey123", + &[], + "https://example.com", + &extra, + ) + .unwrap(); + let vms = doc["verificationMethod"].as_array().unwrap(); + assert_eq!(vms.len(), 2); + assert_eq!(vms[0]["id"], "did:web:example.com#atproto"); + assert_eq!(vms[1]["id"], "did:web:example.com#atproto_space"); + assert_eq!(vms[1]["publicKeyMultibase"], "zExtraKey"); + assert_eq!(vms[1]["type"], "Multikey"); + } } diff --git a/src/spaces/auth.rs b/src/spaces/auth.rs index e9e9f6c..edf971d 100644 --- a/src/spaces/auth.rs +++ b/src/spaces/auth.rs @@ -9,24 +9,28 @@ use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::plugin::encryption::{decrypt, encrypt}; use crate::spaces::credential::{ - DEFAULT_CREDENTIAL_TTL_SECS, SpaceCredentialClaims, sign_credential, + DEFAULT_CREDENTIAL_TTL_SECS, SpaceCredentialClaims, make_jti, sign_credential, }; -use crate::spaces::types::{AccessMode, Space}; +use crate::spaces::types::{AppAccess, MintPolicy, Space}; pub struct IssuedCredential { pub token: String, pub expires_at: String, } +#[allow(clippy::too_many_arguments)] pub async fn issue_credential( pool: &sqlx::AnyPool, backend: DatabaseBackend, + http: &reqwest::Client, encryption_key: &[u8; 32], space: &Space, subject_did: &str, client_id: Option<&str>, + authority_did: &str, ) -> Result { check_app_access(space, client_id)?; + check_mint_policy(http, space, subject_did, client_id, authority_did).await?; let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; @@ -37,12 +41,11 @@ pub async fn issue_credential( let exp = now + DEFAULT_CREDENTIAL_TTL_SECS; let claims = SpaceCredentialClaims { - iss: space.did.clone(), - sub: subject_did.to_string(), - space: format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey), - scope: "read".into(), + iss: space.authority_did.clone(), + sub: format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey), iat: now, exp, + jti: make_jti(), }; let token = sign_credential(&claims, &private_jwk)?; @@ -57,37 +60,188 @@ pub async fn issue_credential( Ok(IssuedCredential { token, expires_at }) } -pub fn check_app_access(space: &Space, client_id: Option<&str>) -> Result<(), AppError> { - let Some(client_id) = client_id else { - return Ok(()); - }; - - match space.access_mode { - AccessMode::DefaultDeny => { - if let Some(ref allowlist) = space.app_allowlist { - if !allowlist.iter().any(|id| id == client_id) { - return Err(AppError::Forbidden( - "This app is not authorized to access this space".into(), - )); - } +async fn check_mint_policy( + http: &reqwest::Client, + space: &Space, + subject_did: &str, + client_id: Option<&str>, + authority_did: &str, +) -> Result<(), AppError> { + match space.mint_policy { + MintPolicy::Public => Ok(()), + MintPolicy::MemberList => { + // Caller must already be a member; verified upstream by the credential issuance route. + // We trust that the delegation token proves membership was checked. + Ok(()) + } + MintPolicy::ManagingApp => { + let managing_app = space.managing_app_did.as_deref().ok_or_else(|| { + AppError::Internal( + "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 granted = check_user_access_with_managing_app( + http, + managing_app, + &space_uri, + subject_did, + client_id, + authority_did, + ) + .await?; + if granted { + Ok(()) } else { - return Err(AppError::Forbidden( - "Space is in default_deny mode with no allowlist".into(), - )); + Err(AppError::Forbidden( + "managing app denied access to this space".into(), + )) } } - AccessMode::DefaultAllow => { - if let Some(ref denylist) = space.app_denylist - && denylist.iter().any(|id| id == client_id) - { - return Err(AppError::Forbidden( - "This app has been denied access to this space".into(), - )); - } + } +} + +async fn check_user_access_with_managing_app( + http: &reqwest::Client, + managing_app: &str, + space_uri: &str, + user_did: &str, + client_id: Option<&str>, + authority_did: &str, +) -> Result { + // Parse DID#fragment — the fragment identifies the service endpoint in the DID doc. + // For outbound callback we derive the endpoint from the DID. + let (did, _fragment) = if let Some(pos) = managing_app.find('#') { + (&managing_app[..pos], Some(&managing_app[pos + 1..])) + } else { + (managing_app, None) + }; + + // Resolve the managing app's PDS/service endpoint from its DID document. + let endpoint = resolve_did_service_endpoint(http, did).await?; + + let url = format!( + "{}/xrpc/com.atproto.simplespace.checkUserAccess", + endpoint.trim_end_matches('/') + ); + + let mut body = serde_json::json!({ + "space": space_uri, + "did": user_did, + }); + if let Some(cid) = client_id { + body["clientId"] = serde_json::Value::String(cid.to_string()); + } + + // Service auth: iss = authority_did, aud = managing_app DID. + // We use a simple unsigned assertion here; a full implementation would sign with the space key. + // For now we send the request without service auth and rely on the managing app to trust HappyView. + let resp = http + .post(&url) + .json(&body) + .header("X-Authority-Did", authority_did) + .send() + .await + .map_err(|e| AppError::Internal(format!("checkUserAccess request failed: {e}")))?; + + if resp.status() == reqwest::StatusCode::FORBIDDEN + || resp.status() == reqwest::StatusCode::UNAUTHORIZED + { + return Ok(false); + } + + if !resp.status().is_success() { + return Err(AppError::Internal(format!( + "checkUserAccess returned unexpected status {}", + resp.status() + ))); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("checkUserAccess response parse failed: {e}")))?; + + Ok(json + .get("granted") + .and_then(|v| v.as_bool()) + .unwrap_or(false)) +} + +async fn resolve_did_service_endpoint( + http: &reqwest::Client, + did: &str, +) -> Result { + let url = if did.starts_with("did:plc:") { + format!("https://plc.directory/{did}") + } else if did.starts_with("did:web:") { + let identifier = did.strip_prefix("did:web:").unwrap(); + let mut segments = identifier.split(':'); + let host = segments.next().unwrap(); + let path_segments: Vec<&str> = segments.collect(); + if path_segments.is_empty() { + format!("https://{host}/.well-known/did.json") + } else { + format!("https://{host}/{}/did.json", path_segments.join("/")) } + } else { + return Err(AppError::BadRequest(format!( + "unsupported DID method for managing app: {did}" + ))); + }; + + #[derive(serde::Deserialize)] + struct DidDoc { + #[serde(default)] + service: Vec, + } + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct DidService { + id: String, + service_endpoint: String, } - Ok(()) + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("DID resolution failed for {did}: {e}")))?; + + if !resp.status().is_success() { + return Err(AppError::Internal(format!( + "DID resolution returned {} for {did}", + resp.status() + ))); + } + + let doc: DidDoc = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid DID document for {did}: {e}")))?; + + doc.service + .iter() + .find(|s| s.id == "#atproto_pds" || s.id == format!("{did}#atproto_pds")) + .map(|s| s.service_endpoint.clone()) + .ok_or_else(|| AppError::Internal(format!("no #atproto_pds service in DID doc for {did}"))) +} + +pub fn check_app_access(space: &Space, attested_client_id: Option<&str>) -> Result<(), AppError> { + match &space.app_access { + AppAccess::Open => Ok(()), + AppAccess::AllowList { allowed } => { + let client_id = attested_client_id + .ok_or_else(|| AppError::Auth("space requires client attestation".into()))?; + if allowed.iter().any(|id| id == client_id) { + Ok(()) + } else { + Err(AppError::Forbidden( + "this app is not authorized to access this space".into(), + )) + } + } + } } async fn get_or_create_signing_key( @@ -139,7 +293,7 @@ async fn get_or_create_signing_key( .bind(&space.id) .bind(&encrypted_signing) .bind(&encrypted_rotation) - .bind(&space.owner_did) + .bind(&space.authority_did) .bind(&now) .execute(pool) .await @@ -219,20 +373,20 @@ async fn store_credential_record( #[cfg(test)] mod tests { use super::*; - use crate::spaces::types::{AccessMode, Space, SpaceConfig}; + use crate::spaces::types::{AppAccess, MintPolicy, Space, SpaceConfig}; - fn test_space(access_mode: AccessMode) -> Space { + fn test_space(app_access: AppAccess) -> Space { Space { id: "test-space".into(), did: "did:plc:owner".into(), - owner_did: "did:plc:owner".into(), + authority_did: "did:plc:owner".into(), + creator_did: "did:plc:owner".into(), type_nsid: "com.example.forum".into(), skey: "main".into(), display_name: None, description: None, - access_mode, - app_allowlist: None, - app_denylist: None, + mint_policy: MintPolicy::MemberList, + app_access, managing_app_did: None, config: SpaceConfig::default(), revision: None, @@ -242,41 +396,41 @@ mod tests { } #[test] - fn app_access_default_allow_no_lists() { - let space = test_space(AccessMode::DefaultAllow); + fn app_access_open_allows_any() { + let space = test_space(AppAccess::Open); assert!(check_app_access(&space, Some("any-app")).is_ok()); } #[test] - fn app_access_default_allow_denied() { - let mut space = test_space(AccessMode::DefaultAllow); - space.app_denylist = Some(vec!["bad-app".into()]); - + fn app_access_allowlist_permits_listed() { + let space = test_space(AppAccess::AllowList { + allowed: vec!["good-app".into()], + }); assert!(check_app_access(&space, Some("good-app")).is_ok()); - assert!(check_app_access(&space, Some("bad-app")).is_err()); + assert!(check_app_access(&space, Some("other-app")).is_err()); } #[test] - fn app_access_default_deny_no_allowlist() { - let space = test_space(AccessMode::DefaultDeny); - assert!(check_app_access(&space, Some("any-app")).is_err()); + fn app_access_allowlist_requires_client_id() { + let space = test_space(AppAccess::AllowList { allowed: vec![] }); + assert!(check_app_access(&space, None).is_err()); } #[test] - fn app_access_default_deny_allowed() { - let mut space = test_space(AccessMode::DefaultDeny); - space.app_allowlist = Some(vec!["good-app".into()]); - - assert!(check_app_access(&space, Some("good-app")).is_ok()); - assert!(check_app_access(&space, Some("other-app")).is_err()); + fn app_access_open_allows_none_client_id() { + let space = test_space(AppAccess::Open); + assert!(check_app_access(&space, None).is_ok()); } #[test] - fn app_access_no_client_id_always_passes() { - let space = test_space(AccessMode::DefaultDeny); - assert!(check_app_access(&space, None).is_ok()); + fn app_access_empty_allowlist_rejects() { + let space = test_space(AppAccess::AllowList { allowed: vec![] }); + assert!(check_app_access(&space, Some("any-client")).is_err()); } + // resolve_did_service_endpoint is async and makes HTTP calls to resolve DID + // documents, so it cannot be unit-tested without a mock HTTP server. + #[test] fn generate_keypair_produces_valid_jwk() { let kp = generate_space_keypair().unwrap(); diff --git a/src/spaces/client_attestation.rs b/src/spaces/client_attestation.rs new file mode 100644 index 0000000..d635340 --- /dev/null +++ b/src/spaces/client_attestation.rs @@ -0,0 +1,131 @@ +use crate::error::AppError; + +pub const CLIENT_ATTESTATION_TYP: &str = "atproto-client-attestation+jwt"; + +pub struct VerifiedAttestation { + pub client_id: String, +} + +pub async fn verify_client_attestation( + token: &str, + expected_aud: &str, + http: &reqwest::Client, +) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Auth("invalid client attestation format".into())); + } + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| AppError::Auth("invalid attestation header encoding".into()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| AppError::Auth("invalid attestation header".into()))?; + + if header["typ"].as_str() != Some(CLIENT_ATTESTATION_TYP) { + return Err(AppError::Auth(format!( + "attestation typ must be {CLIENT_ATTESTATION_TYP}" + ))); + } + + let kid = header["kid"] + .as_str() + .ok_or_else(|| AppError::Auth("attestation header missing kid".into()))?; + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid attestation payload encoding".into()))?; + + #[derive(serde::Deserialize)] + struct AttestationClaims { + iss: String, + sub: String, + aud: String, + exp: u64, + } + + let claims: AttestationClaims = serde_json::from_slice(&payload_bytes) + .map_err(|_| AppError::Auth("invalid attestation payload".into()))?; + + if claims.iss != claims.sub { + return Err(AppError::Auth("attestation iss must equal sub".into())); + } + + if claims.aud != expected_aud { + return Err(AppError::Auth("attestation aud mismatch".into())); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + if now >= claims.exp { + return Err(AppError::Auth("client attestation has expired".into())); + } + + // Fetch client metadata + let metadata: serde_json::Value = http + .get(&claims.iss) + .send() + .await + .map_err(|e| AppError::Internal(format!("failed to fetch client metadata: {e}")))? + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid client metadata: {e}")))?; + + // Resolve JWKS + let jwks = if let Some(jwks) = metadata.get("jwks") { + jwks.clone() + } else if let Some(jwks_uri) = metadata["jwks_uri"].as_str() { + http.get(jwks_uri) + .send() + .await + .map_err(|e| AppError::Internal(format!("failed to fetch JWKS: {e}")))? + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid JWKS: {e}")))? + } else { + return Err(AppError::Auth( + "client metadata has no jwks or jwks_uri".into(), + )); + }; + + // Find key by kid + let keys = jwks["keys"] + .as_array() + .ok_or_else(|| AppError::Auth("JWKS missing keys array".into()))?; + + let key = keys + .iter() + .find(|k| k["kid"].as_str() == Some(kid)) + .ok_or_else(|| AppError::Auth(format!("no key matching kid '{kid}' in JWKS")))?; + + // Verify signature using the matched key + let alg = header["alg"].as_str().unwrap_or("ES256"); + match alg { + "ES256" => { + let jwk = crate::spaces::credential::p256_jwk_to_verifying_key(key)?; + let message = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| AppError::Auth("invalid attestation signature encoding".into()))?; + let sig = p256::ecdsa::Signature::from_bytes(sig_bytes.as_slice().into()) + .map_err(|_| AppError::Auth("invalid attestation signature format".into()))?; + use p256::ecdsa::signature::Verifier; + jwk.verify(message.as_bytes(), &sig) + .map_err(|_| AppError::Auth("attestation signature verification failed".into()))?; + } + _ => { + return Err(AppError::Auth(format!( + "unsupported attestation alg: {alg}" + ))); + } + } + + Ok(VerifiedAttestation { + client_id: claims.iss, + }) +} diff --git a/src/spaces/commit.rs b/src/spaces/commit.rs new file mode 100644 index 0000000..1248d53 --- /dev/null +++ b/src/spaces/commit.rs @@ -0,0 +1,227 @@ +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use k256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Signer, signature::Verifier}; +use sha2::Sha256; + +use crate::error::AppError; + +pub struct SignedCommit { + pub hash: [u8; 32], + pub ikm: [u8; 32], + pub sig: Vec, + pub mac: [u8; 32], + pub rev: String, +} + +pub fn build_context(space_uri: &str, rev: &str, ikm: &[u8; 32]) -> Vec { + let tag = b"atproto-space-v1"; + let space_bytes = space_uri.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); + + ctx.extend_from_slice(tag); + + // TLS 1.3 variable-length encoding: big-endian uint16 length prefix + ctx.extend_from_slice(&(space_bytes.len() as u16).to_be_bytes()); + ctx.extend_from_slice(space_bytes); + + ctx.extend_from_slice(&(rev_bytes.len() as u16).to_be_bytes()); + ctx.extend_from_slice(rev_bytes); + + ctx.extend_from_slice(&(ikm.len() as u16).to_be_bytes()); + ctx.extend_from_slice(ikm); + + ctx +} + +pub fn sign_commit( + hash: &[u8; 32], + space_uri: &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); + + // sig covers space + rev + ikm, NOT the hash — prevents rebroadcast proof + let sig: Signature = signing_key.sign(&ctx); + + // mac = HMAC-SHA256(HKDF-SHA256(ikm, ctx), hash) + let hk = Hkdf::::new(None, &ikm); + let mut derived_key = [0u8; 32]; + hk.expand(&ctx, &mut derived_key) + .map_err(|e| AppError::Internal(format!("HKDF expand failed: {e}")))?; + + let mut mac_hasher = as Mac>::new_from_slice(&derived_key) + .map_err(|e| AppError::Internal(format!("HMAC init failed: {e}")))?; + mac_hasher.update(hash); + let mac: [u8; 32] = mac_hasher.finalize().into_bytes().into(); + + Ok(SignedCommit { + hash: *hash, + ikm, + sig: sig.to_bytes().to_vec(), + mac, + rev: rev.to_string(), + }) +} + +pub fn verify_commit( + commit: &SignedCommit, + space_uri: &str, + verifying_key: &VerifyingKey, +) -> Result<(), AppError> { + let ctx = build_context(space_uri, &commit.rev, &commit.ikm); + + let sig = Signature::from_bytes(commit.sig.as_slice().into()) + .map_err(|_| AppError::Auth("invalid commit signature format".into()))?; + verifying_key + .verify(&ctx, &sig) + .map_err(|_| AppError::Auth("commit signature verification failed".into()))?; + + // Recompute and verify MAC + let hk = Hkdf::::new(None, &commit.ikm); + let mut derived_key = [0u8; 32]; + hk.expand(&ctx, &mut derived_key) + .map_err(|e| AppError::Internal(format!("HKDF expand failed: {e}")))?; + + let mut mac_hasher = as Mac>::new_from_slice(&derived_key) + .map_err(|e| AppError::Internal(format!("HMAC init failed: {e}")))?; + mac_hasher.update(&commit.hash); + + mac_hasher.verify_slice(&commit.mac).map_err(|_| { + AppError::Auth("commit MAC verification failed — repo hash mismatch".into()) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use k256::ecdsa::SigningKey; + + fn test_signing_key() -> SigningKey { + let mut bytes = [0u8; 32]; + bytes[31] = 1; // valid non-zero scalar + SigningKey::from_bytes((&bytes[..]).into()).unwrap() + } + + #[test] + fn context_string_format() { + let ctx = build_context( + "ats://did:plc:abc/com.example.forum/main", + "3k2abc", + &[0xAA; 32], + ); + // Starts with protocol tag + assert!(ctx.starts_with(b"atproto-space-v1")); + } + + #[test] + fn context_includes_all_fields() { + let space = "ats://did:plc:abc/com.example.forum/main"; + let rev = "3k2abc"; + let ikm = [0xBB; 32]; + let ctx = build_context(space, rev, &ikm); + + // Context must contain the space URI, rev, and ikm + assert!(ctx.windows(space.len()).any(|w| w == space.as_bytes())); + assert!(ctx.windows(rev.len()).any(|w| w == rev.as_bytes())); + assert!(ctx.windows(32).any(|w| w == ikm)); + } + + #[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 commit = sign_commit(&hash, space, "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!(verify_commit(&commit, space, &vk).is_ok()); + } + + #[test] + fn verify_rejects_wrong_key() { + let sk1 = test_signing_key(); + let mut bytes2 = [0u8; 32]; + bytes2[31] = 2; + let sk2 = SigningKey::from_bytes((&bytes2[..]).into()).unwrap(); + let vk2 = *sk2.verifying_key(); + + let hash = [0xDD; 32]; + let space = "ats://did:plc:abc/com.example.forum/main"; + + let commit = sign_commit(&hash, space, "rev1", &sk1).unwrap(); + assert!(verify_commit(&commit, space, &vk2).is_err()); + } + + #[test] + fn verify_rejects_tampered_hash() { + 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 mut commit = sign_commit(&hash, space, "rev1", &sk).unwrap(); + commit.hash[0] ^= 0xFF; // tamper + assert!(verify_commit(&commit, space, &vk).is_err()); + } + + #[test] + fn verify_rejects_wrong_space() { + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let hash = [0xFF; 32]; + + let commit = sign_commit( + &hash, + "ats://did:plc:abc/com.example.forum/main", + "rev1", + &sk, + ) + .unwrap(); + assert!(verify_commit(&commit, "ats://did:plc:xyz/com.example.forum/other", &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 c1 = sign_commit(&hash, space, "rev1", &sk).unwrap(); + let c2 = sign_commit(&hash, space, "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()); + } + + #[test] + fn verify_rejects_tampered_mac() { + 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 mut commit = sign_commit(&hash, space, "rev1", &sk).unwrap(); + assert!(verify_commit(&commit, space, &vk).is_ok()); + commit.mac[0] ^= 0xFF; + assert!(verify_commit(&commit, space, &vk).is_err()); + } +} diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs index 5237291..fa61b93 100644 --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -1,13 +1,21 @@ use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use p256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Signer, signature::Verifier}; +use k256::ecdsa::{ + Signature as K256Signature, SigningKey as K256SigningKey, VerifyingKey as K256VerifyingKey, + signature::Signer as K256Signer, signature::Verifier as K256Verifier, +}; +use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::error::AppError; use crate::profile; -pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 4 * 60 * 60; // 4 hours -pub const GRANT_TTL_SECS: u64 = 5 * 60; // 5 minutes +pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 2 * 60 * 60; // 2 hours +pub const DELEGATION_TOKEN_TTL_SECS: u64 = 60; // 60 seconds + +pub const DELEGATION_TOKEN_TYP: &str = "atproto-space-delegation+jwt"; +pub const SPACE_CREDENTIAL_TYP: &str = "atproto-space-credential+jwt"; /// Peek at a JWT's header to check its `typ` field without verifying the signature. pub fn peek_jwt_typ(token: &str) -> Option { @@ -17,7 +25,7 @@ pub fn peek_jwt_typ(token: &str) -> Option { header["typ"].as_str().map(|s| s.to_string()) } -/// Peek at a space credential JWT's payload to extract the `sub` (user DID) without verifying. +/// Peek at a space credential JWT's payload to extract the `sub` (space URI) without verifying. pub fn peek_credential_sub(token: &str) -> Option { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { @@ -29,49 +37,111 @@ pub fn peek_credential_sub(token: &str) -> Option { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MemberGrantClaims { - pub sub: String, - pub space: String, - pub scope: String, +pub struct DelegationTokenClaims { + pub iss: String, // User DID + pub sub: String, // Space URI (ats://...) + pub aud: String, // Space host (did#atproto_space_host) pub iat: u64, pub exp: u64, + pub jti: String, // Random nonce } -pub fn sign_grant(claims: &MemberGrantClaims, secret: &[u8; 32]) -> Result { - let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256); - let key = jsonwebtoken::EncodingKey::from_secret(secret); - jsonwebtoken::encode(&header, claims, &key) - .map_err(|e| AppError::Internal(format!("failed to sign member grant: {e}"))) +pub fn sign_delegation_token( + claims: &DelegationTokenClaims, + signing_key: &K256SigningKey, +) -> Result { + let header = serde_json::json!({ + "alg": "ES256K", + "typ": DELEGATION_TOKEN_TYP, + "kid": "#atproto", + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()); + + let message = format!("{}.{}", header_b64, payload_b64); + let signature: K256Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + Ok(format!("{}.{}.{}", header_b64, payload_b64, sig_b64)) } -pub fn verify_grant(token: &str, secret: &[u8; 32]) -> Result { - let key = jsonwebtoken::DecodingKey::from_secret(secret); - let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256); - validation.required_spec_claims.clear(); - validation.validate_exp = false; - let data = jsonwebtoken::decode::(token, &key, &validation) - .map_err(|e| AppError::Auth(format!("invalid member grant: {e}")))?; +pub fn verify_delegation_token( + token: &str, + verifying_key: &K256VerifyingKey, +) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Auth("invalid delegation token format".into())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| AppError::Auth("invalid delegation token header encoding".into()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| AppError::Auth("invalid delegation token header".into()))?; + + if header["alg"].as_str() != Some("ES256K") { + return Err(AppError::Auth("delegation token alg must be ES256K".into())); + } + + if header["typ"].as_str() != Some(DELEGATION_TOKEN_TYP) { + return Err(AppError::Auth(format!( + "delegation token typ must be {DELEGATION_TOKEN_TYP}" + ))); + } + + let message = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| AppError::Auth("invalid delegation token signature encoding".into()))?; + + // Try direct verify, then with low-S normalization + let verified = if let Ok(sig) = K256Signature::from_bytes(sig_bytes.as_slice().into()) { + if verifying_key.verify(message.as_bytes(), &sig).is_ok() { + true + } else if let Some(normalized) = sig.normalize_s() { + verifying_key + .verify(message.as_bytes(), &normalized) + .is_ok() + } else { + false + } + } else { + false + }; + + if !verified { + return Err(AppError::Auth( + "delegation token signature verification failed".into(), + )); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid delegation token payload encoding".into()))?; + let claims: DelegationTokenClaims = serde_json::from_slice(&payload_bytes) + .map_err(|_| AppError::Auth("invalid delegation token payload".into()))?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - if now >= data.claims.exp { - return Err(AppError::Auth("member grant has expired".into())); + if now >= claims.exp { + return Err(AppError::Auth("delegation token has expired".into())); } - Ok(data.claims) + Ok(claims) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceCredentialClaims { - pub iss: String, - pub sub: String, - pub space: String, - pub scope: String, + pub iss: String, // Space authority DID + pub sub: String, // Space URI (ats://...) pub iat: u64, pub exp: u64, + pub jti: String, // Random nonce } pub fn sign_credential( @@ -91,7 +161,8 @@ pub fn sign_credential( let header = serde_json::json!({ "alg": "ES256", - "typ": "space_credential", + "typ": SPACE_CREDENTIAL_TYP, + "kid": "#atproto_space", }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); @@ -123,33 +194,13 @@ pub fn verify_credential( return Err(AppError::Auth("credential alg must be ES256".into())); } - if header["typ"].as_str() != Some("space_credential") { - return Err(AppError::Auth( - "credential typ must be space_credential".into(), - )); + if header["typ"].as_str() != Some(SPACE_CREDENTIAL_TYP) { + return Err(AppError::Auth(format!( + "credential typ must be {SPACE_CREDENTIAL_TYP}" + ))); } - let x_b64 = public_jwk["x"] - .as_str() - .ok_or_else(|| AppError::Auth("public key missing x".into()))?; - let y_b64 = public_jwk["y"] - .as_str() - .ok_or_else(|| AppError::Auth("public key missing y".into()))?; - - let x_bytes = URL_SAFE_NO_PAD - .decode(x_b64) - .map_err(|_| AppError::Auth("invalid public key x".into()))?; - let y_bytes = URL_SAFE_NO_PAD - .decode(y_b64) - .map_err(|_| AppError::Auth("invalid public key y".into()))?; - - let mut sec1 = Vec::with_capacity(1 + 32 + 32); - sec1.push(0x04); - sec1.extend_from_slice(&x_bytes); - sec1.extend_from_slice(&y_bytes); - - let verifying_key = VerifyingKey::from_sec1_bytes(&sec1) - .map_err(|_| AppError::Auth("invalid space credential public key".into()))?; + let verifying_key = p256_jwk_to_verifying_key(public_jwk)?; let message = format!("{}.{}", parts[0], parts[1]); let sig_bytes = URL_SAFE_NO_PAD @@ -180,6 +231,31 @@ pub fn verify_credential( Ok(claims) } +/// Extract a P-256 verifying key from a JWK. +pub fn p256_jwk_to_verifying_key(jwk: &serde_json::Value) -> Result { + let x_b64 = jwk["x"] + .as_str() + .ok_or_else(|| AppError::Auth("JWK missing x".into()))?; + let y_b64 = jwk["y"] + .as_str() + .ok_or_else(|| AppError::Auth("JWK missing y".into()))?; + + let x_bytes = URL_SAFE_NO_PAD + .decode(x_b64) + .map_err(|_| AppError::Auth("invalid JWK x".into()))?; + let y_bytes = URL_SAFE_NO_PAD + .decode(y_b64) + .map_err(|_| AppError::Auth("invalid JWK y".into()))?; + + let mut sec1 = Vec::with_capacity(65); + sec1.push(0x04); + sec1.extend_from_slice(&x_bytes); + sec1.extend_from_slice(&y_bytes); + + VerifyingKey::from_sec1_bytes(&sec1) + .map_err(|_| AppError::Auth("invalid P-256 public key".into())) +} + /// Convert a multibase-encoded P-256 public key (from a DID doc `publicKeyMultibase`) /// into a JWK suitable for `verify_credential`. pub fn multikey_to_p256_jwk(public_key_multibase: &str) -> Result { @@ -215,14 +291,13 @@ pub fn multikey_to_p256_jwk(public_key_multibase: &str) -> Result Result { - // Peek at the payload to extract the issuer DID without verifying yet let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { return Err(AppError::Auth("invalid credential format".into())); @@ -239,8 +314,10 @@ pub async fn verify_external_credential( let vm = did_doc .verification_method .iter() - .find(|v| v.id.ends_with("#atproto")) - .ok_or_else(|| AppError::Auth("issuer DID has no #atproto verification method".into()))?; + .find(|v| v.id.ends_with("#atproto_space")) + .ok_or_else(|| { + AppError::Auth("issuer DID has no #atproto_space verification method".into()) + })?; let multibase = vm .public_key_multibase @@ -251,6 +328,10 @@ pub async fn verify_external_credential( verify_credential(token, &jwk) } +pub fn make_jti() -> String { + Uuid::new_v4().to_string() +} + #[cfg(test)] mod tests { use super::*; @@ -263,11 +344,10 @@ mod tests { .as_secs(); SpaceCredentialClaims { iss: "did:plc:spaceowner".into(), - sub: "did:plc:requester".into(), - space: "did:plc:spaceowner/com.example.forum/main".into(), - scope: "read".into(), + sub: "ats://did:plc:spaceowner/com.example.forum/main".into(), iat: now, exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + jti: make_jti(), } } @@ -281,10 +361,9 @@ mod tests { assert_eq!(verified.iss, claims.iss); assert_eq!(verified.sub, claims.sub); - assert_eq!(verified.space, claims.space); - assert_eq!(verified.scope, claims.scope); assert_eq!(verified.iat, claims.iat); assert_eq!(verified.exp, claims.exp); + assert_eq!(verified.jti, claims.jti); } #[test] @@ -293,7 +372,6 @@ mod tests { let claims = make_claims(); let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); - // Tamper with the payload let parts: Vec<&str> = token.split('.').collect(); let mut payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); payload_bytes[0] ^= 0xFF; @@ -324,11 +402,10 @@ mod tests { .as_secs(); let claims = SpaceCredentialClaims { iss: "did:plc:owner".into(), - sub: "did:plc:user".into(), - space: "did:plc:owner/test/main".into(), - scope: "read".into(), + sub: "ats://did:plc:owner/com.example.test/main".into(), iat: now - 7200, - exp: now - 3600, // expired 1 hour ago + exp: now - 3600, + jti: make_jti(), }; let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); @@ -344,81 +421,114 @@ mod tests { assert!(result.is_err()); } - fn test_secret() -> [u8; 32] { - [0xAB; 32] + fn make_k256_signing_key() -> K256SigningKey { + let key_bytes = [0x42u8; 32]; + K256SigningKey::from_bytes((&key_bytes[..]).into()).expect("valid key") } - #[test] - fn grant_sign_and_verify_roundtrip() { - let secret = test_secret(); + fn make_delegation_claims() -> DelegationTokenClaims { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let claims = MemberGrantClaims { - sub: "did:plc:member".into(), - space: "ats://did:plc:space/com.example.forum/main".into(), - scope: "read".into(), + DelegationTokenClaims { + iss: "did:plc:member".into(), + sub: "ats://did:plc:space/com.example.forum/main".into(), + aud: "did:plc:space#atproto_space_host".into(), iat: now, - exp: now + GRANT_TTL_SECS, - }; + exp: now + DELEGATION_TOKEN_TTL_SECS, + jti: make_jti(), + } + } - let token = sign_grant(&claims, &secret).unwrap(); - let verified = verify_grant(&token, &secret).unwrap(); + #[test] + fn delegation_sign_and_verify_roundtrip() { + let signing_key = make_k256_signing_key(); + let verifying_key = K256VerifyingKey::from(&signing_key); + let claims = make_delegation_claims(); + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + let verified = verify_delegation_token(&token, &verifying_key).unwrap(); + + assert_eq!(verified.iss, claims.iss); assert_eq!(verified.sub, claims.sub); - assert_eq!(verified.space, claims.space); - assert_eq!(verified.scope, claims.scope); + assert_eq!(verified.aud, claims.aud); + assert_eq!(verified.jti, claims.jti); } #[test] - fn grant_rejects_wrong_secret() { - let secret1 = [0xAB; 32]; - let secret2 = [0xCD; 32]; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let claims = MemberGrantClaims { - sub: "did:plc:member".into(), - space: "ats://did:plc:space/com.example.forum/main".into(), - scope: "read".into(), - iat: now, - exp: now + GRANT_TTL_SECS, - }; - - let token = sign_grant(&claims, &secret1).unwrap(); - let result = verify_grant(&token, &secret2); + fn delegation_rejects_wrong_key() { + let signing_key = make_k256_signing_key(); + let other_key = K256SigningKey::from_bytes((&[0x99u8; 32][..]).into()).unwrap(); + let verifying_key = K256VerifyingKey::from(&other_key); + let claims = make_delegation_claims(); + + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + let result = verify_delegation_token(&token, &verifying_key); assert!(result.is_err()); } #[test] - fn grant_rejects_expired() { - let secret = test_secret(); + fn delegation_rejects_expired() { + let signing_key = make_k256_signing_key(); + let verifying_key = K256VerifyingKey::from(&signing_key); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let claims = MemberGrantClaims { - sub: "did:plc:member".into(), - space: "ats://did:plc:space/com.example.forum/main".into(), - scope: "read".into(), - iat: now - 600, - exp: now - 300, + let claims = DelegationTokenClaims { + iss: "did:plc:member".into(), + sub: "ats://did:plc:space/com.example.forum/main".into(), + aud: "did:plc:space#atproto_space_host".into(), + iat: now - 120, + exp: now - 60, + jti: make_jti(), }; - let token = sign_grant(&claims, &secret).unwrap(); - let result = verify_grant(&token, &secret); + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + let result = verify_delegation_token(&token, &verifying_key); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("expired")); } + #[test] + fn delegation_rejects_wrong_typ() { + let signing_key = make_k256_signing_key(); + let verifying_key = K256VerifyingKey::from(&signing_key); + let claims = make_delegation_claims(); + + // Craft a token with wrong typ + let header = serde_json::json!({ "alg": "ES256K", "typ": "wrong-typ", "kid": "#atproto" }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + let sig: K256Signature = signing_key.sign(message.as_bytes()); + let token = format!( + "{}.{}.{}", + header_b64, + payload_b64, + URL_SAFE_NO_PAD.encode(sig.to_bytes()) + ); + + let result = verify_delegation_token(&token, &verifying_key); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("typ")); + } + #[test] fn credential_has_space_credential_typ() { let keypair = generate_dpop_keypair().unwrap(); let claims = make_claims(); let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); - assert_eq!(peek_jwt_typ(&token).as_deref(), Some("space_credential")); + assert_eq!(peek_jwt_typ(&token).as_deref(), Some(SPACE_CREDENTIAL_TYP)); + } + + #[test] + fn delegation_has_delegation_typ() { + let signing_key = make_k256_signing_key(); + let claims = make_delegation_claims(); + let token = sign_delegation_token(&claims, &signing_key).unwrap(); + assert_eq!(peek_jwt_typ(&token).as_deref(), Some(DELEGATION_TOKEN_TYP)); } #[test] @@ -428,13 +538,13 @@ mod tests { } #[test] - fn peek_credential_sub_extracts_did() { + fn peek_credential_sub_extracts_space_uri() { let keypair = generate_dpop_keypair().unwrap(); let claims = make_claims(); let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); assert_eq!( peek_credential_sub(&token).as_deref(), - Some("did:plc:requester") + Some("ats://did:plc:spaceowner/com.example.forum/main") ); } @@ -469,4 +579,21 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("typ")); } + + #[test] + fn multikey_to_p256_jwk_invalid_multibase() { + let result = multikey_to_p256_jwk("xabc123"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("multibase")); + } + + #[test] + fn multikey_to_p256_jwk_wrong_codec() { + let mut bytes = vec![0x99u8, 0x99]; + bytes.extend_from_slice(&[0u8; 33]); + let encoded = multibase::encode(multibase::Base::Base58Btc, &bytes); + let result = multikey_to_p256_jwk(&encoded); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("P-256")); + } } diff --git a/src/spaces/db.rs b/src/spaces/db.rs index 4f5994c..baf4ff4 100644 --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -14,31 +14,25 @@ pub async fn create_space( let now = now_rfc3339(); let config_json = serde_json::to_string(&space.config) .map_err(|e| AppError::Internal(format!("failed to serialize space config: {e}")))?; - let allowlist_json = space - .app_allowlist - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()); - let denylist_json = space - .app_denylist - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()); + let app_access_json = serde_json::to_string(&space.app_access) + .map_err(|e| AppError::Internal(format!("failed to serialize app_access: {e}")))?; let sql = adapt_sql( - "INSERT INTO happyview_spaces (id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO happyview_spaces (id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", backend, ); sqlx::query(&sql) .bind(&space.id) .bind(&space.did) - .bind(&space.owner_did) + .bind(&space.authority_did) + .bind(&space.creator_did) .bind(&space.type_nsid) .bind(&space.skey) .bind(&space.display_name) .bind(&space.description) - .bind(space.access_mode.as_str()) - .bind(&allowlist_json) - .bind(&denylist_json) + .bind(space.mint_policy.as_str()) + .bind(&app_access_json) .bind(&space.managing_app_did) .bind(&config_json) .bind(&now) @@ -56,7 +50,7 @@ pub async fn get_space( id: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE id = ?", + "SELECT id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE id = ?", backend, ); @@ -77,7 +71,7 @@ pub async fn get_space_by_address( skey: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE did = ? AND type_nsid = ? AND skey = ?", + "SELECT id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE did = ? AND type_nsid = ? AND skey = ?", backend, ); @@ -95,15 +89,15 @@ pub async fn get_space_by_address( pub async fn list_spaces_by_owner( pool: &sqlx::AnyPool, backend: DatabaseBackend, - owner_did: &str, + authority_did: &str, ) -> Result, AppError> { let sql = adapt_sql( - "SELECT id, did, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE owner_did = ? ORDER BY created_at DESC", + "SELECT id, did, authority_did, creator_did, type_nsid, skey, display_name, description, mint_policy, app_access, managing_app_did, config, revision, created_at, updated_at FROM happyview_spaces WHERE authority_did = ? ORDER BY created_at DESC", backend, ); let rows: Vec = sqlx::query_as(&sql) - .bind(owner_did) + .bind(authority_did) .fetch_all(pool) .await .map_err(|e| AppError::Internal(format!("failed to list spaces: {e}")))?; @@ -128,12 +122,12 @@ pub async fn list_spaces_for_user( let sql = if decoded_cursor.is_some() { adapt_sql( - "SELECT s.did, s.owner_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 ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) > ?)) ORDER BY sm.created_at ASC, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", backend, ) } else { adapt_sql( - "SELECT s.did, s.owner_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, ('ats://' || s.did || '/' || s.type_nsid || '/' || s.skey) ASC LIMIT ?", backend, ) }; @@ -152,9 +146,9 @@ pub async fn list_spaces_for_user( let views: Vec = rows .into_iter() .map( - |(space_did, owner_did, type_nsid, skey, created_at)| SpaceView { + |(space_did, authority_did, type_nsid, skey, created_at)| SpaceView { uri: format!("ats://{}/{}/{}", space_did, type_nsid, skey), - is_owner: owner_did == did, + is_owner: authority_did == did, created_at, }, ) @@ -177,26 +171,19 @@ pub async fn update_space( let now = now_rfc3339(); let config_json = serde_json::to_string(&space.config) .map_err(|e| AppError::Internal(format!("failed to serialize space config: {e}")))?; - let allowlist_json = space - .app_allowlist - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()); - let denylist_json = space - .app_denylist - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()); + let app_access_json = serde_json::to_string(&space.app_access) + .map_err(|e| AppError::Internal(format!("failed to serialize app_access: {e}")))?; let sql = adapt_sql( - "UPDATE happyview_spaces SET display_name = ?, description = ?, access_mode = ?, app_allowlist = ?, app_denylist = ?, managing_app_did = ?, config = ?, updated_at = ? WHERE id = ?", + "UPDATE happyview_spaces SET display_name = ?, description = ?, mint_policy = ?, app_access = ?, managing_app_did = ?, config = ?, updated_at = ? WHERE id = ?", backend, ); let result = sqlx::query(&sql) .bind(&space.display_name) .bind(&space.description) - .bind(space.access_mode.as_str()) - .bind(&allowlist_json) - .bind(&denylist_json) + .bind(space.mint_policy.as_str()) + .bind(&app_access_json) .bind(&space.managing_app_did) .bind(&config_json) .bind(&now) @@ -230,11 +217,11 @@ type SpaceRow = ( String, String, String, - Option, - Option, String, Option, Option, + String, + String, Option, String, Option, @@ -243,32 +230,24 @@ type SpaceRow = ( ); fn parse_space_row(r: SpaceRow) -> Result { - let access_mode = AccessMode::parse(&r.7) - .ok_or_else(|| AppError::Internal(format!("invalid access_mode: {}", r.7)))?; - let app_allowlist: Option> = - r.8.as_deref() - .map(serde_json::from_str) - .transpose() - .map_err(|e| AppError::Internal(format!("invalid app_allowlist: {e}")))?; - let app_denylist: Option> = - r.9.as_deref() - .map(serde_json::from_str) - .transpose() - .map_err(|e| AppError::Internal(format!("invalid app_denylist: {e}")))?; + let mint_policy = MintPolicy::parse(&r.8) + .ok_or_else(|| AppError::Internal(format!("invalid mint_policy: {}", r.8)))?; + let app_access: AppAccess = serde_json::from_str(&r.9) + .map_err(|e| AppError::Internal(format!("invalid app_access: {e}")))?; let config: SpaceConfig = serde_json::from_str(&r.11) .map_err(|e| AppError::Internal(format!("invalid space config: {e}")))?; Ok(Space { id: r.0, did: r.1, - owner_did: r.2, - type_nsid: r.3, - skey: r.4, - display_name: r.5, - description: r.6, - access_mode, - app_allowlist, - app_denylist, + authority_did: r.2, + creator_did: r.3, + type_nsid: r.4, + skey: r.5, + display_name: r.6, + description: r.7, + mint_policy, + app_access, managing_app_did: r.10, config, revision: r.12, @@ -691,6 +670,220 @@ pub async fn update_space_revision( Ok(()) } +// --------------------------------------------------------------------------- +// Repo State +// --------------------------------------------------------------------------- + +pub async fn get_or_create_repo_state( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: &str, +) -> Result { + let sql = adapt_sql( + "SELECT id, space_id, author_did, lthash_state, rev, hash, ikm, sig, mac, updated_at FROM happyview_space_repo_state WHERE space_id = ? AND author_did = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(space_id) + .bind(author_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get repo state: {e}")))?; + + if let Some(r) = row { + return parse_repo_state_row(r); + } + + let id = uuid::Uuid::new_v4().to_string(); + let now = now_rfc3339(); + let default_lthash = vec![0u8; 2048]; + let insert_sql = adapt_sql( + "INSERT INTO happyview_space_repo_state (id, space_id, author_did, lthash_state, rev, hash, ikm, sig, mac, updated_at) VALUES (?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?)", + backend, + ); + sqlx::query(&insert_sql) + .bind(&id) + .bind(space_id) + .bind(author_did) + .bind(&default_lthash) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to create repo state: {e}")))?; + + Ok(RepoState { + id, + space_id: space_id.to_string(), + author_did: author_did.to_string(), + lthash_state: default_lthash, + rev: None, + hash: None, + ikm: None, + sig: None, + mac: None, + updated_at: now, + }) +} + +pub async fn update_repo_state( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + state: &RepoState, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "UPDATE happyview_space_repo_state SET lthash_state = ?, rev = ?, hash = ?, ikm = ?, sig = ?, mac = ?, updated_at = ? WHERE id = ?", + backend, + ); + + sqlx::query(&sql) + .bind(&state.lthash_state) + .bind(&state.rev) + .bind(&state.hash) + .bind(&state.ikm) + .bind(&state.sig) + .bind(&state.mac) + .bind(&now) + .bind(&state.id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to update repo state: {e}")))?; + + Ok(()) +} + +type RepoStateRow = ( + String, + String, + String, + Vec, + Option, + Option>, + Option>, + Option>, + Option>, + String, +); + +fn parse_repo_state_row(r: RepoStateRow) -> Result { + Ok(RepoState { + id: r.0, + space_id: r.1, + author_did: r.2, + lthash_state: r.3, + rev: r.4, + hash: r.5, + ikm: r.6, + sig: r.7, + mac: r.8, + updated_at: r.9, + }) +} + +// --------------------------------------------------------------------------- +// Notification Registrations +// --------------------------------------------------------------------------- + +pub async fn register_notify( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + reg: &NotifyRegistration, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO happyview_space_notify_registrations (id, space_id, author_did, endpoint, registered_by, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(®.id) + .bind(®.space_id) + .bind(®.author_did) + .bind(®.endpoint) + .bind(®.registered_by) + .bind(®.expires_at) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to register notify: {e}")))?; + + Ok(()) +} + +pub async fn list_notify_registrations( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + author_did: Option<&str>, +) -> Result, AppError> { + let sql = if author_did.is_some() { + adapt_sql( + "SELECT id, space_id, author_did, endpoint, registered_by, expires_at, created_at FROM happyview_space_notify_registrations WHERE space_id = ? AND author_did = ? ORDER BY created_at ASC", + backend, + ) + } else { + adapt_sql( + "SELECT id, space_id, author_did, endpoint, registered_by, expires_at, created_at FROM happyview_space_notify_registrations WHERE space_id = ? ORDER BY created_at ASC", + backend, + ) + }; + + let mut query = sqlx::query_as::<_, NotifyRow>(&sql).bind(space_id); + if let Some(did) = author_did { + query = query.bind(did); + } + + let rows = query + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list notify registrations: {e}")))?; + + Ok(rows.into_iter().map(parse_notify_row).collect()) +} + +pub async fn delete_notify_registration( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + id: &str, +) -> Result { + let sql = adapt_sql( + "DELETE FROM happyview_space_notify_registrations WHERE id = ?", + backend, + ); + + let result = sqlx::query(&sql) + .bind(id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete notify registration: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +type NotifyRow = ( + String, + String, + Option, + String, + String, + String, + String, +); + +fn parse_notify_row(r: NotifyRow) -> NotifyRegistration { + NotifyRegistration { + id: r.0, + space_id: r.1, + author_did: r.2, + endpoint: r.3, + registered_by: r.4, + expires_at: r.5, + created_at: r.6, + } +} + type RecordRow = ( String, String, @@ -718,6 +911,55 @@ fn parse_record_row(r: RecordRow) -> Result { }) } +/// Find the author DID of any record in the space that contains a blob ref +/// with the given CID. The CID appears in serialised record JSON as the +/// `$link` value inside an ATProto blob ref object. +pub async fn find_blob_author_did( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + blob_cid: &str, +) -> Result, AppError> { + let pattern = format!("%\"$link\":\"{blob_cid}\"%"); + let sql = adapt_sql( + "SELECT author_did FROM happyview_space_records WHERE space_id = ? AND record LIKE ? LIMIT 1", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(space_id) + .bind(&pattern) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to find blob author: {e}")))?; + Ok(row.map(|(did,)| did)) +} + +// --------------------------------------------------------------------------- +// Space Repos +// --------------------------------------------------------------------------- + +pub async fn list_space_repos( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT DISTINCT r.author_did, s.rev FROM happyview_space_records r LEFT JOIN happyview_space_repo_state s ON s.space_id = r.space_id AND s.author_did = r.author_did WHERE r.space_id = ? ORDER BY r.author_did ASC", + backend, + ); + + let rows: Vec<(String, Option)> = sqlx::query_as(&sql) + .bind(space_id) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list space repos: {e}")))?; + + Ok(rows + .into_iter() + .map(|(did, rev)| serde_json::json!({ "did": did, "rev": rev })) + .collect()) +} + // --------------------------------------------------------------------------- // Space Invites // --------------------------------------------------------------------------- diff --git a/src/spaces/integration_tests.rs b/src/spaces/integration_tests.rs new file mode 100644 index 0000000..03dcc24 --- /dev/null +++ b/src/spaces/integration_tests.rs @@ -0,0 +1,575 @@ +/// Cross-module integration tests for the spaces subsystem. +/// +/// These run with `cargo test --lib` — no database required. +#[cfg(test)] +mod tests { + // ----------------------------------------------------------------------- + // 1. LtHash + commit integration + // ----------------------------------------------------------------------- + + use crate::spaces::commit::{sign_commit, verify_commit}; + use crate::spaces::lthash::{LtHashState, record_element}; + use k256::ecdsa::SigningKey; + + fn test_signing_key() -> SigningKey { + let mut bytes = [0u8; 32]; + bytes[31] = 1; + SigningKey::from_bytes((&bytes[..]).into()).unwrap() + } + + /// Add two records, generate a commit over the hash, verify it. + #[test] + fn lthash_commit_roundtrip() { + let mut state = LtHashState::new(); + let elem_a = record_element("com.example.forum.post", "aaa", "bafyreiaaa"); + let elem_b = record_element("com.example.forum.post", "bbb", "bafyreibbb"); + + state.add(&elem_a); + let hash_after_a = state.hash(); + assert_ne!( + hash_after_a, + LtHashState::new().hash(), + "hash must change after first add" + ); + + state.add(&elem_b); + let hash_after_ab = state.hash(); + assert_ne!( + hash_after_ab, hash_after_a, + "hash must change after second add" + ); + + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let space_uri = "ats://did:plc:abc/com.example.forum/main"; + let rev = "3k2rev1"; + + let commit = sign_commit(&hash_after_ab, space_uri, rev, &sk).unwrap(); + assert_eq!(commit.hash, hash_after_ab); + assert_eq!(commit.rev, rev); + assert!(verify_commit(&commit, space_uri, &vk).is_ok()); + } + + /// Remove a record — hash must change back toward the previous state. + #[test] + fn lthash_commit_after_delete() { + let mut state = LtHashState::new(); + let elem_a = record_element("com.example.forum.post", "aaa", "bafyreiaaa"); + let elem_b = record_element("com.example.forum.post", "bbb", "bafyreibbb"); + + state.add(&elem_a); + state.add(&elem_b); + let hash_two = state.hash(); + + state.remove(&elem_b); + let hash_one = state.hash(); + assert_ne!(hash_one, hash_two, "hash must change after delete"); + + // The remaining state should equal a state built with only elem_a + let mut expected = LtHashState::new(); + expected.add(&elem_a); + assert_eq!( + hash_one, + expected.hash(), + "hash after delete must match single-record state" + ); + + 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()); + } + + /// Commit signed for one hash must not verify against a different hash. + #[test] + fn commit_does_not_verify_for_different_hash() { + let mut state_a = LtHashState::new(); + state_a.add(&record_element("col", "key1", "cid1")); + let hash_a = state_a.hash(); + + let mut state_b = LtHashState::new(); + state_b.add(&record_element("col", "key2", "cid2")); + let hash_b = state_b.hash(); + + let sk = test_signing_key(); + let vk = *sk.verifying_key(); + let space_uri = "ats://did:plc:abc/com.example.forum/main"; + + let commit_a = sign_commit(&hash_a, space_uri, "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()); + } + + // ----------------------------------------------------------------------- + // 2. Credential flow cross-module + // ----------------------------------------------------------------------- + + use crate::oauth::keys::generate_dpop_keypair; + use crate::spaces::credential::{ + DEFAULT_CREDENTIAL_TTL_SECS, DELEGATION_TOKEN_TTL_SECS, DELEGATION_TOKEN_TYP, + DelegationTokenClaims, SPACE_CREDENTIAL_TYP, SpaceCredentialClaims, make_jti, + peek_credential_sub, peek_jwt_typ, sign_credential, sign_delegation_token, + verify_credential, verify_delegation_token, + }; + use k256::ecdsa::{SigningKey as K256SigningKey, VerifyingKey as K256VerifyingKey}; + + fn k256_key() -> K256SigningKey { + K256SigningKey::from_bytes((&[0x42u8; 32][..]).into()).unwrap() + } + + fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + } + + /// Full delegation → space credential flow: sign delegation token, then sign space credential + /// and verify both in sequence. + #[test] + fn delegation_then_credential_flow() { + let now = now_secs(); + let sk = k256_key(); + let vk = K256VerifyingKey::from(&sk); + + // Step 1: member signs delegation token + let delegation = DelegationTokenClaims { + iss: "did:plc:member".into(), + sub: "ats://did:plc:space/com.example.forum/main".into(), + aud: "did:plc:space#atproto_space_host".into(), + iat: now, + exp: now + DELEGATION_TOKEN_TTL_SECS, + jti: make_jti(), + }; + let token = sign_delegation_token(&delegation, &sk).unwrap(); + + // Peek must return the correct typ before verification + assert_eq!(peek_jwt_typ(&token).as_deref(), Some(DELEGATION_TOKEN_TYP)); + + // Step 2: space host verifies delegation token + let verified_delegation = verify_delegation_token(&token, &vk).unwrap(); + assert_eq!(verified_delegation.iss, "did:plc:member"); + assert_eq!( + verified_delegation.sub, + "ats://did:plc:space/com.example.forum/main" + ); + + // Step 3: space host issues a space credential (using P-256 key) + let keypair = generate_dpop_keypair().unwrap(); + let cred_claims = SpaceCredentialClaims { + iss: "did:plc:space".into(), + sub: verified_delegation.sub.clone(), + iat: now, + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + jti: make_jti(), + }; + let credential = sign_credential(&cred_claims, &keypair.private_jwk).unwrap(); + + // Peek on credential + assert_eq!( + peek_jwt_typ(&credential).as_deref(), + Some(SPACE_CREDENTIAL_TYP) + ); + assert_eq!( + peek_credential_sub(&credential).as_deref(), + Some("ats://did:plc:space/com.example.forum/main") + ); + + // Step 4: verify credential + let verified_cred = verify_credential(&credential, &keypair.public_jwk).unwrap(); + assert_eq!(verified_cred.iss, "did:plc:space"); + assert_eq!( + verified_cred.sub, + "ats://did:plc:space/com.example.forum/main" + ); + } + + /// An expired delegation token must be rejected before we even try to issue a credential. + #[test] + fn expired_delegation_blocks_credential_flow() { + let now = now_secs(); + let sk = k256_key(); + let vk = K256VerifyingKey::from(&sk); + + let delegation = DelegationTokenClaims { + iss: "did:plc:member".into(), + sub: "ats://did:plc:space/com.example.forum/main".into(), + aud: "did:plc:space#atproto_space_host".into(), + iat: now - 120, + exp: now - 60, // already expired + jti: make_jti(), + }; + let token = sign_delegation_token(&delegation, &sk).unwrap(); + let result = verify_delegation_token(&token, &vk); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("expired")); + } + + // ----------------------------------------------------------------------- + // 3. Oplog types: serialization roundtrips + // ----------------------------------------------------------------------- + + use crate::spaces::types::{OplogAction, OplogEntry}; + + #[test] + fn oplog_action_serde_roundtrip() { + for action in [ + OplogAction::Create, + OplogAction::Update, + OplogAction::Delete, + ] { + let json = serde_json::to_string(&action).unwrap(); + let parsed: OplogAction = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, action); + } + } + + #[test] + fn oplog_entry_serialization() { + let entry = OplogEntry { + id: "entry-1".into(), + space_id: "space-abc".into(), + author_did: "did:plc:author".into(), + rev: "3k2rev1".into(), + idx: 0, + action: OplogAction::Create, + collection: "com.example.forum.post".into(), + rkey: "3k2abc".into(), + cid: Some("bafyreiabc".into()), + prev: None, + created_at: "2026-01-01T00:00:00Z".into(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let parsed: OplogEntry = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.id, entry.id); + assert_eq!(parsed.space_id, entry.space_id); + assert_eq!(parsed.action.as_str(), "create"); + assert_eq!(parsed.cid.as_deref(), Some("bafyreiabc")); + assert!(parsed.prev.is_none()); + } + + #[test] + fn oplog_entry_delete_has_no_cid() { + let entry = OplogEntry { + id: "entry-2".into(), + space_id: "space-abc".into(), + author_did: "did:plc:author".into(), + rev: "3k2rev2".into(), + idx: 0, + action: OplogAction::Delete, + collection: "com.example.forum.post".into(), + rkey: "3k2abc".into(), + cid: None, + prev: Some("bafyreiabc".into()), + created_at: "2026-01-01T00:00:01Z".into(), + }; + + let json = serde_json::to_string(&entry).unwrap(); + let parsed: OplogEntry = serde_json::from_str(&json).unwrap(); + assert!(parsed.cid.is_none()); + assert_eq!(parsed.prev.as_deref(), Some("bafyreiabc")); + } + + // ----------------------------------------------------------------------- + // 4. Simplespace config types + // ----------------------------------------------------------------------- + + use crate::spaces::types::{AppAccess, MintPolicy, SpaceConfig}; + + #[test] + fn mint_policy_serde_roundtrip() { + let cases = [ + (MintPolicy::MemberList, "\"member-list\""), + (MintPolicy::Public, "\"public\""), + (MintPolicy::ManagingApp, "\"managing-app\""), + ]; + for (policy, expected_json) in cases { + let json = serde_json::to_string(&policy).unwrap(); + assert_eq!(json, expected_json); + let parsed: MintPolicy = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, policy); + } + } + + #[test] + fn app_access_open_default() { + let access = AppAccess::default(); + assert!(matches!(access, AppAccess::Open)); + let json = serde_json::to_string(&access).unwrap(); + assert_eq!(json, r#"{"type":"open"}"#); + } + + #[test] + fn app_access_allowlist_roundtrip() { + let access = AppAccess::AllowList { + allowed: vec!["https://app.example.com/client-metadata.json".into()], + }; + let json = serde_json::to_string(&access).unwrap(); + let parsed: AppAccess = serde_json::from_str(&json).unwrap(); + match parsed { + AppAccess::AllowList { allowed } => { + assert_eq!(allowed.len(), 1); + assert_eq!(allowed[0], "https://app.example.com/client-metadata.json"); + } + _ => panic!("expected AllowList"), + } + } + + #[test] + fn space_config_defaults_false() { + let config: SpaceConfig = serde_json::from_str("{}").unwrap(); + assert!(!config.membership_public); + assert!(!config.records_public); + assert!(config.extra.is_empty()); + } + + #[test] + fn space_config_preserves_extra_fields() { + let json = r#"{"membership_public":true,"records_public":false,"allowedCollections":["col.a","col.b"]}"#; + let config: SpaceConfig = serde_json::from_str(json).unwrap(); + assert!(config.membership_public); + assert!(!config.records_public); + let collections = config.extra.get("allowedCollections").unwrap(); + assert_eq!(collections.as_array().unwrap().len(), 2); + } + + // ----------------------------------------------------------------------- + // 5. Backward-compatible route namespace constants + // + // The PROTO_NS and LEGACY_NS constants are private to routes.rs. + // We verify the expected values here as a named constant in test scope + // so the intent is documented and any refactor that changes the strings + // will need to update these tests. + // ----------------------------------------------------------------------- + + /// The AT Protocol namespace used for canonical space routes. + const EXPECTED_PROTO_NS: &str = "com.atproto"; + + /// The HappyView legacy namespace kept for backward compatibility. + const EXPECTED_LEGACY_NS: &str = "dev.happyview"; + + #[test] + fn proto_ns_value_is_com_atproto() { + // getDelegationToken is on the proto namespace; getMemberGrant is the legacy alias + let proto_route = format!("/xrpc/{}.space.getDelegationToken", EXPECTED_PROTO_NS); + assert_eq!(proto_route, "/xrpc/com.atproto.space.getDelegationToken"); + } + + #[test] + fn legacy_ns_value_is_dev_happyview() { + // getMemberGrant is the legacy alias for getDelegationToken + let legacy_route = format!("/xrpc/{}.space.getMemberGrant", EXPECTED_LEGACY_NS); + assert_eq!(legacy_route, "/xrpc/dev.happyview.space.getMemberGrant"); + } + + #[test] + fn create_space_legacy_maps_to_simplespace() { + // dev.happyview.space.createSpace is the legacy alias for com.atproto.simplespace.createSpace + let legacy = format!("/xrpc/{}.space.createSpace", EXPECTED_LEGACY_NS); + let canonical = format!("/xrpc/{}.simplespace.createSpace", EXPECTED_PROTO_NS); + // Both paths must be distinct strings that map to the same handler + assert_ne!(legacy, canonical); + assert_eq!(legacy, "/xrpc/dev.happyview.space.createSpace"); + assert_eq!(canonical, "/xrpc/com.atproto.simplespace.createSpace"); + } + + // ----------------------------------------------------------------------- + // 6. Read scope validation — cross-module + // ----------------------------------------------------------------------- + + use crate::spaces::scope::{SpaceReadAccess, check_delegation_token_access, check_read_access}; + use crate::spaces::types::SpaceAccess; + + /// read_self member reads own record → ok + #[test] + fn read_self_member_reads_own_record_ok() { + let result = check_read_access( + "did:plc:alice", + "did:plc:alice", + SpaceReadAccess::ReadSelf, + false, + ); + assert!(result.is_ok()); + } + + /// read_self member reads other's record → error + #[test] + fn read_self_member_reads_others_record_err() { + let result = check_read_access( + "did:plc:alice", + "did:plc:bob", + SpaceReadAccess::ReadSelf, + false, + ); + assert!(result.is_err()); + } + + /// read_self member tries getDelegationToken → error + #[test] + fn read_self_member_cannot_get_delegation_token() { + let result = check_delegation_token_access(SpaceReadAccess::ReadSelf, false); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("delegation")); + } + + /// Full read member: can read own record + #[test] + fn full_read_member_reads_own_record_ok() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:alice", + SpaceReadAccess::Read, + false + ) + .is_ok() + ); + } + + /// Full read member: can read other's record + #[test] + fn full_read_member_reads_others_record_ok() { + assert!( + check_read_access("did:plc:alice", "did:plc:bob", SpaceReadAccess::Read, false).is_ok() + ); + } + + /// Full read member: can get delegation token + #[test] + fn full_read_member_can_get_delegation_token() { + assert!(check_delegation_token_access(SpaceReadAccess::Read, false).is_ok()); + } + + /// space_credential bypasses read_self restriction on reads + #[test] + fn space_credential_bypasses_read_self_on_read() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:bob", + SpaceReadAccess::ReadSelf, + true + ) + .is_ok() + ); + } + + /// space_credential bypasses read_self restriction on delegation token + #[test] + fn space_credential_bypasses_read_self_on_delegation() { + assert!(check_delegation_token_access(SpaceReadAccess::ReadSelf, true).is_ok()); + } + + /// SpaceReadAccess::from_space_access maps access levels correctly + #[test] + fn space_access_to_read_access_mapping() { + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::ReadSelf), + SpaceReadAccess::ReadSelf + ); + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::Read), + SpaceReadAccess::Read + ); + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::Write), + SpaceReadAccess::Read + ); + } + + // ----------------------------------------------------------------------- + // 7. Blob sync query params + // ----------------------------------------------------------------------- + + /// GetSpaceBlobQuery is private to routes.rs; test the equivalent deserialization shape. + #[test] + fn blob_query_params_camel_case() { + // The route accepts ?space=...&cid=... in camelCase — verify serde_json can + // round-trip the equivalent shape used in routes.rs. + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct BlobQuery { + space: String, + cid: String, + } + + let qs = serde_json::json!({ + "space": "ats://did:plc:abc/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.cid, "bafyreiabc123"); + } + + // ----------------------------------------------------------------------- + // 8. Verification methods — key generation and roundtrip + // ----------------------------------------------------------------------- + + use crate::spaces::credential::p256_jwk_to_verifying_key; + + #[test] + fn p256_keypair_generation_and_jwk_roundtrip() { + let keypair = generate_dpop_keypair().unwrap(); + + // Public JWK must have kty, crv, x, y + assert_eq!(keypair.public_jwk["kty"].as_str(), Some("EC")); + assert_eq!(keypair.public_jwk["crv"].as_str(), Some("P-256")); + assert!(keypair.public_jwk["x"].as_str().is_some()); + assert!(keypair.public_jwk["y"].as_str().is_some()); + + // Private JWK must have d + assert!(keypair.private_jwk["d"].as_str().is_some()); + + // Can reconstruct verifying key from public JWK + let vk = p256_jwk_to_verifying_key(&keypair.public_jwk).unwrap(); + let point = vk.to_encoded_point(false); + assert!(point.x().is_some()); + assert!(point.y().is_some()); + } + + #[test] + fn sign_and_verify_with_generated_keypair() { + let keypair = generate_dpop_keypair().unwrap(); + let now = now_secs(); + + let claims = SpaceCredentialClaims { + iss: "did:plc:owner".into(), + sub: "ats://did:plc:owner/com.example.forum/main".into(), + iat: now, + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + jti: make_jti(), + }; + + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); + let verified = verify_credential(&token, &keypair.public_jwk).unwrap(); + + assert_eq!(verified.iss, claims.iss); + assert_eq!(verified.sub, claims.sub); + assert_eq!(verified.jti, claims.jti); + } + + #[test] + fn two_different_keypairs_do_not_cross_verify() { + let kp1 = generate_dpop_keypair().unwrap(); + let kp2 = generate_dpop_keypair().unwrap(); + let now = now_secs(); + + let claims = SpaceCredentialClaims { + iss: "did:plc:owner".into(), + sub: "ats://did:plc:owner/com.example.forum/main".into(), + iat: now, + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + jti: make_jti(), + }; + + let token = sign_credential(&claims, &kp1.private_jwk).unwrap(); + let result = verify_credential(&token, &kp2.public_jwk); + assert!(result.is_err()); + } +} diff --git a/src/spaces/lthash.rs b/src/spaces/lthash.rs new file mode 100644 index 0000000..a8aefc6 --- /dev/null +++ b/src/spaces/lthash.rs @@ -0,0 +1,180 @@ +use blake3::Hasher as Blake3Hasher; +use sha2::{Digest, Sha256}; + +const NUM_LANES: usize = 1024; +const STATE_BYTES: usize = NUM_LANES * 2; // 2048 + +pub struct LtHashState { + lanes: [u16; NUM_LANES], +} + +impl Default for LtHashState { + fn default() -> Self { + Self::new() + } +} + +impl LtHashState { + pub fn new() -> Self { + LtHashState { + lanes: [0u16; NUM_LANES], + } + } + + pub fn add(&mut self, element: &[u8]) { + let expanded = expand_element(element); + for (i, val) in expanded.iter().enumerate().take(NUM_LANES) { + self.lanes[i] = self.lanes[i].wrapping_add(*val); + } + } + + pub fn remove(&mut self, element: &[u8]) { + let expanded = expand_element(element); + for (i, val) in expanded.iter().enumerate().take(NUM_LANES) { + self.lanes[i] = self.lanes[i].wrapping_sub(*val); + } + } + + pub fn hash(&self) -> [u8; 32] { + Sha256::digest(self.as_bytes()).into() + } + + pub fn as_bytes(&self) -> [u8; STATE_BYTES] { + let mut bytes = [0u8; STATE_BYTES]; + for i in 0..NUM_LANES { + let le = self.lanes[i].to_le_bytes(); + bytes[i * 2] = le[0]; + bytes[i * 2 + 1] = le[1]; + } + bytes + } + + pub fn from_bytes(bytes: [u8; STATE_BYTES]) -> Self { + let mut lanes = [0u16; NUM_LANES]; + for i in 0..NUM_LANES { + lanes[i] = u16::from_le_bytes([bytes[i * 2], bytes[i * 2 + 1]]); + } + LtHashState { lanes } + } +} + +fn expand_element(element: &[u8]) -> [u16; NUM_LANES] { + let mut hasher = Blake3Hasher::new(); + hasher.update(element); + let mut xof = hasher.finalize_xof(); + let mut buf = [0u8; STATE_BYTES]; + xof.fill(&mut buf); + + let mut lanes = [0u16; NUM_LANES]; + for i in 0..NUM_LANES { + lanes[i] = u16::from_le_bytes([buf[i * 2], buf[i * 2 + 1]]); + } + lanes +} + +pub fn record_element(collection: &str, rkey: &str, cid: &str) -> Vec { + format!("{collection}/{rkey}/{cid}").into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_state_is_all_zeroes() { + let state = LtHashState::new(); + assert_eq!(state.as_bytes(), [0u8; 2048]); + let expected: [u8; 32] = sha2::Sha256::digest([0u8; 2048]).into(); + assert_eq!(state.hash(), expected); + } + + #[test] + fn add_then_remove_returns_to_empty() { + let mut state = LtHashState::new(); + let element = record_element("com.example.post", "3k2abc", "bafyreiabc123"); + state.add(&element); + assert_ne!(state.as_bytes(), [0u8; 2048]); + state.remove(&element); + assert_eq!(state.as_bytes(), [0u8; 2048]); + } + + #[test] + fn order_independent() { + let elem_a = record_element("com.example.post", "aaa", "bafyreiaaa"); + let elem_b = record_element("com.example.post", "bbb", "bafyreibbb"); + + let mut state1 = LtHashState::new(); + state1.add(&elem_a); + state1.add(&elem_b); + + let mut state2 = LtHashState::new(); + state2.add(&elem_b); + state2.add(&elem_a); + + assert_eq!(state1.hash(), state2.hash()); + assert_eq!(state1.as_bytes(), state2.as_bytes()); + } + + #[test] + fn different_records_different_hashes() { + let elem_a = record_element("com.example.post", "aaa", "bafyreiaaa"); + let elem_b = record_element("com.example.post", "bbb", "bafyreibbb"); + + let mut state_a = LtHashState::new(); + state_a.add(&elem_a); + + let mut state_b = LtHashState::new(); + state_b.add(&elem_b); + + assert_ne!(state_a.hash(), state_b.hash()); + } + + #[test] + fn record_element_format() { + let elem = record_element("com.example.post", "3k2abc", "bafyreiabc"); + assert_eq!(elem, b"com.example.post/3k2abc/bafyreiabc"); + } + + #[test] + fn from_bytes_roundtrip() { + let mut state = LtHashState::new(); + let elem = record_element("com.example.post", "3k2abc", "bafyreiabc"); + state.add(&elem); + let bytes = state.as_bytes(); + let restored = LtHashState::from_bytes(bytes); + assert_eq!(state.hash(), restored.hash()); + } + + #[test] + fn wrapping_arithmetic() { + let mut state = LtHashState::new(); + let elem = record_element("test", "key", "cid"); + // Adding the same element 65536 times should wrap back to zero + for _ in 0..65536 { + state.add(&elem); + } + assert_eq!(state.as_bytes(), [0u8; 2048]); + } + + #[test] + fn remove_standalone() { + let mut state = LtHashState::new(); + let elem = record_element("app.bsky.feed.post", "abc123", "bafydata"); + state.add(&elem); + assert_ne!(state.as_bytes(), LtHashState::new().as_bytes()); + state.remove(&elem); + assert_eq!(state.as_bytes(), LtHashState::new().as_bytes()); + assert_eq!(state.hash(), LtHashState::new().hash()); + } + + #[test] + fn from_bytes_roundtrip_modified() { + let mut state = LtHashState::new(); + state.add(&record_element("com.example.post", "rk1", "bafyabc")); + let original_hash = state.hash(); + let mut bytes = state.as_bytes(); + bytes[1024] ^= 0xFF; + let tampered = LtHashState::from_bytes(bytes); + assert_ne!(tampered.hash(), original_hash); + } +} diff --git a/src/spaces/mod.rs b/src/spaces/mod.rs index 8dc3c7d..c7b5c11 100644 --- a/src/spaces/mod.rs +++ b/src/spaces/mod.rs @@ -1,10 +1,20 @@ pub mod auth; +pub mod client_attestation; +pub mod commit; pub mod credential; pub mod db; +pub mod lthash; pub mod members; +pub mod notifications; +pub mod oplog; pub mod routes; +pub mod scope; +pub mod simplespace; pub mod types; +#[cfg(test)] +mod integration_tests; + use crate::error::AppError; use std::fmt; diff --git a/src/spaces/notifications.rs b/src/spaces/notifications.rs new file mode 100644 index 0000000..6b0bc2e --- /dev/null +++ b/src/spaces/notifications.rs @@ -0,0 +1,93 @@ +use crate::db::{DatabaseBackend, now_rfc3339}; +use crate::error::AppError; +use crate::spaces::db; +use crate::spaces::types::NotifyRegistration; +use uuid::Uuid; + +const NOTIFY_REGISTRATION_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours + +pub async fn register( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + service_did: &str, + endpoint: &str, + registered_by: &str, +) -> Result { + let id = Uuid::new_v4().to_string(); + let now = now_rfc3339(); + let expires_at = { + let expiry = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + NOTIFY_REGISTRATION_TTL_SECS; + chrono::DateTime::from_timestamp(expiry as i64, 0) + .unwrap() + .to_rfc3339() + }; + let reg = NotifyRegistration { + id: id.clone(), + space_id: space_id.to_string(), + author_did: Some(service_did.to_string()), + endpoint: endpoint.to_string(), + registered_by: registered_by.to_string(), + expires_at, + created_at: now, + }; + db::register_notify(pool, backend, ®).await?; + Ok(id) +} + +#[allow(clippy::too_many_arguments)] +pub async fn dispatch_write_notification( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + http: &reqwest::Client, + space_id: &str, + author_did: &str, + collection: &str, + rkey: &str, + cid: Option<&str>, +) -> Result<(), AppError> { + let registrations = + db::list_notify_registrations(pool, backend, space_id, Some(author_did)).await?; + // Also include space-wide registrations (no author_did filter) + let space_wide = db::list_notify_registrations(pool, backend, space_id, None).await?; + + let all: Vec<&NotifyRegistration> = registrations + .iter() + .chain(space_wide.iter().filter(|r| r.author_did.is_none())) + .collect(); + + let payload = serde_json::json!({ + "space": space_id, + "did": author_did, + "collection": collection, + "rkey": rkey, + "cid": cid, + }); + + for reg in all { + let _ = http.post(®.endpoint).json(&payload).send().await; + } + + Ok(()) +} + +pub async fn dispatch_space_deleted( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + http: &reqwest::Client, + space_id: &str, +) -> Result<(), AppError> { + let registrations = db::list_notify_registrations(pool, backend, space_id, None).await?; + + let payload = serde_json::json!({ "space": space_id }); + + for reg in ®istrations { + let _ = http.post(®.endpoint).json(&payload).send().await; + } + + Ok(()) +} diff --git a/src/spaces/oplog.rs b/src/spaces/oplog.rs new file mode 100644 index 0000000..65cd745 --- /dev/null +++ b/src/spaces/oplog.rs @@ -0,0 +1,98 @@ +use crate::db::{DatabaseBackend, adapt_sql}; +use crate::error::AppError; +use crate::spaces::types::{OplogAction, OplogEntry}; + +pub async fn append_op( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + entry: &OplogEntry, +) -> Result<(), AppError> { + let sql = adapt_sql( + "INSERT INTO happyview_space_record_oplog (id, space_id, author_did, rev, idx, action, collection, rkey, cid, prev, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + backend, + ); + sqlx::query(&sql) + .bind(&entry.id) + .bind(&entry.space_id) + .bind(&entry.author_did) + .bind(&entry.rev) + .bind(entry.idx) + .bind(entry.action.as_str()) + .bind(&entry.collection) + .bind(&entry.rkey) + .bind(&entry.cid) + .bind(&entry.prev) + .bind(&entry.created_at) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to append oplog entry: {e}")))?; + Ok(()) +} + +pub async fn list_ops( + 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 id, space_id, author_did, rev, idx, action, collection, rkey, cid, prev, created_at FROM happyview_space_record_oplog WHERE space_id = ? AND author_did = ? AND rev > ? ORDER BY rev, idx LIMIT ?", + backend, + ) + } else { + adapt_sql( + "SELECT id, space_id, author_did, rev, idx, action, collection, rkey, cid, prev, created_at FROM happyview_space_record_oplog WHERE space_id = ? AND author_did = ? ORDER BY rev, idx LIMIT ?", + backend, + ) + }; + + type OplogRow = ( + String, + String, + String, + String, + i32, + String, + String, + String, + Option, + Option, + String, + ); + + let mut query = sqlx::query_as::<_, OplogRow>(&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: {e}")))?; + + rows.into_iter() + .map(|r| { + let action = OplogAction::parse(&r.5) + .ok_or_else(|| AppError::Internal(format!("invalid oplog action: {}", r.5)))?; + 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, + created_at: r.10, + }) + }) + .collect() +} diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index 111bbb7..4b4f11f 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -1,8 +1,10 @@ use axum::extract::{Query, State}; -use axum::http::StatusCode; +use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; +use base64::Engine as _; +use k256; use serde::Deserialize; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -12,8 +14,9 @@ use crate::auth::XrpcClaims; use crate::db::{adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::lua::tid::generate_tid; +use crate::spaces::scope::{SpaceReadAccess, check_delegation_token_access, check_read_access}; use crate::spaces::types::*; -use crate::spaces::{SpaceUri, db, members}; +use crate::spaces::{SpaceUri, db, members, notifications, oplog}; // --------------------------------------------------------------------------- // Request / response types @@ -21,48 +24,62 @@ use crate::spaces::{SpaceUri, db, members}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct CreateSpaceInput { - #[serde(rename = "type")] - type_nsid: String, - skey: String, - display_name: Option, - description: Option, - access_mode: Option, - managing_app_did: Option, - config: Option, +struct RepoStateQuery { + space: String, + did: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct SpaceUriQuery { +struct ListRepoOpsQuery { space: String, + did: String, + limit: Option, + cursor: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct ListSpacesQuery { - did: Option, - limit: Option, - cursor: Option, +struct RegisterNotifyInput { + space: String, + service_did: String, + endpoint: String, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct DeleteSpaceInput { +struct NotifyWriteInput { space: String, + did: String, + collection: String, + rkey: String, + cid: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct UpdateSpaceInput { +struct NotifySpaceDeletedInput { space: String, - display_name: Option>, - description: Option>, - access_mode: Option, - app_allowlist: Option>>, - app_denylist: Option>>, - managing_app_did: Option>, - config: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetDelegationTokenQuery { + space: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SpaceUriQuery { + space: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListSpacesQuery { + did: Option, + limit: Option, + cursor: Option, } #[derive(Deserialize)] @@ -103,22 +120,6 @@ struct ListRecordsQuery { reverse: Option, } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct AddMemberInput { - space: String, - did: String, - access: Option, - is_delegation: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct RemoveMemberInput { - space: String, - did: String, -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct CreateInviteInput { @@ -141,12 +142,6 @@ struct RevokeInviteInput { invite_id: String, } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct GetMemberGrantInput { - space: String, -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GetSpaceCredentialInput { @@ -161,6 +156,13 @@ struct CreateRecordInput { record: serde_json::Value, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetSpaceBlobQuery { + space: String, + cid: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ApplyWritesInput { @@ -196,78 +198,136 @@ enum WriteOp { // Route registration // --------------------------------------------------------------------------- -const NS: &str = "dev.happyview"; +const PROTO_NS: &str = "com.atproto"; +const LEGACY_NS: &str = "dev.happyview"; pub fn space_routes() -> Router { Router::new() - // Space CRUD - .route(&format!("/xrpc/{NS}.space.createSpace"), post(create_space)) - .route(&format!("/xrpc/{NS}.space.getSpace"), get(get_space)) - .route(&format!("/xrpc/{NS}.space.listSpaces"), get(list_spaces)) - .route(&format!("/xrpc/{NS}.space.deleteSpace"), post(delete_space)) - .route(&format!("/xrpc/{NS}.space.updateSpace"), post(update_space)) - // Records + // Protocol-level routes (com.atproto.space.*) + .route(&format!("/xrpc/{PROTO_NS}.space.getSpace"), get(get_space)) + .route( + &format!("/xrpc/{PROTO_NS}.space.listSpaces"), + get(list_spaces), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getRecord"), + get(get_record), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.listRecords"), + get(list_records), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getRepoState"), + get(get_repo_state), + ) .route( - &format!("/xrpc/{NS}.space.createRecord"), + &format!("/xrpc/{PROTO_NS}.space.listRepoOps"), + get(list_repo_ops), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.listRepos"), + get(list_repos), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getDelegationToken"), + get(get_delegation_token), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getSpaceCredential"), + post(get_space_credential), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.createRecord"), post(create_record), ) - .route(&format!("/xrpc/{NS}.space.putRecord"), post(put_record)) .route( - &format!("/xrpc/{NS}.space.deleteRecord"), + &format!("/xrpc/{PROTO_NS}.space.putRecord"), + post(put_record), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.deleteRecord"), post(delete_record), ) - .route(&format!("/xrpc/{NS}.space.applyWrites"), post(apply_writes)) - .route(&format!("/xrpc/{NS}.space.getRecord"), get(get_record)) - .route(&format!("/xrpc/{NS}.space.listRecords"), get(list_records)) - // Members - .route(&format!("/xrpc/{NS}.space.listMembers"), get(list_members)) - .route(&format!("/xrpc/{NS}.space.addMember"), post(add_member)) .route( - &format!("/xrpc/{NS}.space.removeMember"), - post(remove_member), + &format!("/xrpc/{PROTO_NS}.space.applyWrites"), + post(apply_writes), ) - // Invites .route( - &format!("/xrpc/{NS}.space.createInvite"), + &format!("/xrpc/{PROTO_NS}.space.registerNotify"), + post(register_notify), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.notifyWrite"), + post(notify_write), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.notifySpaceDeleted"), + post(notify_space_deleted), + ) + .route( + &format!("/xrpc/{PROTO_NS}.space.getBlob"), + get(get_space_blob), + ) + // Invites (HappyView extension, no com.atproto equivalent) + .route( + &format!("/xrpc/{LEGACY_NS}.space.createInvite"), post(create_invite), ) .route( - &format!("/xrpc/{NS}.space.redeemInvite"), - post(redeem_invite), + &format!("/xrpc/{LEGACY_NS}.space.acceptInvite"), + post(accept_invite), ) .route( - &format!("/xrpc/{NS}.space.revokeInvite"), + &format!("/xrpc/{LEGACY_NS}.space.revokeInvite"), post(revoke_invite), ) - .route(&format!("/xrpc/{NS}.space.listInvites"), get(list_invites)) - // Credentials .route( - &format!("/xrpc/{NS}.space.getMemberGrant"), - post(get_member_grant), + &format!("/xrpc/{LEGACY_NS}.space.listInvites"), + get(list_invites), + ) + // Backward-compatible aliases (dev.happyview.space.*) — kept until v3 + .route(&format!("/xrpc/{LEGACY_NS}.space.getSpace"), get(get_space)) + .route( + &format!("/xrpc/{LEGACY_NS}.space.listSpaces"), + get(list_spaces), ) .route( - &format!("/xrpc/{NS}.space.getSpaceCredential"), + &format!("/xrpc/{LEGACY_NS}.space.getRecord"), + get(get_record), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.listRecords"), + get(list_records), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.getMemberGrant"), + get(get_delegation_token), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.getSpaceCredential"), post(get_space_credential), ) - // Legacy aliases (will be removed in a future release) - .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) - .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) - .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) - .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) - .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) .route( - &format!("/xrpc/{NS}.space.invite.create"), - post(create_invite), + &format!("/xrpc/{LEGACY_NS}.space.createRecord"), + post(create_record), ) .route( - &format!("/xrpc/{NS}.space.invite.redeem"), - post(redeem_invite), + &format!("/xrpc/{LEGACY_NS}.space.putRecord"), + post(put_record), ) .route( - &format!("/xrpc/{NS}.space.invite.revoke"), - post(revoke_invite), + &format!("/xrpc/{LEGACY_NS}.space.deleteRecord"), + post(delete_record), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.applyWrites"), + post(apply_writes), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.getBlob"), + get(get_space_blob), ) - .route(&format!("/xrpc/{NS}.space.invite.list"), get(list_invites)) } // --------------------------------------------------------------------------- @@ -321,7 +381,7 @@ async fn resolve_space(state: &AppState, space_uri: &str) -> Result Result<(), AppError> { - if space.owner_did == did { + if space.authority_did == did { return Ok(()); } let sql = adapt_sql( @@ -357,17 +417,14 @@ async fn require_membership( ) .await { - Ok(claims) if claims.space == space_uri => { - let access = match claims.scope.as_str() { - "write" => SpaceAccess::Write, - _ => SpaceAccess::Read, - }; - if require_write && !access.can_write() { + Ok(claims) if claims.sub == space_uri => { + // External credential grants read access; write is not supported via space credential + if require_write { return Err(AppError::Forbidden( "Write access is required for this action".into(), )); } - return Ok(access); + return Ok(SpaceAccess::Read); } Ok(_) => { // Credential is valid but for a different space — fall through @@ -396,77 +453,9 @@ fn content_cid(record: &serde_json::Value) -> String { } // --------------------------------------------------------------------------- -// Space CRUD handlers +// Space read handlers // --------------------------------------------------------------------------- -async fn create_space( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result { - let claims = require_auth(&xrpc_claims)?; - let did = claims.did().to_string(); - - if input.type_nsid.is_empty() || input.skey.is_empty() { - return Err(AppError::BadRequest("type and skey are required".into())); - } - - let existing = db::get_space_by_address( - &state.db, - state.db_backend, - &did, - &input.type_nsid, - &input.skey, - ) - .await?; - if existing.is_some() { - return Err(AppError::Conflict( - "A space with this address already exists".into(), - )); - } - - let space = Space { - id: Uuid::new_v4().to_string(), - did: did.clone(), - owner_did: did.clone(), - type_nsid: input.type_nsid, - skey: input.skey, - display_name: input.display_name, - description: input.description, - access_mode: input.access_mode.unwrap_or(AccessMode::DefaultAllow), - app_allowlist: None, - app_denylist: None, - managing_app_did: input.managing_app_did, - config: input.config.unwrap_or_default(), - revision: None, - created_at: now_rfc3339(), - updated_at: now_rfc3339(), - }; - - db::create_space(&state.db, state.db_backend, &space).await?; - - // Auto-add the creator as a write member - let member = SpaceMember { - id: Uuid::new_v4().to_string(), - space_id: space.id.clone(), - did: did.clone(), - access: SpaceAccess::Write, - is_delegation: false, - granted_by: Some(did), - created_at: now_rfc3339(), - }; - db::add_member(&state.db, state.db_backend, &member).await?; - - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); - let body = serde_json::json!({ - "uri": space_uri, - }); - - let mut response = Json(body).into_response(); - *response.status_mut() = StatusCode::CREATED; - Ok(response) -} - async fn get_space( State(state): State, xrpc_claims: XrpcClaims, @@ -478,7 +467,7 @@ async fn get_space( if !space.config.membership_public { let claims = require_auth(&xrpc_claims)?; let did = claims.did(); - if space.owner_did != did { + if space.authority_did != did { members::is_member(&state.db, state.db_backend, &space.id, did) .await? .ok_or_else(|| AppError::NotFound("Space not found".into()))?; @@ -486,9 +475,16 @@ async fn get_space( } let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let simplespace_config = serde_json::json!({ + "$type": "com.atproto.simplespace.defs#spaceConfig", + "mintPolicy": space.mint_policy, + "appAccess": space.app_access, + "managingApp": space.managing_app_did, + }); Ok(Json(serde_json::json!({ "uri": space_uri, "space": space, + "config": simplespace_config, }))) } @@ -526,60 +522,6 @@ async fn list_spaces( }))) } -async fn delete_space( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - db::delete_space(&state.db, state.db_backend, &space.id).await?; - - Ok(Json(serde_json::json!({ "success": true }))) -} - -async fn update_space( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; - let mut space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - if let Some(name) = input.display_name { - space.display_name = name; - } - if let Some(desc) = input.description { - space.description = desc; - } - if let Some(mode) = input.access_mode { - space.access_mode = mode; - } - if let Some(list) = input.app_allowlist { - space.app_allowlist = list; - } - if let Some(list) = input.app_denylist { - space.app_denylist = list; - } - if let Some(did) = input.managing_app_did { - space.managing_app_did = did; - } - if let Some(config) = input.config { - space.config = config; - } - - db::update_space(&state.db, state.db_backend, &space).await?; - - let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); - Ok(Json(serde_json::json!({ - "uri": space_uri, - "space": space, - }))) -} - // --------------------------------------------------------------------------- // Record handlers // --------------------------------------------------------------------------- @@ -860,7 +802,8 @@ async fn get_record( ) -> Result, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &query.space).await?; - require_membership( + let has_credential = xrpc_claims.space_credential.is_some(); + let membership = require_membership( &state, &space, &did, @@ -879,6 +822,9 @@ async fn get_record( .await? .ok_or_else(|| AppError::NotFound("Record not found".into()))?; + let read_access = SpaceReadAccess::from_space_access(membership); + check_read_access(&did, &record.author_did, read_access, has_credential)?; + Ok(Json(serde_json::json!({ "uri": record.uri, "cid": record.cid, @@ -893,7 +839,8 @@ async fn list_records( ) -> Result, AppError> { let did = require_auth_or_credential(&state, &xrpc_claims).await?; let space = resolve_space(&state, &query.space).await?; - require_membership( + let has_credential = xrpc_claims.space_credential.is_some(); + let membership = require_membership( &state, &space, &did, @@ -902,13 +849,18 @@ async fn list_records( ) .await?; - let repo = query.repo.as_deref().or_else(|| { - if xrpc_claims.space_credential.is_some() { + let read_access = SpaceReadAccess::from_space_access(membership); + + // read_self members may only list their own records regardless of what the caller requests + let repo = if !has_credential && read_access == SpaceReadAccess::ReadSelf { + Some(did.as_str()) + } else { + query.repo.as_deref().or(if has_credential { None } else { Some(did.as_str()) - } - }); + }) + }; let limit = query.limit.unwrap_or(50).min(100); let reverse = query.reverse.unwrap_or(false); @@ -941,85 +893,6 @@ async fn list_records( }))) } -// --------------------------------------------------------------------------- -// Member handlers -// --------------------------------------------------------------------------- - -async fn list_members( - State(state): State, - xrpc_claims: XrpcClaims, - Query(query): Query, -) -> Result, AppError> { - let space = resolve_space(&state, &query.space).await?; - - if !space.config.membership_public { - let did = require_auth_or_credential(&state, &xrpc_claims).await?; - require_membership( - &state, - &space, - &did, - false, - xrpc_claims.space_credential.as_deref(), - ) - .await?; - } - - let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?; - - Ok(Json(serde_json::json!({ "members": resolved }))) -} - -async fn add_member( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result { - let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - let existing = db::get_member(&state.db, state.db_backend, &space.id, &input.did).await?; - if existing.is_some() { - return Err(AppError::Conflict( - "Member already exists in this space".into(), - )); - } - - let member = SpaceMember { - id: Uuid::new_v4().to_string(), - space_id: space.id, - did: input.did, - access: input.access.unwrap_or(SpaceAccess::Read), - is_delegation: input.is_delegation.unwrap_or(false), - granted_by: Some(claims.did().to_string()), - created_at: now_rfc3339(), - }; - - db::add_member(&state.db, state.db_backend, &member).await?; - - let mut response = Json(serde_json::json!({ "member": member })).into_response(); - *response.status_mut() = StatusCode::CREATED; - Ok(response) -} - -async fn remove_member( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space).await?; - require_space_admin(&state, &space, claims.did()).await?; - - let removed = db::remove_member(&state.db, state.db_backend, &space.id, &input.did).await?; - - if !removed { - return Err(AppError::NotFound("Member not found in this space".into())); - } - - Ok(Json(serde_json::json!({ "success": true }))) -} - // --------------------------------------------------------------------------- // Invite handlers // --------------------------------------------------------------------------- @@ -1065,7 +938,7 @@ async fn create_invite( Ok(response) } -async fn redeem_invite( +async fn accept_invite( State(state): State, xrpc_claims: XrpcClaims, Json(input): Json, @@ -1180,48 +1053,271 @@ async fn list_invites( // Credential handlers // --------------------------------------------------------------------------- -async fn get_member_grant( +async fn get_delegation_token( State(state): State, xrpc_claims: XrpcClaims, - Json(input): Json, + Query(params): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); - let space = resolve_space(&state, &input.space).await?; + let space = resolve_space(&state, ¶ms.space).await?; - require_membership(&state, &space, &did, false, None).await?; + let membership = require_membership(&state, &space, &did, false, None).await?; + let read_access = SpaceReadAccess::from_space_access(membership); + check_delegation_token_access(read_access, false)?; let encryption_key = state.config.token_encryption_key.as_ref().ok_or_else(|| { AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) })?; + let signing_key = k256::ecdsa::SigningKey::from_bytes(encryption_key.into()) + .map_err(|e| AppError::Internal(format!("failed to derive delegation signing key: {e}")))?; + let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs(); - let exp = now + crate::spaces::credential::GRANT_TTL_SECS; + let exp = now + crate::spaces::credential::DELEGATION_TOKEN_TTL_SECS; let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); - let grant_claims = crate::spaces::credential::MemberGrantClaims { - sub: did, - space: space_uri, - scope: "read".into(), + let space_host = format!("{}#atproto_space_host", space.did); + let delegation_claims = crate::spaces::credential::DelegationTokenClaims { + iss: did, + sub: space_uri, + aud: space_host, iat: now, exp, + jti: crate::spaces::credential::make_jti(), }; - let grant = crate::spaces::credential::sign_grant(&grant_claims, encryption_key)?; + let grant = crate::spaces::credential::sign_delegation_token(&delegation_claims, &signing_key)?; let expires_at = chrono::DateTime::from_timestamp(exp as i64, 0) .map(|dt| dt.to_rfc3339()) .unwrap_or_default(); Ok(Json(serde_json::json!({ - "grant": grant, + "delegationToken": grant, "expiresAt": expires_at, }))) } +// --------------------------------------------------------------------------- +// Protocol endpoint implementations +// --------------------------------------------------------------------------- + +async fn get_repo_state( + 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?; + + Ok(Json(serde_json::json!({ + "rev": repo_state.rev, + "commit": repo_state.hash.as_ref().map(|h| { + serde_json::json!({ + "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()), + "mac": base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(repo_state.mac.as_deref().unwrap_or_default()), + "rev": repo_state.rev, + }) + }), + }))) +} + +async fn list_repo_ops( + 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 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?; + + Ok(Json(serde_json::json!({ "ops": ops }))) +} + +async fn list_repos( + 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 repos = db::list_space_repos(&state.db, state.db_backend, &space.id).await?; + Ok(Json(serde_json::json!({ "repos": repos }))) +} + +async fn get_space_blob( + 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 author_did = db::find_blob_author_did(&state.db, state.db_backend, &space.id, ¶ms.cid) + .await? + .ok_or_else(|| AppError::NotFound("Blob not found in this space".into()))?; + + let read_access = SpaceReadAccess::from_space_access(membership); + check_read_access(&did, &author_did, read_access, has_credential)?; + + let pds_endpoint = + crate::profile::resolve_pds_endpoint(&state.http, &state.config.plc_url, &author_did) + .await?; + + let url = format!( + "{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}", + pds_endpoint, + urlencoding::encode(&author_did), + urlencoding::encode(¶ms.cid), + ); + + let resp = state + .http + .get(&url) + .send() + .await + .map_err(|e| AppError::BadGateway(format!("blob fetch failed: {e}")))?; + + let status = resp.status(); + if !status.is_success() { + return Err(AppError::BadGateway(format!( + "PDS returned {status} for blob cid={}", + params.cid + ))); + } + + let content_type = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + + let bytes = resp + .bytes() + .await + .map_err(|e| AppError::BadGateway(format!("failed to read blob body: {e}")))?; + + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + content_type + .parse() + .unwrap_or_else(|_| "application/octet-stream".parse().unwrap()), + ); + + Ok((status, headers, bytes)) +} + +async fn register_notify( + State(state): State, + claims: XrpcClaims, + Json(input): Json, +) -> Result { + let did = require_auth_or_credential(&state, &claims).await?; + let space = resolve_space(&state, &input.space).await?; + + let id = notifications::register( + &state.db, + state.db_backend, + &space.id, + &input.service_did, + &input.endpoint, + &did, + ) + .await?; + + Ok(Json(serde_json::json!({ "id": id }))) +} + +async fn notify_write( + State(state): State, + _claims: XrpcClaims, + Json(input): Json, +) -> Result { + let space = resolve_space(&state, &input.space).await?; + + notifications::dispatch_write_notification( + &state.db, + state.db_backend, + &state.http, + &space.id, + &input.did, + &input.collection, + &input.rkey, + input.cid.as_deref(), + ) + .await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + +async fn notify_space_deleted( + State(state): State, + _claims: XrpcClaims, + Json(input): Json, +) -> Result { + let space = resolve_space(&state, &input.space).await?; + + notifications::dispatch_space_deleted(&state.db, state.db_backend, &state.http, &space.id) + .await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + async fn get_space_credential( State(state): State, xrpc_claims: XrpcClaims, @@ -1233,18 +1329,29 @@ async fn get_space_credential( AppError::Internal("TOKEN_ENCRYPTION_KEY is required for space credentials".into()) })?; - let grant_claims = crate::spaces::credential::verify_grant(&input.grant, encryption_key)?; + let verifying_key = { + let signing_key = + k256::ecdsa::SigningKey::from_bytes(encryption_key.into()).map_err(|e| { + AppError::Internal(format!("failed to derive delegation signing key: {e}")) + })?; + k256::ecdsa::VerifyingKey::from(&signing_key) + }; + + let delegation_claims = + crate::spaces::credential::verify_delegation_token(&input.grant, &verifying_key)?; - let space = resolve_space(&state, &grant_claims.space).await?; + let space = resolve_space(&state, &delegation_claims.sub).await?; let client_id = claims.client_key().map(|k| k.to_string()); let issued = crate::spaces::auth::issue_credential( &state.db, state.db_backend, + &state.http, encryption_key, &space, - &grant_claims.sub, + &delegation_claims.iss, client_id.as_deref(), + &space.authority_did, ) .await?; diff --git a/src/spaces/scope.rs b/src/spaces/scope.rs new file mode 100644 index 0000000..1d27ecb --- /dev/null +++ b/src/spaces/scope.rs @@ -0,0 +1,141 @@ +use crate::error::AppError; +use crate::spaces::types::SpaceAccess; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpaceReadAccess { + Read, // whole space — can getDelegationToken, read any repo + ReadSelf, // own repo only — no delegation token, only own records +} + +impl SpaceReadAccess { + pub fn from_space_access(access: SpaceAccess) -> Self { + match access { + SpaceAccess::ReadSelf => SpaceReadAccess::ReadSelf, + SpaceAccess::Read | SpaceAccess::Write => SpaceReadAccess::Read, + } + } +} + +/// Check whether the caller may read a specific target repo. +/// +/// Space credentials always grant full read (they were already authorized by the +/// credential issuance flow). OAuth/session callers are limited by their membership +/// access level. +pub fn check_read_access( + caller_did: &str, + target_repo_did: &str, + access: SpaceReadAccess, + has_space_credential: bool, +) -> Result<(), AppError> { + if has_space_credential { + return Ok(()); + } + match access { + SpaceReadAccess::Read => Ok(()), + SpaceReadAccess::ReadSelf => { + if caller_did == target_repo_did { + Ok(()) + } else { + Err(AppError::Forbidden( + "read_self access only permits reading your own repo".into(), + )) + } + } + } +} + +/// Check whether the caller may call getDelegationToken. +/// +/// Requires full `read` access — `read_self` members cannot obtain delegation tokens. +pub fn check_delegation_token_access( + access: SpaceReadAccess, + has_space_credential: bool, +) -> Result<(), AppError> { + if has_space_credential { + return Ok(()); + } + match access { + SpaceReadAccess::Read => Ok(()), + SpaceReadAccess::ReadSelf => Err(AppError::Forbidden( + "read_self access does not permit obtaining delegation tokens".into(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_access_allows_any_repo() { + assert!( + check_read_access("did:plc:alice", "did:plc:bob", SpaceReadAccess::Read, false).is_ok() + ); + } + + #[test] + fn read_self_allows_own_repo() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:alice", + SpaceReadAccess::ReadSelf, + false + ) + .is_ok() + ); + } + + #[test] + fn read_self_denies_other_repo() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:bob", + SpaceReadAccess::ReadSelf, + false + ) + .is_err() + ); + } + + #[test] + fn space_credential_bypasses_read_self() { + assert!( + check_read_access( + "did:plc:alice", + "did:plc:bob", + SpaceReadAccess::ReadSelf, + true + ) + .is_ok() + ); + } + + #[test] + fn delegation_token_requires_read() { + assert!(check_delegation_token_access(SpaceReadAccess::Read, false).is_ok()); + assert!(check_delegation_token_access(SpaceReadAccess::ReadSelf, false).is_err()); + } + + #[test] + fn delegation_token_space_credential_bypasses() { + assert!(check_delegation_token_access(SpaceReadAccess::ReadSelf, true).is_ok()); + } + + #[test] + fn from_space_access_mapping() { + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::Read), + SpaceReadAccess::Read + ); + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::Write), + SpaceReadAccess::Read + ); + assert_eq!( + SpaceReadAccess::from_space_access(SpaceAccess::ReadSelf), + SpaceReadAccess::ReadSelf + ); + } +} diff --git a/src/spaces/simplespace.rs b/src/spaces/simplespace.rs new file mode 100644 index 0000000..7a8fd82 --- /dev/null +++ b/src/spaces/simplespace.rs @@ -0,0 +1,488 @@ +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::db::now_rfc3339; +use crate::error::AppError; +use crate::spaces::types::*; +use crate::spaces::{SpaceUri, db, members}; + +// --------------------------------------------------------------------------- +// Request / response types +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateSpaceInput { + #[serde(rename = "type")] + pub type_nsid: String, + pub skey: String, + pub display_name: Option, + pub description: Option, + pub mint_policy: Option, + pub app_access: Option, + pub managing_app_did: Option, + pub config: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SpaceUriQuery { + pub space: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeleteSpaceInput { + pub space: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateSpaceInput { + pub space: String, + pub display_name: Option>, + pub description: Option>, + pub mint_policy: Option, + pub app_access: Option, + pub managing_app_did: Option>, + pub config: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AddMemberInput { + pub space: String, + pub did: String, + pub access: Option, + pub is_delegation: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RemoveMemberInput { + pub space: String, + pub did: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateConfigInput { + pub space: String, + pub mint_policy: Option, + pub app_access: Option, + pub managing_app: Option>, +} + +// --------------------------------------------------------------------------- +// Route registration +// --------------------------------------------------------------------------- + +const NS: &str = "com.atproto"; +const LEGACY_NS: &str = "dev.happyview"; + +pub fn simplespace_routes() -> Router { + Router::new() + // Management routes (com.atproto.simplespace.*) + .route( + &format!("/xrpc/{NS}.simplespace.createSpace"), + post(create_space), + ) + .route( + &format!("/xrpc/{NS}.simplespace.updateSpace"), + post(update_space), + ) + .route( + &format!("/xrpc/{NS}.simplespace.deleteSpace"), + post(delete_space), + ) + .route( + &format!("/xrpc/{NS}.simplespace.addMember"), + post(add_member), + ) + .route( + &format!("/xrpc/{NS}.simplespace.removeMember"), + post(remove_member), + ) + .route( + &format!("/xrpc/{NS}.simplespace.listMembers"), + get(list_members), + ) + .route( + &format!("/xrpc/{NS}.simplespace.getConfig"), + get(get_config), + ) + .route( + &format!("/xrpc/{NS}.simplespace.updateConfig"), + post(update_config), + ) + // Backward-compatible aliases (dev.happyview.space.*) — kept until v3 + .route( + &format!("/xrpc/{LEGACY_NS}.space.createSpace"), + post(create_space), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.updateSpace"), + post(update_space), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.deleteSpace"), + post(delete_space), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.addMember"), + post(add_member), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.removeMember"), + post(remove_member), + ) + .route( + &format!("/xrpc/{LEGACY_NS}.space.listMembers"), + get(list_members), + ) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { + claims + .identity + .as_ref() + .ok_or_else(|| AppError::Auth("This endpoint requires authentication".into())) +} + +async fn require_auth_or_credential( + state: &AppState, + claims: &XrpcClaims, +) -> Result { + if let Some(identity) = &claims.identity { + return Ok(identity.did().to_string()); + } + + if let Some(token) = &claims.space_credential { + let verified = crate::spaces::credential::verify_external_credential( + token, + &state.http, + &state.config.plc_url, + ) + .await?; + return Ok(verified.sub); + } + + Err(AppError::Auth( + "This endpoint requires authentication".into(), + )) +} + +async fn resolve_space(state: &AppState, space_uri: &str) -> Result { + let uri = SpaceUri::parse(space_uri)?; + db::get_space_by_address( + &state.db, + state.db_backend, + &uri.did, + &uri.type_nsid, + &uri.skey, + ) + .await? + .ok_or_else(|| AppError::NotFound("Space not found".into())) +} + +async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Result<(), AppError> { + use crate::db::adapt_sql; + if space.authority_did == did { + return Ok(()); + } + let sql = adapt_sql( + "SELECT is_super FROM happyview_users WHERE did = ?", + state.db_backend, + ); + let row: Option<(i32,)> = sqlx::query_as(&sql) + .bind(did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to check admin status: {e}")))?; + if row.is_some_and(|(is_super,)| is_super != 0) { + return Ok(()); + } + Err(AppError::Forbidden( + "Only the space owner can perform this action".into(), + )) +} + +// --------------------------------------------------------------------------- +// Space management handlers +// --------------------------------------------------------------------------- + +async fn create_space( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + + if input.type_nsid.is_empty() || input.skey.is_empty() { + return Err(AppError::BadRequest("type and skey are required".into())); + } + + let existing = db::get_space_by_address( + &state.db, + state.db_backend, + &did, + &input.type_nsid, + &input.skey, + ) + .await?; + if existing.is_some() { + return Err(AppError::Conflict( + "A space with this address already exists".into(), + )); + } + + // Optionally resolve the space type declaration from the lexicon registry. + // If the type NSID maps to a stored space declaration, use its collections + // as the default `allowed_collections` in the space config. + let mut config = input.config.unwrap_or_default(); + if let Some(decl) = state.lexicons.get_space_declaration(&input.type_nsid).await + && let Some(collections) = decl.space_collections + && !collections.is_empty() + && !config.extra.contains_key("allowedCollections") + { + config.extra.insert( + "allowedCollections".to_string(), + serde_json::Value::Array( + collections + .into_iter() + .map(serde_json::Value::String) + .collect(), + ), + ); + } + + let space = Space { + id: Uuid::new_v4().to_string(), + did: did.clone(), + authority_did: did.clone(), + creator_did: did.clone(), + type_nsid: input.type_nsid, + skey: input.skey, + display_name: input.display_name, + description: input.description, + mint_policy: input.mint_policy.unwrap_or(MintPolicy::MemberList), + app_access: input.app_access.unwrap_or_default(), + managing_app_did: input.managing_app_did, + config, + revision: None, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + }; + + db::create_space(&state.db, state.db_backend, &space).await?; + + // Auto-provision #atproto_space verification method if TOKEN_ENCRYPTION_KEY is available + if let Some(encryption_key) = &state.config.token_encryption_key + && let Err(e) = crate::verification_methods::ensure_atproto_space_method( + &state.db, + state.db_backend, + encryption_key, + ) + .await + { + tracing::warn!("failed to auto-provision #atproto_space verification method: {e}"); + } + + let member = SpaceMember { + id: Uuid::new_v4().to_string(), + space_id: space.id.clone(), + did: did.clone(), + access: SpaceAccess::Write, + is_delegation: false, + granted_by: Some(did), + created_at: now_rfc3339(), + }; + db::add_member(&state.db, state.db_backend, &member).await?; + + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + let body = serde_json::json!({ + "uri": space_uri, + }); + + let mut response = Json(body).into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + +async fn delete_space( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + db::delete_space(&state.db, state.db_backend, &space.id).await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + +async fn update_space( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let mut space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + if let Some(name) = input.display_name { + space.display_name = name; + } + if let Some(desc) = input.description { + space.description = desc; + } + if let Some(policy) = input.mint_policy { + space.mint_policy = policy; + } + if let Some(access) = input.app_access { + space.app_access = access; + } + if let Some(did) = input.managing_app_did { + space.managing_app_did = did; + } + if let Some(config) = input.config { + space.config = config; + } + + db::update_space(&state.db, state.db_backend, &space).await?; + + let space_uri = format!("ats://{}/{}/{}", space.did, space.type_nsid, space.skey); + Ok(Json(serde_json::json!({ + "uri": space_uri, + "space": space, + }))) +} + +async fn list_members( + State(state): State, + xrpc_claims: XrpcClaims, + Query(query): Query, +) -> Result, AppError> { + let space = resolve_space(&state, &query.space).await?; + + if !space.config.membership_public { + let did = require_auth_or_credential(&state, &xrpc_claims).await?; + let member = members::is_member(&state.db, state.db_backend, &space.id, &did).await?; + member.ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; + } + + let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?; + + Ok(Json(serde_json::json!({ "members": resolved }))) +} + +async fn add_member( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let existing = db::get_member(&state.db, state.db_backend, &space.id, &input.did).await?; + if existing.is_some() { + return Err(AppError::Conflict( + "Member already exists in this space".into(), + )); + } + + let member = SpaceMember { + id: Uuid::new_v4().to_string(), + space_id: space.id, + did: input.did, + access: input.access.unwrap_or(SpaceAccess::Read), + is_delegation: input.is_delegation.unwrap_or(false), + granted_by: Some(claims.did().to_string()), + created_at: now_rfc3339(), + }; + + db::add_member(&state.db, state.db_backend, &member).await?; + + let mut response = Json(serde_json::json!({ "member": member })).into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + +async fn remove_member( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let removed = db::remove_member(&state.db, state.db_backend, &space.id, &input.did).await?; + + if !removed { + return Err(AppError::NotFound("Member not found in this space".into())); + } + + Ok(Json(serde_json::json!({ "success": true }))) +} + +async fn get_config( + State(state): State, + xrpc_claims: XrpcClaims, + Query(query): Query, +) -> Result, AppError> { + let space = resolve_space(&state, &query.space).await?; + let claims = require_auth(&xrpc_claims)?; + require_space_admin(&state, &space, claims.did()).await?; + + Ok(Json(serde_json::json!({ + "$type": "com.atproto.simplespace.defs#spaceConfig", + "mintPolicy": space.mint_policy, + "appAccess": space.app_access, + "managingApp": space.managing_app_did, + }))) +} + +async fn update_config( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let mut space = resolve_space(&state, &input.space).await?; + require_space_admin(&state, &space, claims.did()).await?; + + if let Some(policy) = input.mint_policy { + space.mint_policy = policy; + } + if let Some(access) = input.app_access { + space.app_access = access; + } + if let Some(managing_app) = input.managing_app { + space.managing_app_did = managing_app; + } + + db::update_space(&state.db, state.db_backend, &space).await?; + + Ok(Json(serde_json::json!({ + "$type": "com.atproto.simplespace.defs#spaceConfig", + "mintPolicy": space.mint_policy, + "appAccess": space.app_access, + "managingApp": space.managing_app_did, + }))) +} diff --git a/src/spaces/types.rs b/src/spaces/types.rs index db5f902..a804264 100644 --- a/src/spaces/types.rs +++ b/src/spaces/types.rs @@ -2,9 +2,10 @@ use serde::{Deserialize, Serialize}; use std::fmt; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum SpaceAccess { Read, + ReadSelf, Write, } @@ -12,6 +13,7 @@ impl SpaceAccess { pub fn as_str(&self) -> &'static str { match self { SpaceAccess::Read => "read", + SpaceAccess::ReadSelf => "read_self", SpaceAccess::Write => "write", } } @@ -19,6 +21,7 @@ impl SpaceAccess { pub fn parse(s: &str) -> Option { match s { "read" => Some(SpaceAccess::Read), + "read_self" => Some(SpaceAccess::ReadSelf), "write" => Some(SpaceAccess::Write), _ => None, } @@ -40,48 +43,119 @@ impl fmt::Display for SpaceAccess { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AccessMode { - DefaultAllow, - DefaultDeny, +pub enum MintPolicy { + #[serde(rename = "member-list")] + MemberList, + #[serde(rename = "public")] + Public, + #[serde(rename = "managing-app")] + ManagingApp, } -impl AccessMode { +impl MintPolicy { pub fn as_str(&self) -> &'static str { match self { - AccessMode::DefaultAllow => "default_allow", - AccessMode::DefaultDeny => "default_deny", + MintPolicy::MemberList => "member-list", + MintPolicy::Public => "public", + MintPolicy::ManagingApp => "managing-app", } } pub fn parse(s: &str) -> Option { match s { - "default_allow" => Some(AccessMode::DefaultAllow), - "default_deny" => Some(AccessMode::DefaultDeny), + "member-list" => Some(MintPolicy::MemberList), + "public" => Some(MintPolicy::Public), + "managing-app" => Some(MintPolicy::ManagingApp), _ => None, } } } -impl fmt::Display for AccessMode { +impl fmt::Display for MintPolicy { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum AppAccess { + #[default] + Open, + AllowList { + allowed: Vec, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OplogAction { + Create, + Update, + Delete, +} + +impl OplogAction { + pub fn as_str(&self) -> &'static str { + match self { + OplogAction::Create => "create", + OplogAction::Update => "update", + OplogAction::Delete => "delete", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "create" => Some(OplogAction::Create), + "update" => Some(OplogAction::Update), + "delete" => Some(OplogAction::Delete), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OplogEntry { + pub id: String, + pub space_id: String, + pub author_did: String, + pub rev: String, + pub idx: i32, + pub action: OplogAction, + pub collection: String, + pub rkey: String, + pub cid: Option, + pub prev: Option, + pub created_at: String, +} + +#[derive(Debug, Clone)] +pub struct RepoState { + pub id: String, + pub space_id: String, + pub author_did: String, + pub lthash_state: Vec, + pub rev: Option, + pub hash: Option>, + pub ikm: Option>, + pub sig: Option>, + pub mac: Option>, + pub updated_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Space { pub id: String, pub did: String, - pub owner_did: String, + pub authority_did: String, + pub creator_did: String, #[serde(rename = "type")] pub type_nsid: String, pub skey: String, pub display_name: Option, pub description: Option, - pub access_mode: AccessMode, - pub app_allowlist: Option>, - pub app_denylist: Option>, + pub mint_policy: MintPolicy, + pub app_access: AppAccess, pub managing_app_did: Option, pub config: SpaceConfig, pub revision: Option, @@ -128,6 +202,17 @@ pub struct SpaceRecord { pub indexed_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotifyRegistration { + pub id: String, + pub space_id: String, + pub author_did: Option, + pub endpoint: String, + pub registered_by: String, + pub expires_at: String, + pub created_at: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpaceInvite { pub id: String, @@ -149,10 +234,12 @@ mod tests { #[test] fn space_access_roundtrip() { assert_eq!(SpaceAccess::parse("read"), Some(SpaceAccess::Read)); + assert_eq!(SpaceAccess::parse("read_self"), Some(SpaceAccess::ReadSelf)); assert_eq!(SpaceAccess::parse("write"), Some(SpaceAccess::Write)); assert_eq!(SpaceAccess::parse("admin"), None); assert_eq!(SpaceAccess::Read.as_str(), "read"); + assert_eq!(SpaceAccess::ReadSelf.as_str(), "read_self"); assert_eq!(SpaceAccess::Write.as_str(), "write"); } @@ -160,21 +247,71 @@ mod tests { fn space_access_permissions() { assert!(SpaceAccess::Read.can_read()); assert!(!SpaceAccess::Read.can_write()); + assert!(SpaceAccess::ReadSelf.can_read()); + assert!(!SpaceAccess::ReadSelf.can_write()); assert!(SpaceAccess::Write.can_read()); assert!(SpaceAccess::Write.can_write()); } #[test] - fn access_mode_roundtrip() { + fn mint_policy_roundtrip() { assert_eq!( - AccessMode::parse("default_allow"), - Some(AccessMode::DefaultAllow) + MintPolicy::parse("member-list"), + Some(MintPolicy::MemberList) ); + assert_eq!(MintPolicy::parse("public"), Some(MintPolicy::Public)); assert_eq!( - AccessMode::parse("default_deny"), - Some(AccessMode::DefaultDeny) + MintPolicy::parse("managing-app"), + Some(MintPolicy::ManagingApp) ); - assert_eq!(AccessMode::parse("open"), None); + assert_eq!(MintPolicy::parse("invalid"), None); + + assert_eq!(MintPolicy::MemberList.as_str(), "member-list"); + assert_eq!(MintPolicy::Public.as_str(), "public"); + assert_eq!(MintPolicy::ManagingApp.as_str(), "managing-app"); + } + + #[test] + fn mint_policy_serialization() { + let json = serde_json::to_string(&MintPolicy::MemberList).unwrap(); + assert_eq!(json, "\"member-list\""); + let parsed: MintPolicy = serde_json::from_str("\"public\"").unwrap(); + assert_eq!(parsed, MintPolicy::Public); + } + + #[test] + fn app_access_open_serialization() { + let access = AppAccess::Open; + let json = serde_json::to_string(&access).unwrap(); + assert_eq!(json, r#"{"type":"open"}"#); + let parsed: AppAccess = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, AppAccess::Open)); + } + + #[test] + fn app_access_allowlist_serialization() { + let access = AppAccess::AllowList { + allowed: vec!["https://app.example.com/client-metadata.json".into()], + }; + let json = serde_json::to_string(&access).unwrap(); + let parsed: AppAccess = serde_json::from_str(&json).unwrap(); + match parsed { + AppAccess::AllowList { allowed } => { + assert_eq!( + allowed, + vec!["https://app.example.com/client-metadata.json"] + ); + } + _ => panic!("expected AllowList"), + } + } + + #[test] + fn oplog_action_roundtrip() { + assert_eq!(OplogAction::parse("create"), Some(OplogAction::Create)); + assert_eq!(OplogAction::parse("update"), Some(OplogAction::Update)); + assert_eq!(OplogAction::parse("delete"), Some(OplogAction::Delete)); + assert_eq!(OplogAction::parse("invalid"), None); } #[test] @@ -198,12 +335,18 @@ mod tests { let json = serde_json::to_string(&SpaceAccess::Read).unwrap(); assert_eq!(json, "\"read\""); + let json = serde_json::to_string(&SpaceAccess::ReadSelf).unwrap(); + assert_eq!(json, "\"read_self\""); + let json = serde_json::to_string(&SpaceAccess::Write).unwrap(); assert_eq!(json, "\"write\""); let parsed: SpaceAccess = serde_json::from_str("\"read\"").unwrap(); assert_eq!(parsed, SpaceAccess::Read); + let parsed: SpaceAccess = serde_json::from_str("\"read_self\"").unwrap(); + assert_eq!(parsed, SpaceAccess::ReadSelf); + let parsed: SpaceAccess = serde_json::from_str("\"write\"").unwrap(); assert_eq!(parsed, SpaceAccess::Write); } diff --git a/src/verification_methods.rs b/src/verification_methods.rs new file mode 100644 index 0000000..730d4b4 --- /dev/null +++ b/src/verification_methods.rs @@ -0,0 +1,249 @@ +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use chrono::Utc; +use p256::ecdsa::SigningKey; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sqlx::AnyPool; +use uuid::Uuid; + +use crate::db::{DatabaseBackend, adapt_sql}; +use crate::error::AppError; +use crate::plc::private_key_to_did_key; +use crate::plugin::encryption::{decrypt, encrypt}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerificationMethod { + pub id: String, + pub fragment_id: String, + pub key_type: String, + pub public_key_multibase: String, + pub created_at: String, +} + +type VerificationMethodRow = (String, String, String, String, String); + +fn parse_row(r: VerificationMethodRow) -> VerificationMethod { + VerificationMethod { + id: r.0, + fragment_id: r.1, + key_type: r.2, + public_key_multibase: r.3, + created_at: r.4, + } +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +pub async fn list_methods( + db: &AnyPool, + backend: DatabaseBackend, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, fragment_id, key_type, public_key_multibase, created_at FROM happyview_verification_methods ORDER BY created_at", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(db) + .await + .map_err(|e| AppError::Internal(format!("failed to list verification methods: {e}")))?; + + Ok(rows.into_iter().map(parse_row).collect()) +} + +pub async fn get_method_by_fragment( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, fragment_id, key_type, public_key_multibase, created_at FROM happyview_verification_methods WHERE fragment_id = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(fragment_id) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("failed to get verification method: {e}")))?; + + Ok(row.map(parse_row)) +} + +pub async fn create_method( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, + encryption_key: &[u8; 32], +) -> Result { + let (private_key_bytes, public_key_multibase) = generate_p256_keypair()?; + + let encrypted = encrypt(encryption_key, &private_key_bytes) + .map_err(|e| AppError::Internal(format!("failed to encrypt verification key: {e}")))?; + let encrypted_b64 = STANDARD.encode(&encrypted); + + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + + let sql = adapt_sql( + "INSERT INTO happyview_verification_methods (id, fragment_id, key_type, public_key_multibase, private_key_enc, created_at) VALUES (?, ?, 'Multikey', ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(&id) + .bind(fragment_id) + .bind(&public_key_multibase) + .bind(encrypted_b64.as_bytes()) + .bind(&now) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to create verification method: {e}")))?; + + Ok(VerificationMethod { + id, + fragment_id: fragment_id.to_string(), + key_type: "Multikey".to_string(), + public_key_multibase, + created_at: now, + }) +} + +pub async fn delete_method( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, +) -> Result { + let sql = adapt_sql( + "DELETE FROM happyview_verification_methods WHERE fragment_id = ?", + backend, + ); + + let result = sqlx::query(&sql) + .bind(fragment_id) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to delete verification method: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +pub async fn get_private_key_bytes( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, + encryption_key: &[u8; 32], +) -> Result>, AppError> { + let sql = adapt_sql( + "SELECT private_key_enc FROM happyview_verification_methods WHERE fragment_id = ?", + backend, + ); + + let row: Option<(Vec,)> = sqlx::query_as(&sql) + .bind(fragment_id) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch verification key: {e}")))?; + + let Some((encrypted_raw,)) = row else { + return Ok(None); + }; + + let encrypted_b64 = String::from_utf8(encrypted_raw) + .map_err(|e| AppError::Internal(format!("invalid private_key_enc encoding: {e}")))?; + let encrypted = STANDARD + .decode(&encrypted_b64) + .map_err(|e| AppError::Internal(format!("failed to decode private_key_enc: {e}")))?; + let key_bytes = decrypt(encryption_key, &encrypted) + .map_err(|e| AppError::Internal(format!("failed to decrypt verification key: {e}")))?; + + Ok(Some(key_bytes)) +} + +// --------------------------------------------------------------------------- +// Key generation +// --------------------------------------------------------------------------- + +fn generate_p256_keypair() -> Result<(Vec, String), AppError> { + let mut rng_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut rng_bytes); + + let signing_key = SigningKey::from_bytes((&rng_bytes[..]).into()) + .map_err(|e| AppError::Internal(format!("failed to generate verification key: {e}")))?; + + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_encoded_point(true); + + // Multikey format: 0x8024 varint for P-256 + compressed public key, base58btc + let mut multikey_bytes = vec![0x80, 0x24]; + multikey_bytes.extend_from_slice(compressed.as_bytes()); + let public_key_multibase = multibase::encode(multibase::Base::Base58Btc, &multikey_bytes); + + Ok((rng_bytes.to_vec(), public_key_multibase)) +} + +pub fn private_key_bytes_to_signing_key(key_bytes: &[u8]) -> Result { + SigningKey::from_bytes(key_bytes.into()) + .map_err(|e| AppError::Internal(format!("invalid verification signing key: {e}"))) +} + +pub fn private_key_bytes_to_did_key(key_bytes: &[u8]) -> Result { + private_key_to_did_key(key_bytes) +} + +// --------------------------------------------------------------------------- +// Auto-provision +// --------------------------------------------------------------------------- + +/// Ensure `#atproto_space` verification method exists; create it if not. +pub async fn ensure_atproto_space_method( + db: &AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], +) -> Result { + if let Some(existing) = get_method_by_fragment(db, backend, "#atproto_space").await? { + return Ok(existing); + } + create_method(db, backend, "#atproto_space", encryption_key).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_p256_keypair_produces_multibase_key() { + let (key_bytes, multibase) = generate_p256_keypair().unwrap(); + assert_eq!(key_bytes.len(), 32); + // base58btc multibase starts with 'z' + assert!( + multibase.starts_with('z'), + "expected base58btc prefix: {multibase}" + ); + } + + #[test] + fn private_key_bytes_to_signing_key_roundtrip() { + let (key_bytes, _) = generate_p256_keypair().unwrap(); + let signing_key = private_key_bytes_to_signing_key(&key_bytes).unwrap(); + // Re-derive bytes should equal original + assert_eq!(signing_key.to_bytes().as_slice(), key_bytes.as_slice()); + } + + #[test] + fn private_key_bytes_to_did_key_format() { + let (key_bytes, _) = generate_p256_keypair().unwrap(); + let did_key = private_key_bytes_to_did_key(&key_bytes).unwrap(); + assert!( + did_key.starts_with("did:key:z"), + "expected did:key: prefix: {did_key}" + ); + } +} diff --git a/tests/spaces_db.rs b/tests/spaces_db.rs new file mode 100644 index 0000000..5e3ed51 --- /dev/null +++ b/tests/spaces_db.rs @@ -0,0 +1,653 @@ +mod common; + +use happyview::db::now_rfc3339; +use happyview::spaces::db as spaces_db; +use happyview::spaces::notifications; +use happyview::spaces::oplog; +use happyview::spaces::types::*; +use serial_test::serial; +use uuid::Uuid; + +use common::db as test_db; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn new_id() -> String { + Uuid::new_v4().to_string() +} + +fn make_space(id: &str, did: &str, type_nsid: &str, skey: &str) -> Space { + let now = now_rfc3339(); + Space { + id: id.to_string(), + did: did.to_string(), + authority_did: did.to_string(), + creator_did: did.to_string(), + type_nsid: type_nsid.to_string(), + skey: skey.to_string(), + display_name: Some("Test Space".to_string()), + description: None, + mint_policy: MintPolicy::MemberList, + app_access: AppAccess::Open, + managing_app_did: None, + config: SpaceConfig::default(), + revision: None, + created_at: now.clone(), + updated_at: now, + } +} + +// --------------------------------------------------------------------------- +// Space CRUD +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn create_and_get_space_roundtrip() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let id = new_id(); + let did = "did:plc:spaces-test-owner"; + let space = make_space(&id, did, "com.example.test", "myspace"); + + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let fetched = spaces_db::get_space(&pool, backend, &id) + .await + .expect("get_space failed") + .expect("space not found after creation"); + + assert_eq!(fetched.id, id); + assert_eq!(fetched.did, did); + assert_eq!(fetched.type_nsid, "com.example.test"); + assert_eq!(fetched.skey, "myspace"); + assert_eq!(fetched.display_name, Some("Test Space".to_string())); + assert_eq!(fetched.mint_policy, MintPolicy::MemberList); + assert!(matches!(fetched.app_access, AppAccess::Open)); +} + +#[tokio::test] +#[serial] +async fn get_space_by_address_roundtrip() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let id = new_id(); + let did = "did:plc:addr-test"; + let space = make_space(&id, did, "com.example.addr", "addr-skey"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let fetched = + spaces_db::get_space_by_address(&pool, backend, did, "com.example.addr", "addr-skey") + .await + .expect("get_space_by_address failed") + .expect("space not found by address"); + + assert_eq!(fetched.id, id); +} + +#[tokio::test] +#[serial] +async fn list_spaces_by_owner() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let owner = "did:plc:list-owner"; + let other = "did:plc:other-owner"; + + for i in 0..3 { + let space = make_space(&new_id(), owner, "com.example.list", &format!("space-{i}")); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + } + let space = make_space(&new_id(), other, "com.example.list", "other-space"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let owned = spaces_db::list_spaces_by_owner(&pool, backend, owner) + .await + .expect("list_spaces_by_owner failed"); + assert_eq!(owned.len(), 3); + + let other_owned = spaces_db::list_spaces_by_owner(&pool, backend, other) + .await + .expect("list_spaces_by_owner failed"); + assert_eq!(other_owned.len(), 1); +} + +#[tokio::test] +#[serial] +async fn delete_space_removes_it() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let id = new_id(); + let space = make_space(&id, "did:plc:del-owner", "com.example.del", "del-skey"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let deleted = spaces_db::delete_space(&pool, backend, &id) + .await + .expect("delete_space failed"); + assert!(deleted); + + let after = spaces_db::get_space(&pool, backend, &id) + .await + .expect("get_space failed"); + assert!(after.is_none()); +} + +// --------------------------------------------------------------------------- +// Repo state +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_or_create_repo_state_creates_default() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:repo-owner", + "com.example.repo", + "repo-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:repo-author"; + let state = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("get_or_create_repo_state failed"); + + assert_eq!(state.space_id, space_id); + assert_eq!(state.author_did, author_did); + assert_eq!(state.lthash_state, vec![0u8; 2048]); + assert!(state.rev.is_none()); + assert!(state.hash.is_none()); +} + +#[tokio::test] +#[serial] +async fn get_or_create_repo_state_is_idempotent() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:idem-owner", + "com.example.idem", + "idem-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:idem-author"; + let first = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("first call failed"); + let second = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("second call failed"); + + assert_eq!(first.id, second.id); +} + +#[tokio::test] +#[serial] +async fn update_repo_state_persists_fields() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:update-owner", + "com.example.upd", + "upd-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:update-author"; + let mut state = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("get_or_create failed"); + + state.rev = Some("rev-001".to_string()); + state.hash = Some(vec![0xde, 0xad, 0xbe, 0xef]); + + spaces_db::update_repo_state(&pool, backend, &state) + .await + .expect("update_repo_state failed"); + + let reloaded = spaces_db::get_or_create_repo_state(&pool, backend, &space_id, author_did) + .await + .expect("reload failed"); + + assert_eq!(reloaded.rev, Some("rev-001".to_string())); + assert_eq!(reloaded.hash, Some(vec![0xde, 0xad, 0xbe, 0xef])); +} + +// --------------------------------------------------------------------------- +// Oplog +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn oplog_append_and_list() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:oplog-owner", + "com.example.oplog", + "oplog-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:oplog-author"; + + for (i, action) in [ + OplogAction::Create, + OplogAction::Update, + OplogAction::Delete, + ] + .iter() + .enumerate() + { + let entry = OplogEntry { + id: new_id(), + space_id: space_id.clone(), + author_did: author_did.to_string(), + rev: format!("rev-{:04}", i + 1), + idx: 0, + action: *action, + collection: "com.example.item".to_string(), + rkey: format!("item-{i}"), + cid: Some(format!("bafy{i}")), + prev: if i > 0 { + Some(format!("rev-{:04}", i)) + } else { + None + }, + created_at: now_rfc3339(), + }; + oplog::append_op(&pool, backend, &entry) + .await + .expect("append_op failed"); + } + + let all_ops = oplog::list_ops(&pool, backend, &space_id, author_did, None, 10) + .await + .expect("list_ops failed"); + assert_eq!(all_ops.len(), 3); + assert!(matches!(all_ops[0].action, OplogAction::Create)); + assert!(matches!(all_ops[1].action, OplogAction::Update)); + assert!(matches!(all_ops[2].action, OplogAction::Delete)); +} + +#[tokio::test] +#[serial] +async fn oplog_list_with_since_rev_cursor() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:cursor-owner", + "com.example.cursor", + "cursor-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:cursor-author"; + + for i in 0..5 { + let entry = OplogEntry { + id: new_id(), + space_id: space_id.clone(), + author_did: author_did.to_string(), + rev: format!("rev-{:04}", i + 1), + idx: 0, + action: OplogAction::Create, + collection: "com.example.item".to_string(), + rkey: format!("item-{i}"), + cid: Some(format!("bafy{i}")), + prev: None, + created_at: now_rfc3339(), + }; + oplog::append_op(&pool, backend, &entry) + .await + .expect("append_op failed"); + } + + let after_rev2 = oplog::list_ops(&pool, backend, &space_id, author_did, Some("rev-0002"), 10) + .await + .expect("list_ops with cursor failed"); + + assert_eq!(after_rev2.len(), 3); + assert_eq!(after_rev2[0].rev, "rev-0003"); +} + +// --------------------------------------------------------------------------- +// Notification registrations +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn register_and_list_notify_registrations() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:notify-owner", + "com.example.notify", + "notify-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let service_did = "did:plc:notify-service"; + let endpoint = "https://service.example.com/notify"; + let registered_by = "did:plc:notify-owner"; + + let reg_id = notifications::register( + &pool, + backend, + &space_id, + service_did, + endpoint, + registered_by, + ) + .await + .expect("register failed"); + assert!(!reg_id.is_empty()); + + let all_regs = spaces_db::list_notify_registrations(&pool, backend, &space_id, None) + .await + .expect("list_notify_registrations failed"); + assert_eq!(all_regs.len(), 1); + assert_eq!(all_regs[0].id, reg_id); + assert_eq!(all_regs[0].endpoint, endpoint); + assert_eq!(all_regs[0].author_did, Some(service_did.to_string())); + + let by_did = spaces_db::list_notify_registrations(&pool, backend, &space_id, Some(service_did)) + .await + .expect("list_notify_registrations by did failed"); + assert_eq!(by_did.len(), 1); +} + +#[tokio::test] +#[serial] +async fn delete_notify_registration_removes_it() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:del-notify-owner", + "com.example.delnotify", + "dn-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let reg_id = notifications::register( + &pool, + backend, + &space_id, + "did:plc:svc", + "https://svc.example.com/n", + "did:plc:del-notify-owner", + ) + .await + .expect("register failed"); + + let deleted = spaces_db::delete_notify_registration(&pool, backend, ®_id) + .await + .expect("delete_notify_registration failed"); + assert!(deleted); + + let remaining = spaces_db::list_notify_registrations(&pool, backend, &space_id, None) + .await + .expect("list after delete failed"); + assert!(remaining.is_empty()); +} + +// --------------------------------------------------------------------------- +// Space members +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn add_and_get_member() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:member-owner", + "com.example.member", + "member-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let member_did = "did:plc:new-member"; + let member = SpaceMember { + id: new_id(), + space_id: space_id.clone(), + did: member_did.to_string(), + access: SpaceAccess::Read, + is_delegation: false, + granted_by: Some("did:plc:member-owner".to_string()), + created_at: now_rfc3339(), + }; + spaces_db::add_member(&pool, backend, &member) + .await + .expect("add_member failed"); + + let fetched = spaces_db::get_member(&pool, backend, &space_id, member_did) + .await + .expect("get_member failed") + .expect("member not found"); + + assert_eq!(fetched.did, member_did); + assert_eq!(fetched.access, SpaceAccess::Read); + assert!(!fetched.is_delegation); +} + +#[tokio::test] +#[serial] +async fn remove_member() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space(&space_id, "did:plc:rm-owner", "com.example.rm", "rm-skey"); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let member_did = "did:plc:rm-member"; + let member = SpaceMember { + id: new_id(), + space_id: space_id.clone(), + did: member_did.to_string(), + access: SpaceAccess::Write, + is_delegation: false, + granted_by: None, + created_at: now_rfc3339(), + }; + spaces_db::add_member(&pool, backend, &member) + .await + .expect("add_member failed"); + + let removed = spaces_db::remove_member(&pool, backend, &space_id, member_did) + .await + .expect("remove_member failed"); + assert!(removed); + + let after = spaces_db::get_member(&pool, backend, &space_id, member_did) + .await + .expect("get_member after remove failed"); + assert!(after.is_none()); +} + +// --------------------------------------------------------------------------- +// Space records +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn upsert_and_get_space_record() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:rec-owner", + "com.example.rec", + "rec-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let author_did = "did:plc:rec-author"; + let uri = format!("at://{author_did}/com.example.item/rec001"); + let record = SpaceRecord { + uri: uri.clone(), + space_id: space_id.clone(), + author_did: author_did.to_string(), + collection: "com.example.item".to_string(), + rkey: "rec001".to_string(), + record: serde_json::json!({"title": "hello"}), + cid: "bafycid001".to_string(), + indexed_at: now_rfc3339(), + }; + + spaces_db::upsert_space_record(&pool, backend, &record) + .await + .expect("upsert_space_record failed"); + + let fetched = spaces_db::get_space_record(&pool, backend, &uri) + .await + .expect("get_space_record failed") + .expect("record not found"); + + assert_eq!(fetched.uri, uri); + assert_eq!(fetched.record["title"], "hello"); + assert_eq!(fetched.cid, "bafycid001"); +} + +#[tokio::test] +#[serial] +async fn upsert_space_record_overwrites_existing() { + common::require_db!(); + let pool = test_db::test_pool().await; + let backend = test_db::test_backend(); + test_db::truncate_all(&pool).await; + + let space_id = new_id(); + let space = make_space( + &space_id, + "did:plc:upsert-owner", + "com.example.upsert", + "upsert-skey", + ); + spaces_db::create_space(&pool, backend, &space) + .await + .expect("create_space failed"); + + let uri = "at://did:plc:upsert-author/com.example.item/upd001"; + let first = SpaceRecord { + uri: uri.to_string(), + space_id: space_id.clone(), + author_did: "did:plc:upsert-author".to_string(), + collection: "com.example.item".to_string(), + rkey: "upd001".to_string(), + record: serde_json::json!({"v": 1}), + cid: "cid-v1".to_string(), + indexed_at: now_rfc3339(), + }; + spaces_db::upsert_space_record(&pool, backend, &first) + .await + .expect("first upsert failed"); + + let second = SpaceRecord { + record: serde_json::json!({"v": 2}), + cid: "cid-v2".to_string(), + ..first + }; + spaces_db::upsert_space_record(&pool, backend, &second) + .await + .expect("second upsert failed"); + + let fetched = spaces_db::get_space_record(&pool, backend, uri) + .await + .expect("get_space_record failed") + .expect("record not found"); + assert_eq!(fetched.record["v"], 2); + assert_eq!(fetched.cid, "cid-v2"); +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 805d3d8..3f22d07 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ "script-delete.spec.ts", "record-delete.spec.ts", "proxy-config.spec.ts", + "spaces.spec.ts", ], dependencies: ["setup"], use: { browserName: "chromium" }, diff --git a/web/tests/e2e/lexicon-services.spec.ts b/web/tests/e2e/lexicon-services.spec.ts index 08cf5bc..7ab149f 100644 --- a/web/tests/e2e/lexicon-services.spec.ts +++ b/web/tests/e2e/lexicon-services.spec.ts @@ -119,17 +119,10 @@ test.describe("Lexicon Services", () => { await expect(sheet.getByRole("heading", { name: "Services" })).toBeVisible() // Should show either service entries or "No services have access" - const hasServices = await sheet - .locator("table tbody tr") - .first() - .isVisible({ timeout: 3000 }) - .catch(() => false) - const hasNoServicesMessage = await sheet - .getByText(/no services have access/i) - .isVisible() - .catch(() => false) - - expect(hasServices || hasNoServicesMessage).toBe(true) + // (must wait for the loading state to resolve before checking) + const serviceRow = sheet.locator("table tbody tr").first() + const noServicesMsg = sheet.getByText(/no services have access/i) + await expect(serviceRow.or(noServicesMsg)).toBeVisible({ timeout: 5000 }) // Clean up the service entry await page.goto("/dashboard/settings/service-identity") diff --git a/web/tests/e2e/spaces.spec.ts b/web/tests/e2e/spaces.spec.ts new file mode 100644 index 0000000..0125bf3 --- /dev/null +++ b/web/tests/e2e/spaces.spec.ts @@ -0,0 +1,371 @@ +import { test, expect } from "@playwright/test" +import pg from "pg" +import { loginAsTestAdmin } from "./auth-helper" + +const TEST_TYPE_NSID = "com.example.testspace" +const TEST_SKEY = "e2e-test-space" +const DB_URL = "postgres://happyview:happyview@localhost:5434/happyview_test" + +async function enableSpacesFeature(): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + const now = new Date().toISOString() + await client.query( + `INSERT INTO happyview_instance_settings (key, value, updated_at) + VALUES ('feature.spaces_enabled', 'true', $1) + ON CONFLICT (key) DO UPDATE SET value = 'true', updated_at = $1`, + [now], + ) + } finally { + await client.end() + } +} + +test.describe("Spaces API", () => { + let createdSpaceUri: string | null = null + + test.beforeEach(async ({ page }) => { + await enableSpacesFeature() + await loginAsTestAdmin(page) + }) + + test.afterEach(async ({ page }) => { + if (!createdSpaceUri) return + await page.request.post("/xrpc/com.atproto.simplespace.deleteSpace", { + data: { space: createdSpaceUri }, + }) + createdSpaceUri = null + }) + + test("create space and verify it appears in listSpaces", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY, + displayName: "E2E Test Space", + mintPolicy: "member-list", + }, + }, + ) + + if (!createResp.ok()) { + const errBody = await createResp.text() + throw new Error(`createSpace failed (${createResp.status()}): ${errBody}`) + } + const createBody = await createResp.json() + expect(createBody).toHaveProperty("uri") + expect(createBody.uri).toMatch(/^ats:\/\//) + createdSpaceUri = createBody.uri + + const listResp = await page.request.get( + "/xrpc/com.atproto.space.listSpaces", + ) + expect(listResp.ok()).toBe(true) + const listBody = await listResp.json() + expect(listBody).toHaveProperty("spaces") + + const found = listBody.spaces.some( + (s: { uri: string }) => s.uri === createdSpaceUri, + ) + expect(found).toBe(true) + }) + + test("getSpace returns the created space", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-get", + displayName: "GetSpace Test", + }, + }, + ) + expect(createResp.ok()).toBe(true) + const { uri } = await createResp.json() + createdSpaceUri = uri + + const getResp = await page.request.get("/xrpc/com.atproto.space.getSpace", { + params: { space: uri }, + }) + expect(getResp.ok()).toBe(true) + const getBody = await getResp.json() + expect(getBody.space.display_name).toBe("GetSpace Test") + expect(getBody.space.mint_policy).toBe("member-list") + }) + + test("create duplicate space returns conflict", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-dup", + }, + }, + ) + expect(createResp.ok()).toBe(true) + createdSpaceUri = (await createResp.json()).uri + + const dupResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-dup", + }, + }, + ) + expect(dupResp.status()).toBe(409) + }) + + test("updateSpace changes display name", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-update", + displayName: "Before Update", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + const updateResp = await page.request.post( + "/xrpc/com.atproto.simplespace.updateSpace", + { data: { space: uri, displayName: "After Update" } }, + ) + expect(updateResp.ok()).toBe(true) + const updateBody = await updateResp.json() + expect(updateBody.space.display_name).toBe("After Update") + }) + + test("deleteSpace removes the space", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-delete", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + + const deleteResp = await page.request.post( + "/xrpc/com.atproto.simplespace.deleteSpace", + { data: { space: uri } }, + ) + expect(deleteResp.ok()).toBe(true) + + const getResp = await page.request.get( + "/xrpc/com.atproto.space.getSpace", + { params: { space: uri } }, + ) + expect(getResp.status()).toBe(404) + }) + + test("addMember returns 201", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-add-member", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + const addResp = await page.request.post( + "/xrpc/com.atproto.simplespace.addMember", + { data: { space: uri, did: "did:plc:test-member", access: "read" } }, + ) + expect(addResp.status()).toBe(201) + const addBody = await addResp.json() + expect(addBody.member.did).toBe("did:plc:test-member") + }) + + test("removeMember removes a previously added member", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-remove-member", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + await page.request.post("/xrpc/com.atproto.simplespace.addMember", { + data: { space: uri, did: "did:plc:test-member-rm", access: "read" }, + }) + + const removeResp = await page.request.post( + "/xrpc/com.atproto.simplespace.removeMember", + { data: { space: uri, did: "did:plc:test-member-rm" } }, + ) + expect(removeResp.ok()).toBe(true) + }) + + test("listMembers includes added member", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-list-members", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + await page.request.post("/xrpc/com.atproto.simplespace.addMember", { + data: { space: uri, did: "did:plc:test-member-list", access: "write" }, + }) + + const listResp = await page.request.get( + "/xrpc/com.atproto.simplespace.listMembers", + { params: { space: uri } }, + ) + expect(listResp.ok()).toBe(true) + const listBody = await listResp.json() + expect(listBody.members).toBeInstanceOf(Array) + const found = listBody.members.some( + (m: { did: string }) => m.did === "did:plc:test-member-list", + ) + expect(found).toBe(true) + }) + + test("getConfig returns space configuration", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-get-config", + mintPolicy: "member-list", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + const configResp = await page.request.get( + "/xrpc/com.atproto.simplespace.getConfig", + { params: { space: uri } }, + ) + expect(configResp.ok()).toBe(true) + const configBody = await configResp.json() + expect(configBody.mintPolicy).toBe("member-list") + }) + + test("updateConfig changes mint policy", async ({ page }) => { + const createResp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: TEST_TYPE_NSID, + skey: TEST_SKEY + "-update-config", + mintPolicy: "member-list", + }, + }, + ) + if (!createResp.ok()) { + throw new Error( + `createSpace failed (${createResp.status()}): ${await createResp.text()}`, + ) + } + const { uri } = await createResp.json() + createdSpaceUri = uri + + const updateResp = await page.request.post( + "/xrpc/com.atproto.simplespace.updateConfig", + { data: { space: uri, mintPolicy: "public" } }, + ) + expect(updateResp.ok()).toBe(true) + + const configResp = await page.request.get( + "/xrpc/com.atproto.simplespace.getConfig", + { params: { space: uri } }, + ) + expect(configResp.ok()).toBe(true) + const configBody = await configResp.json() + expect(configBody.mintPolicy).toBe("public") + }) +}) + +async function disableSpacesFeature(): Promise { + const client = new pg.Client(DB_URL) + await client.connect() + try { + await client.query( + `DELETE FROM happyview_instance_settings WHERE key = 'feature.spaces_enabled'`, + ) + } finally { + await client.end() + } +} + +test.describe("Spaces Feature Flag", () => { + test.beforeEach(async ({ page }) => { + await disableSpacesFeature() + await loginAsTestAdmin(page) + }) + + test("spaces endpoints return 404 when feature is disabled", async ({ + page, + }) => { + const resp = await page.request.post( + "/xrpc/com.atproto.simplespace.createSpace", + { + data: { + type: "com.example.test", + skey: "flag-test", + }, + }, + ) + expect(resp.status()).toBe(404) + const body = await resp.json() + expect(body.error).toBe("FeatureDisabled") + }) +})