From f3875b2535a455ae4f25e7845114c4d006324717 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 26 Jun 2026 19:42:33 -0500 Subject: [PATCH 1/3] feat: update to latest permissioned data spec Signed-off-by: Trezy Signed-off-by: Trezy --- Cargo.lock | 52 +- Cargo.toml | 3 + ...20260627000000_proposal_0016_alignment.sql | 71 ++ .../20260627000001_verification_methods.sql | 8 + ...20260627000000_proposal_0016_alignment.sql | 84 ++ .../20260627000001_verification_methods.sql | 8 + src/admin/mod.rs | 9 + src/admin/service_entries.rs | 18 +- src/admin/verification_methods.rs | 95 +++ src/auth/middleware.rs | 24 +- src/lexicon.rs | 211 +++++ src/lib.rs | 1 + src/lua/xrpc_api.rs | 4 + src/server.rs | 13 + src/service_identity.rs | 70 +- src/spaces/auth.rs | 268 +++++-- src/spaces/client_attestation.rs | 131 +++ src/spaces/commit.rs | 227 ++++++ src/spaces/credential.rs | 351 +++++--- src/spaces/db.rs | 354 +++++++-- src/spaces/integration_tests.rs | 575 ++++++++++++++ src/spaces/lthash.rs | 180 +++++ src/spaces/mod.rs | 10 + src/spaces/notifications.rs | 93 +++ src/spaces/oplog.rs | 98 +++ src/spaces/routes.rs | 749 ++++++++++-------- src/spaces/scope.rs | 141 ++++ src/spaces/simplespace.rs | 488 ++++++++++++ src/spaces/types.rs | 185 ++++- src/verification_methods.rs | 249 ++++++ tests/spaces_db.rs | 653 +++++++++++++++ web/playwright.config.ts | 1 + web/tests/e2e/lexicon-services.spec.ts | 15 +- web/tests/e2e/spaces.spec.ts | 371 +++++++++ 34 files changed, 5216 insertions(+), 594 deletions(-) create mode 100644 migrations/postgres/20260627000000_proposal_0016_alignment.sql create mode 100644 migrations/postgres/20260627000001_verification_methods.sql create mode 100644 migrations/sqlite/20260627000000_proposal_0016_alignment.sql create mode 100644 migrations/sqlite/20260627000001_verification_methods.sql create mode 100644 src/admin/verification_methods.rs create mode 100644 src/spaces/client_attestation.rs create mode 100644 src/spaces/commit.rs create mode 100644 src/spaces/integration_tests.rs create mode 100644 src/spaces/lthash.rs create mode 100644 src/spaces/notifications.rs create mode 100644 src/spaces/oplog.rs create mode 100644 src/spaces/scope.rs create mode 100644 src/spaces/simplespace.rs create mode 100644 src/verification_methods.rs create mode 100644 tests/spaces_db.rs create mode 100644 web/tests/e2e/spaces.spec.ts 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") + }) +}) -- 2.51.2 From 1eac9149a39eed64b9443b00e95109322b4ed2c6 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 26 Jun 2026 20:19:56 -0500 Subject: [PATCH 2/3] docs: update docs for permissioned spaces updates Signed-off-by: Trezy Signed-off-by: Trezy --- .../docs/api-reference/admin/admin-api.md | 3 +- .../docs/api-reference/admin/settings.md | 1 + .../content/docs/api-reference/xrpc-api.md | 16 +- .../docs/experimental/spaces/changelog.md | 59 +++++- .../docs/experimental/spaces/credentials.md | 126 ++++++------- .../content/docs/experimental/spaces/index.md | 124 ++++++++----- .../docs/experimental/spaces/invites.md | 22 +-- .../experimental/spaces/managing-spaces.md | 95 +++++----- .../docs/experimental/spaces/members.md | 46 ++--- .../docs/experimental/spaces/records.md | 74 ++++---- .../docs/getting-started/authentication.md | 10 +- .../content/docs/getting-started/dashboard.md | 12 ++ .../docs/content/docs/guides/lua-scripting.md | 22 +++ .../docs/content/docs/guides/permissions.md | 139 ++++++++++---- packages/docs/content/docs/index.md | 3 + .../content/docs/reference/architecture.md | 175 +++++++++++++++++- .../docs/content/docs/reference/glossary.md | 20 +- 17 files changed, 669 insertions(+), 278 deletions(-) diff --git a/packages/docs/content/docs/api-reference/admin/admin-api.md b/packages/docs/content/docs/api-reference/admin/admin-api.md index f389005..85c5aa0 100644 --- a/packages/docs/content/docs/api-reference/admin/admin-api.md +++ b/packages/docs/content/docs/api-reference/admin/admin-api.md @@ -6,10 +6,11 @@ The admin API lets you manage lexicons, monitor records, run backfill jobs, and ## Auth -The admin API supports two authentication methods: +The admin API supports three authentication methods: 1. **API keys** — read/write tokens starting with `hv_`, passed as `Authorization: Bearer hv_...`. See the [API Keys guide](../../guides/api-keys.md) for details. 2. **Service auth JWT** — atproto inter-service authentication via signed JWTs. +3. **Cookie-based session auth** — signed session cookies set during the dashboard OAuth login flow. The [web dashboard](../../getting-started/dashboard.md) uses this method. In all cases the resolved DID is checked against the `users` table, and the user's permissions are loaded to authorize the request. diff --git a/packages/docs/content/docs/api-reference/admin/settings.md b/packages/docs/content/docs/api-reference/admin/settings.md index d4ecb49..791ddfe 100644 --- a/packages/docs/content/docs/api-reference/admin/settings.md +++ b/packages/docs/content/docs/api-reference/admin/settings.md @@ -74,6 +74,7 @@ Returns all key/value pairs stored in the `instance_settings` table, plus any en | `backfill_concurrent_resolution` | `BACKFILL_CONCURRENT_RESOLUTION` | `100` | How many DID document lookups to run in parallel during PDS resolution | | `backfill_retention_days` | `BACKFILL_RETENTION_DAYS` | `28` | Days to keep per-repo detail data from completed backfill jobs. `0` = keep indefinitely | | `verbose_event_logging` | `VERBOSE_EVENT_LOGGING` | `false` | Log every record index, hook execution, and hook skip to the event log. High write volume — recommended only for debugging | +| `feature.spaces_enabled` | `FEATURE_SPACES_ENABLED` | --- | Enables the experimental Permissioned Spaces API. When `"true"`, space endpoints are available. When absent or any other value, space endpoints return `404 FeatureDisabled` | ## Upsert a setting diff --git a/packages/docs/content/docs/api-reference/xrpc-api.md b/packages/docs/content/docs/api-reference/xrpc-api.md index 3db76a5..a9452eb 100644 --- a/packages/docs/content/docs/api-reference/xrpc-api.md +++ b/packages/docs/content/docs/api-reference/xrpc-api.md @@ -8,8 +8,20 @@ If a query or procedure lexicon has a [Lua script](../guides/lua-scripting.md) a ## Auth -- **Queries** (`GET /xrpc/{method}`): unauthenticated -- **Procedures** (`POST /xrpc/{method}`): require DPoP authentication (`Authorization: DPoP` + `DPoP` proof header + `X-Client-Key`) +XRPC routes accept several authentication methods: + +- **DPoP auth** — `Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key` +- **Space credentials** — `Authorization: Bearer ` (space-scoped routes only) +- **Service auth JWTs** — `Authorization: Bearer ` (inter-service calls) +- **Cookie-based session auth** — signed session cookies (used by the dashboard, falls back when no `Authorization` header is present) +- **Anonymous** — no auth headers (identity is `nil` in Lua scripts) + +Bearer API keys (`hv_*`) are rejected on XRPC routes — they are only accepted on the [admin API](admin/admin-api.md). + +Default auth behavior: + +- **Queries** (`GET /xrpc/{method}`): unauthenticated by default (identity available if provided) +- **Procedures** (`POST /xrpc/{method}`): require authentication (DPoP, session cookie, or service auth) - **getProfile**: requires auth - **uploadBlob**: requires auth diff --git a/packages/docs/content/docs/experimental/spaces/changelog.md b/packages/docs/content/docs/experimental/spaces/changelog.md index fda0ec3..2b0aae6 100644 --- a/packages/docs/content/docs/experimental/spaces/changelog.md +++ b/packages/docs/content/docs/experimental/spaces/changelog.md @@ -2,7 +2,64 @@ title: "Changelog" --- -## Latest +## Latest — Proposal 0016 Alignment + +Major restructuring to align with [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data). + +### Namespace split + +- **Protocol routes** now live under `com.atproto.space.*` (queries, data, credentials) +- **Management routes** now live under `com.atproto.simplespace.*` (create/update/delete spaces, membership, config) +- **`dev.happyview.space.*`** endpoints remain as backward-compatible aliases until v3 +- Invite endpoints remain under `dev.happyview.space.*` as HappyView extensions + +### New terminology + +- **`owner_did` → `authority_did`** — the DID that controls the space. A separate `creator_did` tracks who originally created it. +- **`accessMode` → `mintPolicy`** — controls who can create permissioned repos: `member-list` (default), `public`, or `managing-app` +- **`appAllowlist`/`appDenylist` → `appAccess`** — controls third-party app access: `open` (default) or `allowList` +- **`getMemberGrant` → `getDelegationToken`** — renamed and changed from POST to GET. Returns a delegation token (JWT with `typ: atproto-space-delegation+jwt`, ES256K, 60-second TTL) +- **`redeemInvite` → `acceptInvite`** — renamed for clarity +- **Space credential `typ`** — changed from `space_credential` to `atproto-space-credential+jwt` +- **Space credential TTL** — reduced from 4 hours to 2 hours + +### New access level + +- **`read_self`** — a new membership access level that restricts reads to only the member's own records within the space + +### New endpoints + +- **`com.atproto.space.getRepoState`** (GET) — returns per-user repo state including LtHash state and signed commit +- **`com.atproto.space.listRepoOps`** (GET) — returns the record operation log for sync +- **`com.atproto.space.listRepos`** (GET) — lists repos (authors) in a space +- **`com.atproto.space.getBlob`** (GET) — retrieves a blob from a space +- **`com.atproto.space.registerNotify`** (POST) — registers for write notifications +- **`com.atproto.space.notifyWrite`** (POST) — pushes a write notification +- **`com.atproto.space.notifySpaceDeleted`** (POST) — pushes a space-deleted notification +- **`com.atproto.simplespace.getConfig`** (GET) — gets space configuration (mint policy, app access, managing app) +- **`com.atproto.simplespace.updateConfig`** (POST) — updates space configuration + +### Cryptographic primitives + +- **LtHash** — homomorphic set-hash for per-user repo state. 2048-byte state with 1024 little-endian uint16 lanes using BLAKE3 XOF. Supports insert/remove operations for incremental record tracking. +- **Deniable commit signatures** — users sign context (space DID + rev + random IKM) rather than content hash, producing a MAC that proves authorship without binding the user to specific content. + +### Data model changes + +- New `happyview_space_repo_state` table — per-user LtHash state + signed commit per space +- New `happyview_space_record_oplog` table — ordered record operation log per space +- New `happyview_space_notify_registrations` table — write notification registrations +- Spaces now use `authority_did` and `creator_did` instead of `owner_did` +- `mint_policy` and `app_access` columns replace `access_mode`, `app_allowlist`, `app_denylist` + +### Breaking changes + +- Feature flag disabled response changed from `501 Not Implemented` to `404` with `FeatureDisabled` error code +- Deleting a space now cascades to all associated data (records, members, repo state, oplog, notifications, credentials) + +--- + +## v2.6.0 ### New endpoints diff --git a/packages/docs/content/docs/experimental/spaces/credentials.md b/packages/docs/content/docs/experimental/spaces/credentials.md index a20bdcc..26bd1fe 100644 --- a/packages/docs/content/docs/experimental/spaces/credentials.md +++ b/packages/docs/content/docs/experimental/spaces/credentials.md @@ -6,11 +6,11 @@ title: "Credentials" This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. -Space credentials are short-lived JWTs for cross-service access to space data. A member proves their membership to get a grant, exchanges the grant for a credential JWT, then passes it to an external service that needs to read the space's records. +Space credentials are short-lived JWTs for cross-service access to space data. A member requests a delegation token to prove their membership, exchanges the token for a credential JWT, then passes it to an external service that needs to read the space's records. ## How credentials work -Credential issuance is a two-step process: +Credential issuance is a two-step process. The delegation token is a short-lived proof of membership (60-second TTL), and the credential is the bearer token used for cross-service access (2-hour TTL). ```mermaid sequenceDiagram @@ -18,12 +18,12 @@ sequenceDiagram participant HV as HappyView participant Svc as External Service - App->>HV: POST dev.happyview.space.getMemberGrant
(DPoP auth, must be a member) + App->>HV: GET com.atproto.space.getDelegationToken
(DPoP auth, must be a member) HV->>HV: Verify membership - HV-->>App: grant token + expiresAt + HV-->>App: delegation token + expiresAt - App->>HV: POST dev.happyview.space.getSpaceCredential
(DPoP auth, grant token) - HV->>HV: Verify grant
Check app access (allow/deny list)
Sign credential with space keypair + App->>HV: POST com.atproto.space.getSpaceCredential
(DPoP auth, delegation token) + HV->>HV: Verify delegation token
Check app access
Sign credential with space keypair HV-->>App: credential JWT + expiresAt App->>Svc: Request with Authorization: Bearer credential @@ -34,93 +34,84 @@ sequenceDiagram Credentials are ES256 JWTs signed with a P-256 keypair unique to each space. The keypair is generated on first credential request and stored encrypted (AES-256-GCM). -## Step 1: Get a member grant +## Step 1: Get a delegation token -The caller must be an authenticated member of the space. The grant is a short-lived token (5 minutes) that proves membership. +The caller must be an authenticated member of the space. The delegation token is a short-lived proof of membership (60-second TTL). + +Note: this endpoint is a GET request (not POST). The previous `getMemberGrant` endpoint (POST) is available as a legacy alias via `dev.happyview.space.getMemberGrant`. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", { - method: "POST", +const params = new URLSearchParams({ + space: "ats://did:plc:abc123/com.example.forum/main", +}); +const response = await fetch(`https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, - "Content-Type": "application/json", }, - body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", - }), }); -interface GrantResponse { - grant: string; +interface DelegationTokenResponse { + delegationToken: string; expiresAt: string; } -const data: GrantResponse = await response.json(); +const data: DelegationTokenResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", { - method: "POST", +const params = new URLSearchParams({ + space: "ats://did:plc:abc123/com.example.forum/main", +}); +const response = await fetch(`https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, "Authorization": `DPoP ${ACCESS_TOKEN}`, "DPoP": DPOP_PROOF, - "Content-Type": "application/json", }, - body: JSON.stringify({ - space: "ats://did:plc:abc123/com.example.forum/main", - }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant") + .get("https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken") + .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) - .json(&serde_json::json!({ - "space": "ats://did:plc:abc123/com.example.forum/main" - })) .send() .await?; let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" -body := bytes.NewBufferString(`{"space": "ats://did:plc:abc123/com.example.forum/main"}`) -req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant", body) +req, _ := http.NewRequest("GET", + "https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=ats%3A%2F%2Fdid%3Aplc%3Aabc123%2Fcom.example.forum%2Fmain", + nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) -req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getMemberGrant' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getDelegationToken?space=ats%3A%2F%2Fdid%3Aplc%3Aabc123%2Fcom.example.forum%2Fmain' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ - -H 'DPoP: ' \ - -H 'Content-Type: application/json' \ - -d '{ - "space": "ats://did:plc:abc123/com.example.forum/main" - }' + -H 'DPoP: ' ``` **Response:** ```json { - "grant": "eyJhbGciOiJIUzI1NiJ9...", - "expiresAt": "2026-05-09T12:05:00Z" + "delegationToken": "eyJhbGciOiJFUzI1NktFWSJ9...", + "expiresAt": "2026-05-09T12:01:00Z" } ``` ## Step 2: Get a space credential -Exchange the grant for a space credential JWT. The credential is signed by the space's keypair and has a 4-hour TTL. +Exchange the delegation token for a space credential JWT. The credential is signed by the space's keypair and has a 2-hour TTL. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -129,7 +120,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s "Content-Type": "application/json", }, body: JSON.stringify({ - grant: "eyJhbGciOiJIUzI1NiJ9...", + grant: "eyJhbGciOiJFUzI1NktFWSJ9...", }), }); interface CredentialResponse { @@ -139,7 +130,7 @@ interface CredentialResponse { const data: CredentialResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -148,28 +139,28 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s "Content-Type": "application/json", }, body: JSON.stringify({ - grant: "eyJhbGciOiJIUzI1NiJ9...", + grant: "eyJhbGciOiJFUzI1NktFWSJ9...", }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential") + .post("https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ - "grant": "eyJhbGciOiJIUzI1NiJ9..." + "grant": "eyJhbGciOiJFUzI1NktFWSJ9..." })) .send() .await?; let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" -body := bytes.NewBufferString(`{"grant": "eyJhbGciOiJIUzI1NiJ9..."}`) +body := bytes.NewBufferString(`{"grant": "eyJhbGciOiJFUzI1NktFWSJ9..."}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential", body) + "https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -177,13 +168,13 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCredential' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.getSpaceCredential' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ -H 'Content-Type: application/json' \ -d '{ - "grant": "eyJhbGciOiJIUzI1NiJ9..." + "grant": "eyJhbGciOiJFUzI1NktFWSJ9..." }' ``` @@ -192,7 +183,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.getSpaceCre ```json { "credential": "eyJhbGciOiJFUzI1NiJ9...", - "expiresAt": "2026-05-09T16:00:00Z" + "expiresAt": "2026-05-09T14:00:00Z" } ``` @@ -202,20 +193,19 @@ The JWT payload contains: | Claim | Description | |---|---| -| `iss` | The space's DID (who signed it) | -| `sub` | The member's DID (who it was issued to) | -| `space` | The full `ats://` space URI | -| `scope` | Access level (`read`) | +| `iss` | The space authority's DID (who signed it) | +| `sub` | The full `ats://` space URI | | `iat` | Issued at (Unix timestamp) | | `exp` | Expiry (Unix timestamp) | +| `jti` | Random nonce for replay protection | ## Using a credential -Pass the credential as a standard Bearer token in the `Authorization` header. HappyView distinguishes space credentials from other tokens by checking the JWT header's `typ` field (`space_credential`). +Pass the credential as a standard Bearer token in the `Authorization` header. HappyView distinguishes space credentials from other tokens by checking the JWT header's `typ` field (`atproto-space-credential+jwt`). ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -226,7 +216,7 @@ const data = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -237,7 +227,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[("space", "..."), ("collection", "..."), ("rkey", "...")]) .header("Authorization", format!("Bearer {}", space_credential)) .send() @@ -246,28 +236,28 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", nil) req.Header.Set("Authorization", "Bearer "+spaceCredential) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...' \ -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' ``` -No DPoP auth or client key is needed when authenticating via space credential — the credential itself is sufficient. The user's identity comes from the `sub` claim in the JWT. +No DPoP auth or client key is needed when authenticating via space credential — the credential itself is sufficient. The `sub` claim identifies the space being accessed. -HappyView verifies the credential by resolving the issuer's DID document, extracting the signing key, and validating the JWT signature and expiry. If valid, the request is treated as if the credential's `sub` is a member of the space. +HappyView verifies the credential by resolving the issuer's DID document, extracting the `#atproto_space` signing key, and validating the JWT signature and expiry. If valid, the request is granted read access to the space identified by `sub`. ## App access control Before issuing a credential, HappyView checks whether the calling app (identified by its DPoP client key) is allowed to access the space: -- **`default_allow` mode**: any app can get credentials unless it's on the `appDenylist` -- **`default_deny` mode**: only apps on the `appAllowlist` can get credentials +- **`open` (default)**: any app can get credentials +- **`allowList`**: only apps whose client metadata URL appears in the `allowed` array can get credentials -If no client key is present in the DPoP claims, the check is skipped (direct user access without an app intermediary). +For `open` spaces, requests without a client key are allowed. For `allowList` spaces, a client key is required — requests without one are rejected. ## External credential verification @@ -275,8 +265,8 @@ HappyView can also verify credentials issued by *other* HappyView instances or s 1. Decodes the JWT without verification to extract the `iss` (issuer DID) 2. Resolves the issuer's DID document -3. Extracts the signing key from the DID doc +3. Extracts the `#atproto_space` signing key from the DID doc 4. Verifies the JWT signature and expiry -5. Checks that the `space` claim matches the requested space +5. Checks that the `sub` claim matches the requested space A credential issued by one instance can be used to read from another instance that hosts the same space's data. diff --git a/packages/docs/content/docs/experimental/spaces/index.md b/packages/docs/content/docs/experimental/spaces/index.md index 803c99b..8fed136 100644 --- a/packages/docs/content/docs/experimental/spaces/index.md +++ b/packages/docs/content/docs/experimental/spaces/index.md @@ -3,7 +3,7 @@ title: "Overview" --- -Permissioned Spaces are experimental and the API will change. This implementation follows Daniel Holmgren's [Permissioned Data Diaries](https://dholms.leaflet.pub/3meluqcwky22a) and aligns structurally with the `permissioned-data` branch on `bluesky-social/atproto`, but uses a `dev.happyview` namespace to allow iteration while the official spec stabilizes. +Permissioned Spaces are experimental and the API will change. This implementation follows [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data). HappyView uses the `com.atproto.space.*` and `com.atproto.simplespace.*` namespaces. The previous `dev.happyview.space.*` endpoints remain available as backward-compatible aliases until v3. Spaces are containers for permissioned data in atproto. Unlike regular public records that live in a user's repo, space records are gated by membership — only members can read or write data within a space. @@ -71,65 +71,100 @@ curl -X PUT http://127.0.0.1:3000/admin/settings/feature.spaces_enabled \ -d '{"value": "true"}' ``` -When disabled, all `/xrpc/dev.happyview.space.*` endpoints return `501 Not Implemented`. +When disabled, all space endpoints return a `404` error with `FeatureDisabled` as the error code. ## Endpoints -All space endpoints live under the `dev.happyview.space` namespace and require [DPoP authentication](../../getting-started/authentication.md). - -| Endpoint | Method | Description | -| ---------------------------------------- | ------ | ------------------------------------- | -| `dev.happyview.space.createSpace` | POST | Create a space | -| `dev.happyview.space.getSpace` | GET | Get a space by URI | -| `dev.happyview.space.listSpaces` | GET | List spaces by membership | -| `dev.happyview.space.updateSpace` | POST | Update space metadata | -| `dev.happyview.space.deleteSpace` | POST | Delete a space | -| `dev.happyview.space.createRecord` | POST | Create a record (auto-generated rkey) | -| `dev.happyview.space.putRecord` | POST | Write a record | -| `dev.happyview.space.getRecord` | GET | Get a record | -| `dev.happyview.space.listRecords` | GET | List records | -| `dev.happyview.space.deleteRecord` | POST | Delete a record | -| `dev.happyview.space.applyWrites` | POST | Batch write operations | -| `dev.happyview.space.addMember` | POST | Add a member | -| `dev.happyview.space.removeMember` | POST | Remove a member | -| `dev.happyview.space.listMembers` | GET | List resolved members | -| `dev.happyview.space.createInvite` | POST | Create an invite | -| `dev.happyview.space.redeemInvite` | POST | Redeem an invite | -| `dev.happyview.space.revokeInvite` | POST | Revoke an invite | -| `dev.happyview.space.listInvites` | GET | List invites | -| `dev.happyview.space.getMemberGrant` | POST | Prove membership (step 1) | -| `dev.happyview.space.getSpaceCredential` | POST | Get a space credential (step 2) | +Space endpoints are split across two namespaces: + +- **`com.atproto.space.*`** — protocol-level routes (queries, data, credentials) +- **`com.atproto.simplespace.*`** — management routes (create/update/delete spaces, membership) + +The previous `dev.happyview.space.*` endpoints remain as backward-compatible aliases until v3. All endpoints require [DPoP authentication](../../getting-started/authentication.md) or cookie-based session auth. + +| Endpoint | Method | Description | +| --------------------------------------------- | ------ | ----------------------------------------------- | +| `com.atproto.simplespace.createSpace` | POST | Create a space | +| `com.atproto.space.getSpace` | GET | Get a space by URI | +| `com.atproto.space.listSpaces` | GET | List spaces by membership | +| `com.atproto.simplespace.updateSpace` | POST | Update space metadata | +| `com.atproto.simplespace.deleteSpace` | POST | Delete a space | +| `com.atproto.simplespace.getConfig` | GET | Get space configuration | +| `com.atproto.simplespace.updateConfig` | POST | Update space configuration | +| `com.atproto.space.createRecord` | POST | Create a record (auto-generated rkey) | +| `com.atproto.space.putRecord` | POST | Write a record | +| `com.atproto.space.getRecord` | GET | Get a record | +| `com.atproto.space.listRecords` | GET | List records | +| `com.atproto.space.deleteRecord` | POST | Delete a record | +| `com.atproto.space.applyWrites` | POST | Batch write operations | +| `com.atproto.simplespace.addMember` | POST | Add a member | +| `com.atproto.simplespace.removeMember` | POST | Remove a member | +| `com.atproto.simplespace.listMembers` | GET | List resolved members | +| `com.atproto.space.getRepoState` | GET | Get per-user repo state (LtHash + commit) | +| `com.atproto.space.listRepoOps` | GET | List record operation log entries | +| `com.atproto.space.listRepos` | GET | List repos (authors) in a space | +| `com.atproto.space.getDelegationToken` | GET | Get a delegation token (step 1 of credentials) | +| `com.atproto.space.getSpaceCredential` | POST | Get a space credential (step 2) | +| `com.atproto.space.getBlob` | GET | Get a blob from a space | +| `com.atproto.space.registerNotify` | POST | Register for write notifications | +| `com.atproto.space.notifyWrite` | POST | Push a write notification | +| `com.atproto.space.notifySpaceDeleted` | POST | Push a space-deleted notification | +| `dev.happyview.space.createInvite` | POST | Create an invite (HappyView extension) | +| `dev.happyview.space.acceptInvite` | POST | Accept an invite (HappyView extension) | +| `dev.happyview.space.revokeInvite` | POST | Revoke an invite (HappyView extension) | +| `dev.happyview.space.listInvites` | GET | List invites (HappyView extension) | ## Access model -Spaces have an **access mode** that controls third-party app access: +Spaces use two independent controls for access: -- **`default_allow`** — any app can access (with optional denylist) -- **`default_deny`** — only explicitly allowed apps can access +**Mint policy** controls who can create permissioned repos in the space: -Individual users access spaces through **membership**. Members have either `read` or `write` access. Write access implies read. The space creator is automatically added as a write member. +- **`member-list`** (default) — only members can create repos +- **`public`** — anyone can create repos +- **`managing-app`** — only the managing app can create repos + +**App access** controls which third-party apps can interact with the space: + +- **`open`** (default) — any app can access +- **`allowList`** — only explicitly listed apps can access + +Individual users access spaces through **membership**. Members have one of three access levels: + +- **`write`** — can read and write data +- **`read`** — can read all data in the space +- **`read_self`** — can only read their own data within the space + +Write access implies read. The space creator is automatically added as a write member. Spaces also support **delegation** — adding another space as a member, which transitively grants access to all members of the delegated space. -## Divergences from the reference spec +## Alignment with Proposal 0016 -HappyView mostly mirrors [Daniel Holmgren's `permissioned-data` branch](https://github.com/bluesky-social/atproto/tree/permissioned-data) but diverges in some areas. These will narrow as the official spec stabilizes. +HappyView implements [AT Protocol Proposal 0016](https://github.com/bluesky-social/proposals) (Permissioned Data) with some HappyView-specific extensions. -### HappyView extensions (not in the reference branch) +### Protocol features implemented -- **`isDelegation` on members** allows spaces to be members of other spaces -- **`displayName`, `description`, `accessMode` on spaces** — the reference space model is minimal (`uri`, `isOwner`, `isMember`, `createdAt`) -- **`appAllowlist` / `appDenylist` / `managingAppDid`** — app-level access control layer -- **`config` object** on spaces (e.g. `membershipPublic`, `recordsPublic`) -- **Invite system** — `createInvite`, `redeemInvite`, `revokeInvite`, `listInvites` -- **`read` / `write` access levels** — the reference branch treats membership as binary +- **Namespace split** — `com.atproto.space.*` for protocol routes, `com.atproto.simplespace.*` for management +- **Mint policy** — `member-list`, `public`, `managing-app` (replaces `accessMode`) +- **App access** — `open`, `allowList` (replaces `appAllowlist`/`appDenylist`) +- **Delegation tokens** — `getDelegationToken` (GET, 60-second TTL) replaces `getMemberGrant` +- **Space credentials** — `atproto-space-credential+jwt` typ, ES256, 2-hour TTL +- **Deniable commit signatures** — user signs context (space + rev + random IKM), not content hash +- **LtHash** — homomorphic set-hash (2048-byte state, 1024 uint16 lanes, BLAKE3 XOF) +- **Record operation log** — `listRepoOps` returns the oplog for sync +- **Repo state** — `getRepoState` returns LtHash state + signed commit +- **Write notifications** — `registerNotify`, `notifyWrite`, `notifySpaceDeleted` +- **Space-scoped blobs** — `getBlob` +- **Authority DID** — spaces use `authority_did` (not `owner_did`) with a separate `creator_did` -### Reference features not yet implemented +### HappyView extensions (not in the protocol spec) -- **Oplogs** — `getRepoOplog`, `getMemberOplog`, `getRepoState`, `getMemberState` (sync primitives for space data) -- **Push notifications** — `notifyWrite`, `notifyMembership` (service-to-service event delivery) -- **Space-scoped blobs** — `uploadBlob` for blobs within a space context -- **Owner record deletion** — in the reference branch the space owner can delete any record; HappyView restricts `deleteRecord` to the record's author only +- **Invite system** — `createInvite`, `acceptInvite`, `revokeInvite`, `listInvites` (under `dev.happyview.space.*`) +- **`isDelegation` on members** — allows spaces to be members of other spaces +- **`displayName`, `description` on spaces** — human-readable metadata +- **`config` object** — `membershipPublic`, `recordsPublic`, plus arbitrary extra fields +- **`read_self` access level** — restricts reads to the member's own data ## Next steps @@ -138,3 +173,4 @@ HappyView mostly mirrors [Daniel Holmgren's `permissioned-data` branch](https:// - [Records](./records.md) — read and write permissioned data - [Credentials](./credentials.md) — cross-service authentication for spaces - [Invites](./invites.md) — invite-based membership +- [Changelog](./changelog.md) — version history diff --git a/packages/docs/content/docs/experimental/spaces/invites.md b/packages/docs/content/docs/experimental/spaces/invites.md index 6c48bbe..981a8f5 100644 --- a/packages/docs/content/docs/experimental/spaces/invites.md +++ b/packages/docs/content/docs/experimental/spaces/invites.md @@ -109,7 +109,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createInvit | Field | Type | Required | Default | Description | |---|---|---|---|---| | `space` | string | Yes | | The space this invite is for | -| `access` | string | No | `read` | Access level granted on redemption (`read` or `write`) | +| `access` | string | No | `read` | Access level granted on acceptance (`read`, `read_self`, or `write`) | | `maxUses` | integer | No | unlimited | Maximum number of times the invite can be redeemed | | `expiresAt` | string (datetime) | No | never | When the invite expires | @@ -129,12 +129,12 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createInvit The `token` is only returned once. It is stored as a SHA-256 hash — HappyView cannot recover the plaintext. -## Redeeming an invite +## Accepting an invite -Any authenticated user can redeem an invite token to join the space. +Any authenticated user can accept an invite token to join the space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", { +const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -146,14 +146,14 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s token: "a1b2c3d4e5f6...", }), }); -interface RedeemInviteResponse { +interface AcceptInviteResponse { uri: string; access: string; } -const data: RedeemInviteResponse = await response.json(); +const data: AcceptInviteResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", { +const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -169,7 +169,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite") + .post("https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -183,7 +183,7 @@ let data: serde_json::Value = response.json().await?; ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{"token": "a1b2c3d4e5f6..."}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite", body) + "https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -191,7 +191,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.redeemInvite' \ +curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.acceptInvite' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -210,7 +210,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.redeemInvit } ``` -Redemption fails if: +Acceptance fails if: - The token is invalid (no matching hash found) - The invite has been revoked diff --git a/packages/docs/content/docs/experimental/spaces/managing-spaces.md b/packages/docs/content/docs/experimental/spaces/managing-spaces.md index 3287f3f..3601956 100644 --- a/packages/docs/content/docs/experimental/spaces/managing-spaces.md +++ b/packages/docs/content/docs/experimental/spaces/managing-spaces.md @@ -9,7 +9,7 @@ This API is experimental and will change. See the [Permissioned Spaces overview] ## Creating a space ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -22,7 +22,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s skey: "main", displayName: "My Forum", description: "A place for discussion", - accessMode: "default_allow", + mintPolicy: "member-list", }), }); interface CreateSpaceResponse { @@ -31,7 +31,7 @@ interface CreateSpaceResponse { const data: CreateSpaceResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -44,14 +44,14 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s skey: "main", displayName: "My Forum", description: "A place for discussion", - accessMode: "default_allow", + mintPolicy: "member-list", }), }); const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.createSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -60,7 +60,7 @@ let response = client "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" })) .send() .await?; @@ -72,10 +72,10 @@ body := bytes.NewBufferString(`{ "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.createSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -83,7 +83,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.createSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -93,7 +93,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace "skey": "main", "displayName": "My Forum", "description": "A place for discussion", - "accessMode": "default_allow" + "mintPolicy": "member-list" }' ``` @@ -105,7 +105,8 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace | `skey` | string | Yes | Space key; differentiates spaces of the same type | | `displayName` | string | No | Human-readable name | | `description` | string | No | Description of the space | -| `accessMode` | string | No | `default_allow` (default) or `default_deny` | +| `mintPolicy` | string | No | `member-list` (default), `public`, or `managing-app` | +| `appAccess` | object | No | `{"type": "open"}` (default) or `{"type": "allowList", "allowed": [...]}` | | `managingAppDid` | string | No | DID of the application that manages this space | | `config` | object | No | Space configuration (see below) | @@ -117,7 +118,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createSpace } ``` -The creator is automatically added as a write member. Use [`dev.happyview.space.getSpace`](#getting-a-space) to retrieve the full space object. +The creator is automatically added as a write member. Use [`com.atproto.space.getSpace`](#getting-a-space) to retrieve the full space object. ### Space configuration @@ -134,7 +135,7 @@ Additional fields are preserved as-is. ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -151,7 +152,7 @@ const data: Space = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -164,7 +165,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getSpace") + .get("https://happyview.example.com/xrpc/com.atproto.space.getSpace") .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -175,7 +176,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -183,7 +184,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getSpace?space=ats://did:plc:abc123/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -197,7 +198,7 @@ Returns spaces where the authenticated user is a member. ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", { headers: { "X-Client-Key": CLIENT_KEY, @@ -218,7 +219,7 @@ const data: ListSpacesResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", { headers: { "X-Client-Key": CLIENT_KEY, @@ -231,7 +232,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listSpaces") + .get("https://happyview.example.com/xrpc/com.atproto.space.listSpaces") .query(&[("limit", "20")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -242,7 +243,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -250,7 +251,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.listSpaces?limit=20' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -258,10 +259,11 @@ curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20 **Parameters:** -| Field | Type | Required | Default | Description | -| -------- | ------- | -------- | ------- | ---------------------------- | -| `limit` | integer | No | 50 | Max spaces to return (1-100) | -| `cursor` | string | No | | Pagination cursor | +| Field | Type | Required | Default | Description | +| -------- | ------- | -------- | -------------- | ---------------------------- | +| `did` | string | No | authenticated user | Filter by DID | +| `limit` | integer | No | 50 | Max spaces to return (1-100) | +| `cursor` | string | No | | Pagination cursor | **Response:** @@ -279,10 +281,10 @@ curl 'https://happyview.example.com/xrpc/dev.happyview.space.listSpaces?limit=20 ## Updating a space -Only the space owner or a HappView super admin can update a space. +Only the space authority or a HappyView super admin can update a space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -293,13 +295,12 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s body: JSON.stringify({ space: "ats://did:plc:abc123/com.example.forum/main", displayName: "Updated Forum Name", - accessMode: "default_deny", - appAllowlist: ["did:web:myapp.example.com"], + mintPolicy: "public", }), }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -310,22 +311,20 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s body: JSON.stringify({ space: "ats://did:plc:abc123/com.example.forum/main", displayName: "Updated Forum Name", - accessMode: "default_deny", - appAllowlist: ["did:web:myapp.example.com"], + mintPolicy: "public", }), }); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.updateSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) .json(&serde_json::json!({ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" })) .send() .await?; @@ -334,11 +333,10 @@ let response = client body := bytes.NewBufferString(`{ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.updateSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -346,7 +344,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.updateSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.updateSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -354,8 +352,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.updateSpace -d '{ "space": "ats://did:plc:abc123/com.example.forum/main", "displayName": "Updated Forum Name", - "accessMode": "default_deny", - "appAllowlist": ["did:web:myapp.example.com"] + "mintPolicy": "public" }' ``` @@ -363,10 +360,10 @@ All fields except `space` are optional. Only provided fields are updated. To cle ## Deleting a space -Only the space owner or a HappyView super admin can delete a space. +Only the space authority or a HappyView super admin can delete a space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -380,7 +377,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -395,7 +392,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -408,7 +405,7 @@ let response = client ```go tab="Go" tab-group="language" body := bytes.NewBufferString(`{"space": "ats://did:plc:abc123/com.example.forum/main"}`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -416,7 +413,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.deleteSpace' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -425,5 +422,5 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteSpace ``` -Deleting a space does not currently cascade to records, members, or credentials. This behavior may change. +Deleting a space cascades to all associated records, members, repo state, oplog entries, notification registrations, and credentials. diff --git a/packages/docs/content/docs/experimental/spaces/members.md b/packages/docs/content/docs/experimental/spaces/members.md index 30bf6e6..b39ce4b 100644 --- a/packages/docs/content/docs/experimental/spaces/members.md +++ b/packages/docs/content/docs/experimental/spaces/members.md @@ -6,14 +6,14 @@ title: "Members" This API is experimental and will change. See the [Permissioned Spaces overview](../spaces.md) for context. -Membership determines who can read and write within a space. Members have either `read` or `write` access — write implies read. +Membership determines who can read and write within a space. Members have one of three access levels — `write`, `read`, or `read_self`. Write implies read. `read_self` restricts the member to reading only their own records within the space. ## Adding a member -Only the space owner or a super admin can add members. +Only the space authority or a super admin can add members. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -40,7 +40,7 @@ interface Member { const data: { member: Member } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -59,7 +59,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.addMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -81,7 +81,7 @@ body := bytes.NewBufferString(`{ "isDelegation": false }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.addMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -89,7 +89,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.addMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -108,7 +108,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' |---|---|---|---|---| | `space` | string | Yes | | The space to add the member to | | `did` | string | Yes | | DID of the member (or space for delegation) | -| `access` | string | No | `read` | `read` or `write` | +| `access` | string | No | `read` | `read`, `read_self`, or `write` | | `isDelegation` | boolean | No | `false` | Whether this member is a delegated space | **Response (201):** @@ -130,7 +130,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' ## Removing a member ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.removeMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -145,7 +145,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.removeMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -161,7 +161,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.removeMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -178,7 +178,7 @@ body := bytes.NewBufferString(`{ "did": "did:plc:newmember" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.removeMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -186,7 +186,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.removeMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.removeMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -201,7 +201,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.removeMembe ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -218,7 +218,7 @@ const data: { members: ResolvedMember[] } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", { headers: { "X-Client-Key": CLIENT_KEY, @@ -231,7 +231,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listMembers") + .get("https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers") .query(&[("space", "ats://did:plc:abc123/com.example.forum/main")]) .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) @@ -242,7 +242,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main", + "https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -250,7 +250,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ +curl 'https://happyview.example.com/xrpc/com.atproto.simplespace.listMembers?space=ats://did:plc:abc123/com.example.forum/main' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -275,7 +275,7 @@ The response returns the **resolved** member list — delegation chains are trav A space can be added as a member of another space by setting `isDelegation: true`. This transitively grants access to all members of the delegated space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -292,7 +292,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.addMember", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -310,7 +310,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.addMember") + .post("https://happyview.example.com/xrpc/com.atproto.simplespace.addMember") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -331,7 +331,7 @@ body := bytes.NewBufferString(`{ "isDelegation": true }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.addMember", body) + "https://happyview.example.com/xrpc/com.atproto.simplespace.addMember", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -339,7 +339,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.addMember' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.simplespace.addMember' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ diff --git a/packages/docs/content/docs/experimental/spaces/records.md b/packages/docs/content/docs/experimental/spaces/records.md index efb8f69..ee6ae30 100644 --- a/packages/docs/content/docs/experimental/spaces/records.md +++ b/packages/docs/content/docs/experimental/spaces/records.md @@ -18,7 +18,7 @@ ats:// did:plc:abcdefghijklmnop1234567890 / com.example.forum / main / di Requires `write` membership in the space. The rkey is auto-generated using a TID. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.createRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -43,7 +43,7 @@ interface CreateRecordResponse { const data: CreateRecordResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.createRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.createRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -65,7 +65,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.createRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.createRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -93,7 +93,7 @@ body := bytes.NewBufferString(`{ } }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.createRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.createRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -101,7 +101,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.createRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -141,7 +141,7 @@ curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.createRecor Requires `write` membership in the space. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.putRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.putRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -167,7 +167,7 @@ interface PutRecordResponse { const data: PutRecordResponse = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.putRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.putRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -190,7 +190,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.putRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.putRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -220,7 +220,7 @@ body := bytes.NewBufferString(`{ } }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.putRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.putRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -228,7 +228,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.putRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.putRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -272,6 +272,8 @@ The author DID is taken from the authenticated user. You can only write records Requires `read` membership (or a valid [space credential](credentials.md)). +Members with `read_self` access can only retrieve their own records. Attempting to read another user's record returns `403 Forbidden`. + ```ts tab="TypeScript" tab-group="language" const params = new URLSearchParams({ space: "ats://did:plc:abc123/com.example.forum/main", @@ -279,7 +281,7 @@ const params = new URLSearchParams({ rkey: "3k2abc", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.getRecord?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.getRecord?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -302,7 +304,7 @@ const params = new URLSearchParams({ rkey: "3k2abc", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.getRecord?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.getRecord?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -315,7 +317,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[ ("space", "ats://did:plc:abc123/com.example.forum/main"), ("collection", "com.example.forum.post"), @@ -330,7 +332,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -338,7 +340,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&rkey=3k2abc' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -375,7 +377,7 @@ const params = new URLSearchParams({ limit: "20", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.listRecords?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.listRecords?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -402,7 +404,7 @@ const params = new URLSearchParams({ limit: "20", }); const response = await fetch( - `https://happyview.example.com/xrpc/dev.happyview.space.listRecords?${params}`, + `https://happyview.example.com/xrpc/com.atproto.space.listRecords?${params}`, { headers: { "X-Client-Key": CLIENT_KEY, @@ -415,7 +417,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.listRecords") + .get("https://happyview.example.com/xrpc/com.atproto.space.listRecords") .query(&[ ("space", "ats://did:plc:abc123/com.example.forum/main"), ("collection", "com.example.forum.post"), @@ -430,7 +432,7 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20", + "https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20", nil) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) @@ -438,7 +440,7 @@ req.Header.Set("DPoP", dpopProof) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.listRecords?space=ats://did:plc:abc123/com.example.forum/main&collection=com.example.forum.post&limit=20' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' @@ -475,7 +477,7 @@ curl 'https://happyview.example.com/xrpc/dev.happyview.space.listRecords?space=a You can only delete your own records. Requires `write` membership. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -491,7 +493,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s }); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -508,7 +510,7 @@ const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.s ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord") + .post("https://happyview.example.com/xrpc/com.atproto.space.deleteRecord") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -527,7 +529,7 @@ body := bytes.NewBufferString(`{ "rkey": "3k2abc" }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord", body) + "https://happyview.example.com/xrpc/com.atproto.space.deleteRecord", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -535,7 +537,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.deleteRecord' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.deleteRecord' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -563,7 +565,7 @@ Attempting to delete another user's record returns `403 Forbidden`. `applyWrites` performs multiple create, update, and delete operations in a single request. Requires `write` membership. ```ts tab="TypeScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.applyWrites", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -601,7 +603,7 @@ interface ApplyWritesResult { const data: { results: ApplyWritesResult[] } = await response.json(); ``` ```js tab="JavaScript" tab-group="language" -const response = await fetch("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", { +const response = await fetch("https://happyview.example.com/xrpc/com.atproto.space.applyWrites", { method: "POST", headers: { "X-Client-Key": CLIENT_KEY, @@ -636,7 +638,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .post("https://happyview.example.com/xrpc/dev.happyview.space.applyWrites") + .post("https://happyview.example.com/xrpc/com.atproto.space.applyWrites") .header("X-Client-Key", client_key) .header("Authorization", format!("DPoP {}", access_token)) .header("DPoP", &dpop_proof) @@ -690,7 +692,7 @@ body := bytes.NewBufferString(`{ ] }`) req, _ := http.NewRequest("POST", - "https://happyview.example.com/xrpc/dev.happyview.space.applyWrites", body) + "https://happyview.example.com/xrpc/com.atproto.space.applyWrites", body) req.Header.Set("X-Client-Key", clientKey) req.Header.Set("Authorization", "DPoP "+accessToken) req.Header.Set("DPoP", dpopProof) @@ -698,7 +700,7 @@ req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl -X POST 'https://happyview.example.com/xrpc/dev.happyview.space.applyWrites' \ +curl -X POST 'https://happyview.example.com/xrpc/com.atproto.space.applyWrites' \ -H 'X-Client-Key: hvc_...' \ -H 'Authorization: DPoP ' \ -H 'DPoP: ' \ @@ -779,7 +781,7 @@ Pass the `swapRecord` field on `putRecord`, `deleteRecord`, or individual operat Pass the `swapCommit` field on `applyWrites` to assert the space's current revision. If another client has written to the space since you last read its state, the operation fails with `409 Conflict` before any writes are applied. -The space's current revision is available as `revision` in the space object returned by `dev.happyview.space.getSpace`. +The space's current revision is available as `revision` in the space object returned by `com.atproto.space.getSpace`. ```json { @@ -795,7 +797,7 @@ Records can also be read using a [space credential](credentials.md) instead of d ```ts tab="TypeScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -806,7 +808,7 @@ const data = await response.json(); ``` ```js tab="JavaScript" tab-group="language" const response = await fetch( - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", { headers: { "Authorization": `Bearer ${SPACE_CREDENTIAL}`, @@ -817,7 +819,7 @@ const data = await response.json(); ``` ```rust tab="Rust" tab-group="language" let response = client - .get("https://happyview.example.com/xrpc/dev.happyview.space.getRecord") + .get("https://happyview.example.com/xrpc/com.atproto.space.getRecord") .query(&[("space", "..."), ("collection", "..."), ("rkey", "...")]) .header("Authorization", format!("Bearer {}", space_credential)) .send() @@ -826,13 +828,13 @@ let data: serde_json::Value = response.json().await?; ``` ```go tab="Go" tab-group="language" req, _ := http.NewRequest("GET", - "https://happyview.example.com/xrpc/dev.happyview.space.getRecord?space=...&collection=...&rkey=...", + "https://happyview.example.com/xrpc/com.atproto.space.getRecord?space=...&collection=...&rkey=...", nil) req.Header.Set("Authorization", "Bearer "+spaceCredential) resp, err := http.DefaultClient.Do(req) ``` ```sh tab="cURL" tab-group="language" -curl 'https://happyview.example.com/xrpc/dev.happyview.space.getRecord?...' \ +curl 'https://happyview.example.com/xrpc/com.atproto.space.getRecord?...' \ -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6InNwYWNlX2NyZWRlbnRpYWwifQ...' ``` diff --git a/packages/docs/content/docs/getting-started/authentication.md b/packages/docs/content/docs/getting-started/authentication.md index 3251c48..b26f8d7 100644 --- a/packages/docs/content/docs/getting-started/authentication.md +++ b/packages/docs/content/docs/getting-started/authentication.md @@ -130,7 +130,15 @@ curl 'https://happyview.example.com/xrpc/com.example.feed.getHot' \ Queries that don't care who is calling need nothing more than the client key. Procedures — and queries whose Lua scripts read the caller's DID — need a real atproto OAuth session. -XRPC routes only accept **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`). Bearer tokens and service auth JWTs are not accepted on XRPC endpoints. +XRPC routes accept several auth methods, resolved in this order: + +1. **DPoP auth** (`Authorization: DPoP ` + `DPoP` proof header + `X-Client-Key`) — used by third-party apps that went through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow. +2. **Bearer space credential** (`Authorization: Bearer `) — a signed JWT granting access to a specific space; accepted on space routes. +3. **Bearer service auth JWT** (`Authorization: Bearer `) — a standard atproto inter-service JWT signed by a DID's atproto signing key; the caller is identified as the issuer DID. +4. **Cookie session** — when no `Authorization` header is present, HappyView falls back to the signed session cookie set after dashboard login. +5. **Anonymous** — if none of the above is present, the request proceeds with no identity. The endpoint's Lua script determines whether that is acceptable. + +Bearer API keys (`hv_*`) are **not** accepted on XRPC endpoints — those are for admin API access only. Third-party apps authenticate users through the [DPoP key provisioning](#dpop-key-provisioning-for-third-party-apps) flow: your app gets a DPoP keypair from HappyView, runs a standard OAuth flow with the user's PDS using that keypair, then registers the resulting tokens back with HappyView. diff --git a/packages/docs/content/docs/getting-started/dashboard.md b/packages/docs/content/docs/getting-started/dashboard.md index 1e55bb8..9b78664 100644 --- a/packages/docs/content/docs/getting-started/dashboard.md +++ b/packages/docs/content/docs/getting-started/dashboard.md @@ -97,6 +97,18 @@ View the current values of all environment variables that affect HappyView's beh View the audit log of admin actions. Events include user creation, lexicon uploads, permission changes, backfill starts, and more. Each entry shows the event type, severity, actor, subject, and timestamp. Events are retained for the number of days configured by `EVENT_LOG_RETENTION_DAYS` (default 30). +### Service Identity + +Configure the AT Protocol service identity for your HappyView instance — either a `did:web` derived from your public URL, a `did:plc` you control, or a linked atproto account. This determines the DID that signs service-level interactions on the network. + +### Experiments + +Toggle experimental feature flags for your instance. Flags like `feature.spaces_enabled` can be enabled here before they are promoted to stable configuration options. + +### Scripts + +Manage script variables that are injected into Lua scripts at runtime. Variables defined here are available to all scripts and can be used to store shared configuration without hardcoding values in individual scripts. + ## About The **About** page shows the current HappyView version and instance configuration: public URL, database backend, Jetstream URL, relay URL, and PLC directory URL. diff --git a/packages/docs/content/docs/guides/lua-scripting.md b/packages/docs/content/docs/guides/lua-scripting.md index eda6062..b411f57 100644 --- a/packages/docs/content/docs/guides/lua-scripting.md +++ b/packages/docs/content/docs/guides/lua-scripting.md @@ -65,6 +65,28 @@ These globals are set automatically before `handle()` is called. | `caller_did` | string? | DID of the authenticated user (nil if unauthenticated) | | `env` | table | Script variables configured in the dashboard | +### Space globals + +When a script handles a space-scoped request, the `space` global is set to a table with the space's metadata. For non-space requests, `space` is `nil`. + +| Field | Type | Description | +| ----------- | ------ | -------------------------------------------------------- | +| `space` | string | The full `ats://` space URI | +| `space_id` | string | Internal space identifier | +| `did` | string | The space's DID | +| `owner_did` | string | The space authority's DID | +| `type_nsid` | string | Space type NSID | +| `skey` | string | Space key | + +```lua +function handle() + if space then + log("handling request for space: " .. space.space) + log("space type: " .. space.type_nsid) + end +end +``` + ## Utility globals Available in both queries and procedures: diff --git a/packages/docs/content/docs/guides/permissions.md b/packages/docs/content/docs/guides/permissions.md index 20e6105..3722fce 100644 --- a/packages/docs/content/docs/guides/permissions.md +++ b/packages/docs/content/docs/guides/permissions.md @@ -6,57 +6,120 @@ HappyView uses a granular permission system to control access to the admin API. ## Permission list -HappyView defines 20 permissions organized by category: +HappyView defines 44 permissions organized by category: ### Lexicons -| Permission | Description | -| ----------------- | ---------------------------------------------- | -| `lexicons:create` | Upload and upsert lexicons (local and network) | -| `lexicons:read` | List and view lexicon details | -| `lexicons:delete` | Delete lexicons | +| Permission | Description | +| ----------------- | ------------------------------------ | +| `lexicons:create` | Upload and register new lexicon schemas | +| `lexicons:read` | View registered lexicon schemas | +| `lexicons:delete` | Remove lexicon schemas | ### Records | Permission | Description | | --------------------------- | --------------------------------------- | -| `records:read` | List and view indexed records | -| `records:delete` | Delete individual records | +| `records:read` | Browse indexed AT Protocol records | +| `records:delete` | Delete individual records from the index | | `records:delete-collection` | Bulk-delete all records in a collection | +### Scripts + +| Permission | Description | +| ---------------- | ------------------------------------------------- | +| `scripts:read` | View trigger-keyed scripts | +| `scripts:manage` | Create, update, and delete trigger-keyed scripts | + ### Script Variables -| Permission | Description | -| ------------------------- | ----------------------------------------- | -| `script-variables:create` | Create and update script variables | -| `script-variables:read` | List script variables (values are masked) | -| `script-variables:delete` | Delete script variables | +| Permission | Description | +| ------------------------- | -------------------------------------------------- | +| `script-variables:create` | Add or update environment variables for Lua scripts | +| `script-variables:read` | View script environment variable keys and values | +| `script-variables:delete` | Remove script environment variables | ### Users -| Permission | Description | -| -------------- | -------------------------- | -| `users:create` | Add new users | -| `users:read` | List and view user details | -| `users:update` | Modify user permissions | -| `users:delete` | Remove users | +| Permission | Description | +| -------------- | -------------------------------------- | +| `users:create` | Add new dashboard users | +| `users:read` | View the user list and their permissions | +| `users:update` | Modify user permissions | +| `users:delete` | Remove dashboard users | ### API Keys -| Permission | Description | -| ----------------- | ------------------- | -| `api-keys:create` | Create new API keys | -| `api-keys:read` | List API keys | -| `api-keys:delete` | Revoke API keys | +| Permission | Description | +| ----------------- | ---------------------------------------- | +| `api-keys:create` | Generate new API keys for admin access | +| `api-keys:read` | View existing API keys | +| `api-keys:delete` | Revoke existing API keys | + +### Backfill + +| Permission | Description | +| ----------------- | ----------------------------------------- | +| `backfill:create` | Trigger historical record backfill jobs | +| `backfill:read` | View backfill job status and progress | + +### Labelers + +| Permission | Description | +| ----------------- | ------------------------------------- | +| `labelers:create` | Subscribe to external labeler services | +| `labelers:read` | View subscribed labeler services | +| `labelers:delete` | Unsubscribe from labeler services | + +### Settings + +| Permission | Description | +| ----------------- | --------------------------------------------------- | +| `settings:manage` | Modify instance settings, logo, and configuration | + +### Plugins + +| Permission | Description | +| ----------------- | -------------------------------------------- | +| `plugins:read` | View installed plugins and their configuration | +| `plugins:create` | Install and configure new plugins | +| `plugins:delete` | Uninstall plugins | + +### API Clients + +| Permission | Description | +| -------------------- | ---------------------------------------- | +| `api-clients:view` | View registered OAuth API clients | +| `api-clients:create` | Register new OAuth API clients | +| `api-clients:edit` | Modify API client settings and credentials | +| `api-clients:delete` | Remove registered API clients | + +### Dead Letters + +| Permission | Description | +| --------------------- | -------------------------------------- | +| `dead-letters:read` | View failed hook executions | +| `dead-letters:manage` | Retry, re-index, or dismiss dead letters | + +### Spaces + +| Permission | Description | +| --------------------------- | ------------------------------------------ | +| `spaces:create` | Create new permissioned data spaces | +| `spaces:read` | View space details and metadata | +| `spaces:update` | Modify space settings | +| `spaces:delete` | Remove spaces and their data | +| `spaces:manage-members` | Add or remove space members and roles | +| `spaces:manage-invites` | Create and revoke space invitations | +| `spaces:manage-records` | Read and write records within spaces | +| `spaces:manage-credentials` | Issue and revoke space access credentials | -### Operations +### System -| Permission | Description | -| ----------------- | ------------------------ | -| `backfill:create` | Start backfill jobs | -| `backfill:read` | View backfill job status | -| `stats:read` | View record statistics | -| `events:read` | Query the event log | +| Permission | Description | +| ------------ | ---------------------------------------- | +| `stats:read` | View collection statistics and record counts | +| `events:read` | View the event log | ## Permission templates @@ -64,25 +127,25 @@ Templates are predefined sets of permissions that simplify user creation. Pass a ### Viewer -Read-only access. Can browse lexicons, records, stats, events, and user lists but cannot modify anything. +Read-only access. Can browse lexicons, records, scripts, stats, events, dead letters, and user lists but cannot modify anything. -Includes: `lexicons:read`, `records:read`, `script-variables:read`, `users:read`, `api-keys:read`, `backfill:read`, `stats:read`, `events:read` +Includes: `lexicons:read`, `records:read`, `scripts:read`, `script-variables:read`, `users:read`, `api-keys:read`, `backfill:read`, `stats:read`, `events:read`, `dead-letters:read` ### Operator -Everything in Viewer, plus the ability to run backfill jobs and manage API keys. +Everything in Viewer, plus the ability to run backfill jobs, manage API keys, and manage dead letters. -Adds: `backfill:create`, `api-keys:create`, `api-keys:delete` +Adds: `backfill:create`, `api-keys:create`, `api-keys:delete`, `dead-letters:manage` ### Manager -Everything in Operator, plus the ability to manage lexicons, records, and script variables. +Everything in Operator, plus the ability to manage lexicons, records, scripts, labelers, settings, plugins, API clients, and spaces. -Adds: `lexicons:create`, `lexicons:delete`, `script-variables:create`, `script-variables:delete`, `records:delete` +Adds: `lexicons:create`, `lexicons:delete`, `scripts:manage`, `script-variables:create`, `script-variables:delete`, `records:delete`, `labelers:create`, `labelers:read`, `labelers:delete`, `settings:manage`, `plugins:read`, `plugins:create`, `plugins:delete`, `api-clients:view`, `api-clients:create`, `api-clients:edit`, `api-clients:delete`, `spaces:create`, `spaces:read`, `spaces:update`, `spaces:delete`, `spaces:manage-members`, `spaces:manage-invites`, `spaces:manage-records`, `spaces:manage-credentials` ### Full Access -All 20 permissions. Equivalent to granting every permission individually (but still not a super user). +All 44 permissions. Equivalent to granting every permission individually (but still not a super user). ## Super user diff --git a/packages/docs/content/docs/index.md b/packages/docs/content/docs/index.md index 917ac20..e28ab6e 100644 --- a/packages/docs/content/docs/index.md +++ b/packages/docs/content/docs/index.md @@ -16,6 +16,8 @@ Building an AppView from scratch means wiring up real-time event streams, record - **Protocol-native:** Works with any PDS, resolves DIDs through the directory, and fetches [network lexicons](guides/lexicons.md#network-lexicons) via DNS authority resolution. +- **Permissioned Spaces:** Experimental support for [AT Protocol Proposal 0016](experimental/spaces/index.md) — membership-gated data containers with per-user repo state, cross-service credentials, and write notifications. + - **Full admin surface:** Built-in [dashboard](getting-started/dashboard.md) and [admin API](api-reference/admin/admin-api.md) for managing lexicons, users, API keys, API clients, backfill jobs, and plugins. ## Design Principles @@ -36,4 +38,5 @@ Building an AppView from scratch means wiring up real-time event streams, record - [Record & Label Scripts](guides/label-scripts): React to record changes and label events in real time - [Labelers](guides/labelers.md): Subscribe to external labelers and manage content labels - [Plugins](guides/plugins.md): Integrate with external platforms using WASM plugins +- [Permissioned Spaces](experimental/spaces/index.md): Create membership-gated data containers with the AT Protocol spaces API - [Event Logs](guides/event-logs.md): Monitor system activity, debug script errors, and audit admin actions diff --git a/packages/docs/content/docs/reference/architecture.md b/packages/docs/content/docs/reference/architecture.md index 0563cef..fc6bf1a 100644 --- a/packages/docs/content/docs/reference/architecture.md +++ b/packages/docs/content/docs/reference/architecture.md @@ -16,14 +16,16 @@ graph LR subgraph HappyView Query["Query Handler
Lua Script (Optional)"] Procedure["Procedure Handler
Lua Script (Optional)"] + Spaces["Spaces
Permissioned Data"] end Procedure --> DB Query --> DB + Spaces --> DB Procedure -->|proxy write| PDS["User PDS"] - DB[("SQLite / PostgreSQL
records · lexicons")] + DB[("SQLite / PostgreSQL
records · lexicons · spaces")] Jetstream["Jetstream
WebSocket"] -->|record events| DB Relay["Relay
listReposByCollection"] -->|repo discovery| Backfill @@ -33,7 +35,7 @@ graph LR Labeler["Labeler
WebSocket (out-of-band)"] -->|label events| DB ``` -Queries go through the query handler to the database (SQLite by default, or Postgres). Writes go through the procedure handler to the user's PDS, then HappyView indexes the record locally. Real-time record events stream in via [Jetstream](https://github.com/bluesky-social/jetstream); historical records are backfilled in-process by discovering repos via the relay's `listReposByCollection` and fetching records directly from each PDS. [Labelers](../guides/labelers.md) are external services that emit content labels over a direct WebSocket connection — they operate out-of-band, outside the relay/repo system. +Queries go through the query handler to the database (SQLite by default, or Postgres). Writes go through the procedure handler to the user's PDS, then HappyView indexes the record locally. Real-time record events stream in via [Jetstream](https://github.com/bluesky-social/jetstream); historical records are backfilled in-process by discovering repos via the relay's `listReposByCollection` and fetching records directly from each PDS. [Labelers](../guides/labelers.md) are external services that emit content labels over a direct WebSocket connection — they operate out-of-band, outside the relay/repo system. [Spaces](../experimental/spaces/index.md) provide permissioned data containers with membership-gated access, per-user repo state tracking (LtHash + signed commits), and cross-service credential-based authentication. ## Request flow @@ -287,6 +289,175 @@ sequenceDiagram | `created_at` | timestamptz | | | `updated_at` | timestamptz | | +### `spaces` + +| Column | Type | Description | +| ----------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | Internal space identifier | +| `did` | text | The space's own DID | +| `authority_did` | text | DID that controls the space | +| `creator_did` | text | DID of the user who created the space | +| `type_nsid` | text | Space type as an NSID | +| `skey` | text | Space key (differentiates spaces of the same type) | +| `display_name` | text | Human-readable name (optional) | +| `description` | text | Description (optional) | +| `mint_policy` | text | `member-list`, `public`, or `managing-app` | +| `app_access` | text (JSON) | `{"type":"open"}` or `{"type":"allowList","allowed":[...]}` | +| `managing_app_did`| text | DID of the managing app (optional) | +| `config` | text (JSON) | Space config (`membershipPublic`, `recordsPublic`, extras) | +| `revision` | text | Current revision TID | +| `created_at` | text | | +| `updated_at` | text | | + +### `space_members` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `did` | text | Member's DID (or space URI for delegation) | +| `access` | text | `read`, `read_self`, or `write` | +| `is_delegation`| boolean | Whether this member is a delegated space | +| `granted_by` | text | DID of who granted membership | +| `created_at` | text | | + +### `space_records` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `uri` | text (PK) | `ats://` URI of the record | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the record author | +| `collection` | text | Lexicon NSID | +| `rkey` | text | Record key | +| `record` | jsonb | Record value | +| `cid` | text | Content identifier | +| `indexed_at` | text | | + +### `space_repo_state` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the repo author | +| `lthash_state` | bytea | 2048-byte LtHash state | +| `rev` | text | Current revision | +| `hash` | bytea | Content hash | +| `ikm` | bytea | Input keying material for deniable signatures | +| `sig` | bytea | Signature | +| `mac` | bytea | Message authentication code | +| `updated_at` | text | | + +### `space_record_oplog` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | DID of the operation author | +| `rev` | text | Revision this operation belongs to | +| `idx` | integer | Index within the revision | +| `action` | text | `create`, `update`, or `delete` | +| `collection` | text | Lexicon NSID | +| `rkey` | text | Record key | +| `cid` | text | Content identifier (for create/update) | +| `prev` | text | Previous CID (for update/delete) | +| `created_at` | text | | + +### `space_notify_registrations` + +| Column | Type | Description | +| -------------- | ----------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `author_did` | text | Filter by author DID (optional) | +| `endpoint` | text | Notification endpoint URL | +| `registered_by`| text | DID of who registered | +| `expires_at` | text | When the registration expires | +| `created_at` | text | | + +### `space_invites` + +| Column | Type | Description | +| ------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `token_hash` | text | SHA-256 hash of the invite token | +| `created_by` | text | DID of the user who created the invite | +| `access` | text | Access level granted: `read`, `read_self`, `write` | +| `max_uses` | integer? | Maximum number of uses (null = unlimited) | +| `uses` | integer | Current use count | +| `expires_at` | text? | Expiry timestamp (null = never) | +| `revoked` | boolean | Whether the invite has been revoked | +| `created_at` | text | | + +### `space_credentials` + +| Column | Type | Description | +| ------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `space_id` | text (FK) | References `spaces.id` | +| `issued_to` | text | DID the credential was issued to | +| `token_hash` | text | Hash of the credential token | +| `expires_at` | text | When the credential expires | +| `created_at` | text | | + +### `space_dids` + +| Column | Type | Description | +| ------------------ | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `did` | text | The space's DID | +| `space_id` | text (FK) | References `spaces.id` | +| `signing_key_enc` | text | Encrypted signing key (AES-256-GCM) | +| `rotation_key_enc` | text | Encrypted rotation key (AES-256-GCM) | +| `created_by` | text | DID of who provisioned the key | +| `created_at` | text | | + +### `service_identity` + +| Column | Type | Description | +| --------------------- | ----------- | ------------------------------------------------ | +| `id` | integer (PK)| Always 1 (singleton) | +| `mode` | text | `did_web`, `did_plc`, or `linked_account` | +| `did` | text | The service's DID | +| `signing_key_enc` | text | Encrypted signing key | +| `rotation_key_enc` | text? | Encrypted rotation key (did:plc only) | +| `attached_account_did`| text? | Linked account DID (linked_account mode) | +| `setup_complete` | boolean | Whether setup has been finalized | +| `created_at` | text | | +| `updated_at` | text | | + +### `service_entries` + +| Column | Type | Description | +| ------------- | ----------- | ------------------------------------------------ | +| `id` | integer (PK)| | +| `fragment_id` | text | DID document fragment identifier | +| `service_type`| text | Service type (e.g. `AtprotoAppView`) | +| `access_mode` | text | `all` or scoped to specific XRPCs | +| `created_at` | text | | +| `updated_at` | text | | + +### `service_entry_xrpcs` + +| Column | Type | Description | +| ------------------ | ----------- | ------------------------------------------------ | +| `service_entry_id` | integer (FK)| References `service_entries.id` | +| `lexicon_id` | text | Lexicon NSID this entry handles | + +### `verification_methods` + +| Column | Type | Description | +| ----------------------- | --------- | ------------------------------------------------ | +| `id` | text (PK) | | +| `fragment_id` | text | DID document fragment (e.g. `#atproto_space`) | +| `key_type` | text | Always `Multikey` | +| `public_key_multibase` | text | Public key in multibase encoding | +| `private_key_enc` | text | Encrypted private key (AES-256-GCM) | +| `created_at` | text | | + ### `backfill_jobs` | Column | Type | Description | diff --git a/packages/docs/content/docs/reference/glossary.md b/packages/docs/content/docs/reference/glossary.md index 85aff68..a25adab 100644 --- a/packages/docs/content/docs/reference/glossary.md +++ b/packages/docs/content/docs/reference/glossary.md @@ -36,13 +36,29 @@ Key terms used throughout the HappyView documentation. For a broader introductio ## HappyView-specific terms +**App Access** — Controls which third-party apps can interact with a space. Either `open` (any app) or `allowList` (only specified apps). Set via `com.atproto.simplespace.updateConfig`. + +**Authority DID** — The DID that controls a space. Distinct from the creator DID (who originally created it). Replaces the earlier `owner_did` concept. + **Backfill** — The process of bulk-indexing existing records from the network. HappyView discovers repos via the relay and fetches each repo's records directly from its PDS. Runs when a new record-type lexicon is uploaded or triggered manually. See [Backfill](../guides/backfill.md). +**Delegation Token** — A short-lived JWT (`typ: atproto-space-delegation+jwt`, ES256K, 60-second TTL) that proves a user is a member of a space. Used as step 1 of the credential issuance flow. Obtained via `com.atproto.space.getDelegationToken`. + +**LtHash** — A homomorphic set-hash used for per-user repo state in spaces. Uses a 2048-byte state with 1024 little-endian uint16 lanes and BLAKE3 XOF. Supports incremental insert/remove operations. + +**Mint Policy** — Controls who can create permissioned repos in a space: `member-list` (only members), `public` (anyone), or `managing-app` (only the managing app). + **Network lexicon** — A lexicon fetched directly from the atproto network via DNS authority resolution, rather than uploaded manually. See [Lexicons - Network lexicons](../guides/lexicons.md#network-lexicons). -**Permission** — A granular access control right that authorizes a specific action in the admin API. HappyView defines 20 permissions organized by category (e.g. `lexicons:create`, `users:read`). See [Permissions](../guides/permissions.md). +**Permission** — A granular access control right that authorizes a specific action in the admin API. HappyView defines 44 permissions organized by category (e.g. `lexicons:create`, `users:read`). See [Permissions](../guides/permissions.md). + +**Permissioned Data** — AT Protocol data that is gated by membership in a space, as opposed to public repo data. Defined by AT Protocol Proposal 0016. + +**Permission template** — A predefined set of permissions that can be applied when creating a user. Templates are: **Viewer** (read-only access), **Operator** (viewer + backfill and API key management), **Manager** (operator + lexicon, record, spaces, and plugin management), and **Full Access** (all 44 permissions). + +**Space** — A container for permissioned data in AT Protocol. Identified by a space DID, type NSID, and space key (skey), forming an `ats://` URI. -**Permission template** — A predefined set of permissions that can be applied when creating a user. Templates are: **Viewer** (read-only access), **Operator** (viewer + backfill and API key management), **Manager** (operator + lexicon and record management), and **Full Access** (all 20 permissions). +**Space Credential** — A short-lived JWT (`typ: atproto-space-credential+jwt`, ES256, 2-hour TTL) for cross-service read access to space data. Signed by the space's P-256 keypair. Obtained by exchanging a delegation token via `com.atproto.space.getSpaceCredential`. **Super user** — The bootstrapped user created on first login to a fresh HappyView instance. The super user has unrestricted access to all endpoints regardless of permissions, can transfer super status to another user, and cannot be deleted. -- 2.51.2 From bc135cdc9fb07799e929423c507d3840405e479a Mon Sep 17 00:00:00 2001 From: Trezy Date: Sat, 27 Jun 2026 01:25:05 -0500 Subject: [PATCH 3/3] fix: address copilot feedback Signed-off-by: Trezy Signed-off-by: Trezy --- ...20260627000000_proposal_0016_alignment.sql | 2 +- .../docs/content/docs/guides/lua-scripting.md | 2 +- src/admin/verification_methods.rs | 17 ++++++++ src/lua/context.rs | 6 +-- src/spaces/auth.rs | 10 ++++- src/spaces/credential.rs | 39 +++++++++++++++++-- src/spaces/integration_tests.rs | 4 +- src/spaces/routes.rs | 39 +++++++++++++++++-- src/spaces/simplespace.rs | 30 ++------------ tests/e2e_feature_flags.rs | 6 +-- 10 files changed, 110 insertions(+), 45 deletions(-) diff --git a/migrations/sqlite/20260627000000_proposal_0016_alignment.sql b/migrations/sqlite/20260627000000_proposal_0016_alignment.sql index 8297e82..84860d7 100644 --- a/migrations/sqlite/20260627000000_proposal_0016_alignment.sql +++ b/migrations/sqlite/20260627000000_proposal_0016_alignment.sql @@ -39,7 +39,7 @@ 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'', + lthash_state BLOB NOT NULL DEFAULT (zeroblob(2048)), rev TEXT, hash BLOB, ikm BLOB, diff --git a/packages/docs/content/docs/guides/lua-scripting.md b/packages/docs/content/docs/guides/lua-scripting.md index b411f57..4887f97 100644 --- a/packages/docs/content/docs/guides/lua-scripting.md +++ b/packages/docs/content/docs/guides/lua-scripting.md @@ -74,7 +74,7 @@ When a script handles a space-scoped request, the `space` global is set to a tab | `space` | string | The full `ats://` space URI | | `space_id` | string | Internal space identifier | | `did` | string | The space's DID | -| `owner_did` | string | The space authority's DID | +| `authority_did` | string | The space authority's DID | | `type_nsid` | string | Space type NSID | | `skey` | string | Space key | diff --git a/src/admin/verification_methods.rs b/src/admin/verification_methods.rs index 9b39b0f..15c699b 100644 --- a/src/admin/verification_methods.rs +++ b/src/admin/verification_methods.rs @@ -32,6 +32,23 @@ pub(super) async fn create( ) -> Result<(StatusCode, Json), AppError> { auth.require(Permission::SettingsManage).await?; + if !body.fragment_id.starts_with('#') { + return Err(AppError::BadRequest( + "fragment_id must start with '#'".into(), + )); + } + let frag_body = &body.fragment_id[1..]; + if frag_body.is_empty() + || !frag_body + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(AppError::BadRequest( + "fragment_id must contain only alphanumeric characters and underscores after '#'" + .into(), + )); + } + let encryption_key = state .config .token_encryption_key diff --git a/src/lua/context.rs b/src/lua/context.rs index 66e25be..6492500 100644 --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -8,7 +8,7 @@ pub struct SpaceContext { pub space: String, pub space_id: String, pub did: String, - pub owner_did: String, + pub authority_did: String, pub type_nsid: String, pub skey: String, } @@ -21,7 +21,7 @@ fn set_space_context(lua: &Lua, space: Option<&SpaceContext>) -> LuaResult<()> { table.set("space", ctx.space.as_str())?; table.set("space_id", ctx.space_id.as_str())?; table.set("did", ctx.did.as_str())?; - table.set("owner_did", ctx.owner_did.as_str())?; + table.set("authority_did", ctx.authority_did.as_str())?; table.set("type_nsid", ctx.type_nsid.as_str())?; table.set("skey", ctx.skey.as_str())?; globals.set("space", table)?; @@ -274,7 +274,7 @@ mod tests { space: "ats://did:plc:owner/com.example.forum/main".into(), space_id: "space-123".into(), did: "did:plc:owner".into(), - owner_did: "did:plc:owner".into(), + authority_did: "did:plc:owner".into(), type_nsid: "com.example.forum".into(), skey: "main".into(), }; diff --git a/src/spaces/auth.rs b/src/spaces/auth.rs index edf971d..cfeaf7d 100644 --- a/src/spaces/auth.rs +++ b/src/spaces/auth.rs @@ -111,12 +111,20 @@ async fn check_user_access_with_managing_app( ) -> 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('#') { + let (did, fragment) = if let Some(pos) = managing_app.find('#') { (&managing_app[..pos], Some(&managing_app[pos + 1..])) } else { (managing_app, None) }; + if let Some(frag) = fragment + && frag != "atproto_pds" + { + return Err(AppError::BadRequest(format!( + "unsupported service fragment '#{frag}' for managing app" + ))); + } + // Resolve the managing app's PDS/service endpoint from its DID document. let endpoint = resolve_did_service_endpoint(http, did).await?; diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs index fa61b93..9252c55 100644 --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -25,6 +25,17 @@ pub fn peek_jwt_typ(token: &str) -> Option { header["typ"].as_str().map(|s| s.to_string()) } +/// Peek at a delegation token's payload to extract the `sub` (space URI) without verifying. +pub fn peek_delegation_sub(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?; + let claims: DelegationTokenClaims = serde_json::from_slice(&payload_bytes).ok()?; + Some(claims.sub) +} + /// 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(); @@ -69,6 +80,7 @@ pub fn sign_delegation_token( pub fn verify_delegation_token( token: &str, verifying_key: &K256VerifyingKey, + expected_aud: &str, ) -> Result { let parts: Vec<&str> = token.split('.').collect(); if parts.len() != 3 { @@ -132,6 +144,12 @@ pub fn verify_delegation_token( return Err(AppError::Auth("delegation token has expired".into())); } + if claims.aud != expected_aud { + return Err(AppError::Auth( + "delegation token audience does not match this host".into(), + )); + } + Ok(claims) } @@ -448,7 +466,7 @@ mod tests { let claims = make_delegation_claims(); let token = sign_delegation_token(&claims, &signing_key).unwrap(); - let verified = verify_delegation_token(&token, &verifying_key).unwrap(); + let verified = verify_delegation_token(&token, &verifying_key, &claims.aud).unwrap(); assert_eq!(verified.iss, claims.iss); assert_eq!(verified.sub, claims.sub); @@ -464,8 +482,21 @@ mod tests { let claims = make_delegation_claims(); let token = sign_delegation_token(&claims, &signing_key).unwrap(); - let result = verify_delegation_token(&token, &verifying_key); + let result = verify_delegation_token(&token, &verifying_key, &claims.aud); + assert!(result.is_err()); + } + + #[test] + fn delegation_rejects_wrong_aud() { + 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 result = + verify_delegation_token(&token, &verifying_key, "did:plc:wrong#atproto_space_host"); assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("audience")); } #[test] @@ -486,7 +517,7 @@ mod tests { }; let token = sign_delegation_token(&claims, &signing_key).unwrap(); - let result = verify_delegation_token(&token, &verifying_key); + let result = verify_delegation_token(&token, &verifying_key, &claims.aud); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("expired")); } @@ -510,7 +541,7 @@ mod tests { URL_SAFE_NO_PAD.encode(sig.to_bytes()) ); - let result = verify_delegation_token(&token, &verifying_key); + let result = verify_delegation_token(&token, &verifying_key, &claims.aud); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("typ")); } diff --git a/src/spaces/integration_tests.rs b/src/spaces/integration_tests.rs index 03dcc24..1494fa6 100644 --- a/src/spaces/integration_tests.rs +++ b/src/spaces/integration_tests.rs @@ -150,7 +150,7 @@ mod tests { 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(); + let verified_delegation = verify_delegation_token(&token, &vk, &delegation.aud).unwrap(); assert_eq!(verified_delegation.iss, "did:plc:member"); assert_eq!( verified_delegation.sub, @@ -203,7 +203,7 @@ mod tests { jti: make_jti(), }; let token = sign_delegation_token(&delegation, &sk).unwrap(); - let result = verify_delegation_token(&token, &vk); + let result = verify_delegation_token(&token, &vk, &delegation.aud); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("expired")); } diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index 4b4f11f..a4cbf1f 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -397,7 +397,7 @@ async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Resu return Ok(()); } Err(AppError::Forbidden( - "Only the space owner can perform this action".into(), + "Only the space authority can perform this action".into(), )) } @@ -452,6 +452,22 @@ fn content_cid(record: &serde_json::Value) -> String { format!("bafyrei{}", hex::encode(&hash[..20])) } +async fn resolve_client_id_url( + state: &AppState, + client_key: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT client_id_url FROM happyview_api_clients WHERE client_key = ?", + state.db_backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(client_key) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to look up API client: {e}")))?; + Ok(row.map(|(url,)| url)) +} + // --------------------------------------------------------------------------- // Space read handlers // --------------------------------------------------------------------------- @@ -1337,12 +1353,27 @@ async fn get_space_credential( k256::ecdsa::VerifyingKey::from(&signing_key) }; - let delegation_claims = - crate::spaces::credential::verify_delegation_token(&input.grant, &verifying_key)?; + let delegation_claims = { + let unverified_sub = crate::spaces::credential::peek_delegation_sub(&input.grant) + .ok_or_else(|| AppError::Auth("invalid delegation token".into()))?; + let space_did = crate::spaces::SpaceUri::parse(&unverified_sub) + .map(|u| u.did.clone()) + .unwrap_or_default(); + let expected_aud = format!("{space_did}#atproto_space_host"); + crate::spaces::credential::verify_delegation_token( + &input.grant, + &verifying_key, + &expected_aud, + )? + }; let space = resolve_space(&state, &delegation_claims.sub).await?; - let client_id = claims.client_key().map(|k| k.to_string()); + let client_id = if let Some(key) = claims.client_key() { + resolve_client_id_url(&state, key).await? + } else { + None + }; let issued = crate::spaces::auth::issue_credential( &state.db, state.db_backend, diff --git a/src/spaces/simplespace.rs b/src/spaces/simplespace.rs index 7a8fd82..e77603f 100644 --- a/src/spaces/simplespace.rs +++ b/src/spaces/simplespace.rs @@ -160,29 +160,6 @@ fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { .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( @@ -214,7 +191,7 @@ async fn require_space_admin(state: &AppState, space: &Space, did: &str) -> Resu return Ok(()); } Err(AppError::Forbidden( - "Only the space owner can perform this action".into(), + "Only the space authority can perform this action".into(), )) } @@ -380,8 +357,9 @@ async fn list_members( 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?; + let claims = require_auth(&xrpc_claims)?; + let member = + members::is_member(&state.db, state.db_backend, &space.id, claims.did()).await?; member.ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; } diff --git a/tests/e2e_feature_flags.rs b/tests/e2e_feature_flags.rs index 6718920..476ac30 100644 --- a/tests/e2e_feature_flags.rs +++ b/tests/e2e_feature_flags.rs @@ -62,7 +62,7 @@ async fn space_routes_blocked_when_flag_disabled() { .clone() .oneshot( Request::builder() - .uri("/xrpc/dev.happyview.space.list") + .uri("/xrpc/com.atproto.space.listSpaces") .body(Body::empty()) .unwrap(), ) @@ -99,7 +99,7 @@ async fn space_routes_allowed_after_enabling_flag() { .clone() .oneshot( Request::builder() - .uri("/xrpc/dev.happyview.space.list") + .uri("/xrpc/com.atproto.space.listSpaces") .body(Body::empty()) .unwrap(), ) @@ -151,7 +151,7 @@ async fn space_routes_blocked_again_after_disabling_flag() { .clone() .oneshot( Request::builder() - .uri("/xrpc/dev.happyview.space.list") + .uri("/xrpc/com.atproto.space.listSpaces") .body(Body::empty()) .unwrap(), ) -- 2.51.2