From 760e0aa17126383f86f0c201edc1d55d1f17fd0c Mon Sep 17 00:00:00 2001 From: Trezy Date: Sat, 2 May 2026 09:51:35 -0500 Subject: [PATCH 01/17] fix: add atproto/lex headers to prevent CORS errors --- src/server.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server.rs b/src/server.rs index 8e96995..1e1512a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -114,6 +114,8 @@ pub fn router(state: AppState) -> Router { axum::http::HeaderName::from_static("x-client-key"), axum::http::HeaderName::from_static("x-client-secret"), axum::http::HeaderName::from_static("dpop"), + axum::http::HeaderName::from_static("atproto-accept-labelers"), + axum::http::HeaderName::from_static("atproto-proxy"), ]) .allow_credentials(true), ) -- 2.51.2 From 29ca19a6c0d17e0a058aea86861d22478d68d865 Mon Sep 17 00:00:00 2001 From: Trezy Date: Sat, 2 May 2026 21:28:06 -0500 Subject: [PATCH 02/17] chore: linting issues --- src/record_refs.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/record_refs.rs b/src/record_refs.rs index 0a39527..cbf9d52 100644 --- a/src/record_refs.rs +++ b/src/record_refs.rs @@ -11,10 +11,8 @@ pub fn extract_at_uris(value: &Value) -> HashSet { fn collect_at_uris(value: &Value, uris: &mut HashSet) { match value { - Value::String(s) => { - if s.starts_with("at://") { - uris.insert(s.clone()); - } + Value::String(s) if s.starts_with("at://") => { + uris.insert(s.clone()); } Value::Array(arr) => { for item in arr { -- 2.51.2 From 480b970cba4fbc4f88f233828c34873a912ce0b6 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 4 May 2026 12:36:27 -0500 Subject: [PATCH 03/17] Add funding details for GitHub and Ko-fi Updated funding information to include GitHub Sponsors and Ko-fi usernames. --- .github/FUNDING.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..2e23367 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [trezy] +ko_fi: trezy -- 2.51.2 From 53f63dada520596765b401d726ba22f11b06b8ea Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:54:11 -0500 Subject: [PATCH 04/17] feat(xrpc-proxy): add settings to control the XRPC proxy --- src/admin/mod.rs | 5 + src/admin/proxy_config.rs | 85 +++++++ src/lib.rs | 2 + src/lua/atproto_api.rs | 3 + src/lua/db_api.rs | 3 + src/lua/execute.rs | 3 + src/lua/http_api.rs | 3 + src/lua/xrpc_api.rs | 3 + src/main.rs | 12 + src/proxy_config.rs | 191 ++++++++++++++++ src/xrpc/mod.rs | 10 + tests/common/app.rs | 3 + tests/e2e_proxy_config.rs | 198 ++++++++++++++++ tests/lua_atproto_api.rs | 3 + tests/lua_db_api.rs | 3 + .../dashboard/settings/xrpc-proxy/page.tsx | 214 ++++++++++++++++++ web/src/components/app-sidebar.tsx | 7 + web/src/lib/api.ts | 17 ++ 18 files changed, 765 insertions(+) create mode 100644 src/admin/proxy_config.rs create mode 100644 src/proxy_config.rs create mode 100644 tests/e2e_proxy_config.rs create mode 100644 web/src/app/dashboard/settings/xrpc-proxy/page.tsx diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 06020e0..5dec953 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -10,6 +10,7 @@ mod lexicons; mod network_lexicons; pub(crate) mod permissions; mod plugins; +mod proxy_config; mod records; mod script_variables; pub mod settings; @@ -76,6 +77,10 @@ pub fn admin_routes(_state: AppState) -> Router { "/settings/logo", put(settings::upload_logo).delete(settings::delete_logo), ) + .route( + "/settings/xrpc-proxy", + get(proxy_config::get).put(proxy_config::put), + ) .route( "/settings/{key}", put(settings::upsert).delete(settings::delete), diff --git a/src/admin/proxy_config.rs b/src/admin/proxy_config.rs new file mode 100644 index 0000000..4a07e2c --- /dev/null +++ b/src/admin/proxy_config.rs @@ -0,0 +1,85 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; + +use crate::AppState; +use crate::db::{adapt_sql, now_rfc3339}; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::proxy_config::{ProxyConfig, ProxyMode, validate_nsid_pattern}; + +use super::auth::UserAuth; +use super::permissions::Permission; + +const SETTING_KEY: &str = "xrpc_proxy_config"; + +/// GET /admin/settings/xrpc-proxy +pub(super) async fn get( + State(state): State, + auth: UserAuth, +) -> Result, AppError> { + auth.require(Permission::SettingsManage).await?; + + let config = (**state.proxy_config.load()).clone(); + Ok(Json(config)) +} + +/// PUT /admin/settings/xrpc-proxy +pub(super) async fn put( + State(state): State, + auth: UserAuth, + Json(mut config): Json, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + // Clear nsids for modes that don't use them + if matches!(config.mode, ProxyMode::Disabled | ProxyMode::Open) { + config.nsids.clear(); + } + + // Validate NSID patterns + for pattern in &config.nsids { + validate_nsid_pattern(pattern).map_err(AppError::BadRequest)?; + } + + let json = serde_json::to_string(&config) + .map_err(|e| AppError::Internal(format!("failed to serialize proxy config: {e}")))?; + + let backend = state.db_backend; + let now = now_rfc3339(); + let sql = adapt_sql( + r#" + INSERT INTO instance_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT (key) DO UPDATE SET value = ?, updated_at = ? + "#, + backend, + ); + sqlx::query(&sql) + .bind(SETTING_KEY) + .bind(&json) + .bind(&now) + .bind(&json) + .bind(&now) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to save proxy config: {e}")))?; + + // Update in-memory cache + state.proxy_config.store(std::sync::Arc::new(config)); + + log_event( + &state.db, + EventLog { + event_type: "setting.updated".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(SETTING_KEY.to_string()), + detail: serde_json::json!({ "value": json }), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/lib.rs b/src/lib.rs index e846b50..e6c615a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod lua; pub mod oauth; pub mod plugin; pub mod profile; +pub mod proxy_config; pub mod rate_limit; pub mod record_handler; pub mod record_refs; @@ -71,6 +72,7 @@ pub struct AppState { pub attestation_signer: Option>, pub official_registry: SharedRegistry, pub official_registry_config: RegistryConfig, + pub proxy_config: Arc>, } impl axum::extract::FromRef for axum_extra::extract::cookie::Key { diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index c536db1..7aa10d0 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -367,6 +367,9 @@ mod tests { )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs index 30c01e8..41e68bf 100644 --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -718,6 +718,9 @@ mod tests { )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/src/lua/execute.rs b/src/lua/execute.rs index ed4d97f..8f0d79d 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -1137,6 +1137,9 @@ mod tests { )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs index d6356d1..5e5b5d6 100644 --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -184,6 +184,9 @@ mod tests { )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs index 64958c8..295ae62 100644 --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -288,6 +288,9 @@ mod tests { )), official_registry_config: crate::plugin::official_registry::RegistryConfig::production( ), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + crate::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/src/main.rs b/src/main.rs index 0d69462..079e61b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -584,6 +584,17 @@ async fn main() { official_registry.clone(), ); + let proxy_config = { + let json_str = + happyview::admin::settings::get_setting(&db_pool, "xrpc_proxy_config", db_backend) + .await; + let config = json_str + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_default(); + info!(mode = ?config.mode, nsid_count = config.nsids.len(), "Loaded XRPC proxy config"); + std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new(config))) + }; + let state = AppState { config: config.clone(), http, @@ -602,6 +613,7 @@ async fn main() { attestation_signer, official_registry, official_registry_config, + proxy_config, }; jetstream::spawn(state.clone(), collections_rx); diff --git a/src/proxy_config.rs b/src/proxy_config.rs new file mode 100644 index 0000000..32c296a --- /dev/null +++ b/src/proxy_config.rs @@ -0,0 +1,191 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProxyMode { + Disabled, + Open, + Allowlist, + Blocklist, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyConfig { + pub mode: ProxyMode, + pub nsids: Vec, +} + +impl Default for ProxyConfig { + fn default() -> Self { + Self { + mode: ProxyMode::Open, + nsids: vec![], + } + } +} + +impl ProxyConfig { + pub fn allows(&self, nsid: &str) -> bool { + match self.mode { + ProxyMode::Disabled => false, + ProxyMode::Open => true, + ProxyMode::Allowlist => self.nsids.iter().any(|pattern| nsid_matches(pattern, nsid)), + ProxyMode::Blocklist => !self.nsids.iter().any(|pattern| nsid_matches(pattern, nsid)), + } + } +} + +fn nsid_matches(pattern: &str, nsid: &str) -> bool { + if let Some(prefix) = pattern.strip_suffix(".*") { + nsid.starts_with(prefix) + && nsid.len() > prefix.len() + && nsid.as_bytes()[prefix.len()] == b'.' + } else { + pattern == nsid + } +} + +pub fn validate_nsid_pattern(pattern: &str) -> Result<(), String> { + if pattern.is_empty() { + return Err("NSID pattern must not be empty".into()); + } + + let (base, is_wildcard) = if let Some(prefix) = pattern.strip_suffix(".*") { + (prefix, true) + } else { + (pattern, false) + }; + + let segments: Vec<&str> = base.split('.').collect(); + if segments.len() < 2 { + return Err(format!( + "NSID pattern must have at least two segments: {pattern}" + )); + } + + for segment in &segments { + if segment.is_empty() { + return Err(format!("NSID pattern has empty segment: {pattern}")); + } + if !segment + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-') + { + return Err(format!( + "NSID segment contains invalid characters: {pattern}" + )); + } + } + + if !is_wildcard && segments.len() < 3 { + return Err(format!( + "Exact NSID must have at least three segments: {pattern}" + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_open_with_empty_nsids() { + let config = ProxyConfig::default(); + assert_eq!(config.mode, ProxyMode::Open); + assert!(config.nsids.is_empty()); + } + + #[test] + fn disabled_blocks_everything() { + let config = ProxyConfig { + mode: ProxyMode::Disabled, + nsids: vec![], + }; + assert!(!config.allows("com.example.feed.getHot")); + assert!(!config.allows("anything.at.all")); + } + + #[test] + fn open_allows_everything() { + let config = ProxyConfig { + mode: ProxyMode::Open, + nsids: vec![], + }; + assert!(config.allows("com.example.feed.getHot")); + assert!(config.allows("anything.at.all")); + } + + #[test] + fn allowlist_exact_match() { + let config = ProxyConfig { + mode: ProxyMode::Allowlist, + nsids: vec!["com.example.feed.getHot".into()], + }; + assert!(config.allows("com.example.feed.getHot")); + assert!(!config.allows("com.example.feed.getCold")); + } + + #[test] + fn allowlist_wildcard() { + let config = ProxyConfig { + mode: ProxyMode::Allowlist, + nsids: vec!["com.example.*".into()], + }; + assert!(config.allows("com.example.feed.getHot")); + assert!(config.allows("com.example.anything")); + assert!(!config.allows("com.other.feed.getHot")); + } + + #[test] + fn blocklist_exact_match() { + let config = ProxyConfig { + mode: ProxyMode::Blocklist, + nsids: vec!["com.example.feed.getHot".into()], + }; + assert!(!config.allows("com.example.feed.getHot")); + assert!(config.allows("com.example.feed.getCold")); + } + + #[test] + fn blocklist_wildcard() { + let config = ProxyConfig { + mode: ProxyMode::Blocklist, + nsids: vec!["com.example.*".into()], + }; + assert!(!config.allows("com.example.feed.getHot")); + assert!(config.allows("com.other.feed.getHot")); + } + + #[test] + fn validate_valid_nsids() { + assert!(validate_nsid_pattern("com.example.feed.getHot").is_ok()); + assert!(validate_nsid_pattern("com.example.*").is_ok()); + assert!(validate_nsid_pattern("games.gamesgamesgamesgames.*").is_ok()); + assert!(validate_nsid_pattern("a.b.c").is_ok()); + } + + #[test] + fn validate_invalid_nsids() { + assert!(validate_nsid_pattern("").is_err()); + assert!(validate_nsid_pattern("*").is_err()); + assert!(validate_nsid_pattern("com").is_err()); + assert!(validate_nsid_pattern("com.example.*.foo").is_err()); + assert!(validate_nsid_pattern("com..example").is_err()); + assert!(validate_nsid_pattern(".com.example").is_err()); + assert!(validate_nsid_pattern("com.example.").is_err()); + } + + #[test] + fn roundtrip_json() { + let config = ProxyConfig { + mode: ProxyMode::Allowlist, + nsids: vec!["com.example.*".into()], + }; + let json = serde_json::to_string(&config).unwrap(); + let parsed: ProxyConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.mode, ProxyMode::Allowlist); + assert_eq!(parsed.nsids, vec!["com.example.*"]); + } +} diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs index 42d694a..093f1e7 100644 --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -296,6 +296,11 @@ pub async fn xrpc_get( let lexicon = match lexicon { Some(l) => l, None => { + if !state.proxy_config.load().allows(&method) { + return Err(AppError::Forbidden( + "NSID not allowed by proxy policy".into(), + )); + } let mut response = proxy_to_authority(&state, &method, &raw_query, None).await?; if let CheckResult::Allowed { remaining, @@ -383,6 +388,11 @@ pub async fn xrpc_post( let lexicon = match lexicon { Some(l) => l, None => { + if !state.proxy_config.load().allows(&method) { + return Err(AppError::Forbidden( + "NSID not allowed by proxy policy".into(), + )); + } let mut response = proxy_to_authority(&state, &method, &raw_query, Some(&body)).await?; if let CheckResult::Allowed { remaining, diff --git a/tests/common/app.rs b/tests/common/app.rs index cc4bbfe..c749a77 100644 --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -152,6 +152,9 @@ impl TestApp { happyview::plugin::official_registry::OfficialRegistryState::default(), )), official_registry_config: registry_config, + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + happyview::proxy_config::ProxyConfig::default(), + ))), }; let router = server::router(state.clone()); diff --git a/tests/e2e_proxy_config.rs b/tests/e2e_proxy_config.rs new file mode 100644 index 0000000..c985fa3 --- /dev/null +++ b/tests/e2e_proxy_config.rs @@ -0,0 +1,198 @@ +mod common; + +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +fn admin_put( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method(Method::PUT) + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +#[tokio::test] +#[serial] +#[ignore] +async fn get_proxy_config_returns_default() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["mode"], "open"); + assert_eq!(json["nsids"], json!([])); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn put_and_get_allowlist() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "allowlist", + "nsids": ["com.example.feed.*", "com.other.thing.getStuff"] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["mode"], "allowlist"); + assert_eq!( + json["nsids"], + json!(["com.example.feed.*", "com.other.thing.getStuff"]) + ); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn disabled_mode_clears_nsids() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "disabled", + "nsids": ["com.example.*"] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) + .await + .unwrap(); + + let json = json_body(resp).await; + assert_eq!(json["mode"], "disabled"); + assert_eq!(json["nsids"], json!([])); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn invalid_mode_rejected() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "yolo", + "nsids": [] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn invalid_nsid_rejected() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "allowlist", + "nsids": ["*"] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn requires_auth() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/settings/xrpc-proxy") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs index 52123f8..3302244 100644 --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -95,6 +95,9 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( ), domain_cache: happyview::domain::DomainCache::new(), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + happyview::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs index abf9754..ada3107 100644 --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -98,6 +98,9 @@ async fn test_state_with_pool(pool: sqlx::AnyPool, backend: DatabaseBackend) -> official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( ), domain_cache: happyview::domain::DomainCache::new(), + proxy_config: std::sync::Arc::new(arc_swap::ArcSwap::new(std::sync::Arc::new( + happyview::proxy_config::ProxyConfig::default(), + ))), } } diff --git a/web/src/app/dashboard/settings/xrpc-proxy/page.tsx b/web/src/app/dashboard/settings/xrpc-proxy/page.tsx new file mode 100644 index 0000000..dd910a1 --- /dev/null +++ b/web/src/app/dashboard/settings/xrpc-proxy/page.tsx @@ -0,0 +1,214 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { X } from "lucide-react" + +import { useCurrentUser } from "@/hooks/use-current-user" +import { + getProxyConfig, + updateProxyConfig, + type ProxyConfig, +} from "@/lib/api" +import { SiteHeader } from "@/components/site-header" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +const MODES = [ + { + value: "disabled" as const, + label: "Disabled", + description: "Block all proxy requests. Only locally registered lexicons are served.", + }, + { + value: "open" as const, + label: "Open", + description: + "Proxy all unrecognized NSIDs to their resolved authority. This is the default.", + }, + { + value: "allowlist" as const, + label: "Allowlist", + description: + "Only proxy NSIDs that match a pattern below. Everything else returns 403.", + }, + { + value: "blocklist" as const, + label: "Blocklist", + description: + "Proxy all NSIDs except those that match a pattern below.", + }, +] + +export default function XrpcProxySettingsPage() { + const { hasPermission } = useCurrentUser() + const canManage = hasPermission("settings:manage") + + const [mode, setMode] = useState("open") + const [nsids, setNsids] = useState([""]) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [notice, setNotice] = useState(null) + + const load = useCallback(async () => { + try { + const config = await getProxyConfig() + setMode(config.mode) + setNsids(config.nsids.length > 0 ? [...config.nsids, ""] : [""]) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const showNsids = mode === "allowlist" || mode === "blocklist" + + async function handleSave() { + setError(null) + setNotice(null) + setSaving(true) + try { + const filteredNsids = nsids.map((s) => s.trim()).filter(Boolean) + await updateProxyConfig({ mode, nsids: filteredNsids }) + setNotice("Proxy settings saved.") + await load() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + function handleNsidChange(index: number, value: string) { + const next = [...nsids] + next[index] = value + if (index === nsids.length - 1 && value.trim() !== "") { + next.push("") + } + setNsids(next) + } + + function handleNsidRemove(index: number) { + const next = nsids.filter((_, i) => i !== index) + if (next.length === 0 || next[next.length - 1].trim() !== "") { + next.push("") + } + setNsids(next) + } + + function handleNsidPaste( + index: number, + e: React.ClipboardEvent, + ) { + const text = e.clipboardData.getData("text") + const parts = text.split(/[,;\s\n]+/).map((s) => s.trim()).filter(Boolean) + if (parts.length <= 1) return + e.preventDefault() + const before = nsids.slice(0, index) + const after = nsids.slice(index + 1).filter((s) => s.trim() !== "") + const next = [...before, ...parts, ...after, ""] + setNsids(next) + } + + function handleNsidKeyDown( + index: number, + e: React.KeyboardEvent, + ) { + if (e.key === "Backspace" && nsids[index] === "" && nsids.length > 1) { + e.preventDefault() + handleNsidRemove(index) + } + } + + return ( + <> + +
+ {error &&

{error}

} + {notice && ( +

+ {notice} +

+ )} + +
+

Proxy Mode

+

+ Control which unrecognized XRPC methods are forwarded to their + resolved authority. Locally registered lexicons are always served + regardless of this setting. +

+
+ +
+ {MODES.map((m) => ( + + ))} +
+ + {showNsids && ( +
+ +

+ Enter NSID patterns. Use com.example.* to + match all NSIDs under a namespace. +

+
+ {nsids.map((val, index) => ( +
+ handleNsidChange(index, e.target.value)} + onKeyDown={(e) => handleNsidKeyDown(index, e)} + onPaste={(e) => handleNsidPaste(index, e)} + placeholder="com.example.feed.*" + className="font-mono text-sm" + disabled={!canManage} + /> + {nsids.length > 1 && val !== "" && ( + + )} +
+ ))} +
+
+ )} + +
+ +
+
+ + ) +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 53bff67..b7d55a0 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -17,6 +17,7 @@ import { IconInfoCircle, IconApps, IconArrowUpCircle, + IconArrowsShuffle, IconSkull, } from "@tabler/icons-react"; import Image from "next/image"; @@ -104,6 +105,12 @@ const systemItems: NavItem[] = [ icon: IconSettings, requiredPermissions: ["settings:manage"], }, + { + title: "XRPC Proxy", + url: "/dashboard/settings/xrpc-proxy", + icon: IconArrowsShuffle, + requiredPermissions: ["settings:manage"], + }, { title: "ENV Variables", url: "/dashboard/settings/env-variables", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ab2949c..b04b1b8 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -338,6 +338,23 @@ export function deleteLogo() { return apiFetch("/admin/settings/logo", { method: "DELETE" }) } +// Proxy config +export type ProxyConfig = { + mode: "disabled" | "open" | "allowlist" | "blocklist" + nsids: string[] +} + +export function getProxyConfig() { + return apiFetch("/admin/settings/xrpc-proxy") +} + +export function updateProxyConfig(config: ProxyConfig) { + return apiFetch("/admin/settings/xrpc-proxy", { + method: "PUT", + body: JSON.stringify(config), + }) +} + // Labelers export function getLabelers() { return apiFetch("/admin/labelers") -- 2.51.2 From fb1344b3a58932f2519c5e7bc55df2f21c90ae4b Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:54:31 -0500 Subject: [PATCH 05/17] docs: add info about the XRPC proxy --- .../docs/docs/getting-started/dashboard.md | 4 + .../docs/docs/reference/admin/xrpc-proxy.md | 93 +++++++++++++++++++ packages/docs/docs/reference/xrpc-api.md | 4 + packages/docs/sidebars.ts | 5 + 4 files changed, 106 insertions(+) create mode 100644 packages/docs/docs/reference/admin/xrpc-proxy.md diff --git a/packages/docs/docs/getting-started/dashboard.md b/packages/docs/docs/getting-started/dashboard.md index f8e361f..daf2e40 100644 --- a/packages/docs/docs/getting-started/dashboard.md +++ b/packages/docs/docs/getting-started/dashboard.md @@ -83,6 +83,10 @@ Manage installed plugins and configure plugin secrets. Plugins extend HappyView Configure labeler subscriptions for content labeling. See [Labelers](../guides/features/labelers.md) for details. +### XRPC Proxy + +Control which unrecognized XRPC methods are forwarded to their resolved authority. Choose from four modes: **Disabled** (block all proxy requests), **Open** (proxy everything — the default), **Allowlist** (only proxy NSIDs matching your patterns), or **Blocklist** (proxy everything except matching patterns). Allowlist and blocklist modes accept NSID patterns with trailing wildcards (e.g. `com.example.*`). Locally registered lexicons are always served regardless of this setting. See [XRPC Proxy](../reference/admin/xrpc-proxy.md) for the full API reference. + ### Environment Variables View the current values of all environment variables that affect HappyView's behavior. This is a read-only view — values are set via your deployment environment, not the dashboard. diff --git a/packages/docs/docs/reference/admin/xrpc-proxy.md b/packages/docs/docs/reference/admin/xrpc-proxy.md new file mode 100644 index 0000000..1a98f29 --- /dev/null +++ b/packages/docs/docs/reference/admin/xrpc-proxy.md @@ -0,0 +1,93 @@ +# Admin API: XRPC Proxy + +Control which unrecognized XRPC methods HappyView forwards to their resolved authority. Locally registered lexicons are always served regardless of this setting. + +All endpoints require the `settings:manage` permission. + +```sh +# All examples assume $TOKEN is an API key (hv_...) +AUTH="Authorization: Bearer $TOKEN" +``` + +## Get proxy config + +``` +GET /admin/settings/xrpc-proxy +``` + +```sh +curl http://127.0.0.1:3000/admin/settings/xrpc-proxy -H "$AUTH" +``` + +**Response**: `200 OK` + +```json +{ + "mode": "allowlist", + "nsids": ["com.example.feed.*", "games.gamesgamesgamesgames.*"] +} +``` + +Returns `{"mode": "open", "nsids": []}` when no config has been saved. + +## Update proxy config + +``` +PUT /admin/settings/xrpc-proxy +``` + +```sh +curl -X PUT http://127.0.0.1:3000/admin/settings/xrpc-proxy \ + -H "$AUTH" \ + -H "Content-Type: application/json" \ + -d '{ + "mode": "allowlist", + "nsids": ["com.example.feed.*"] + }' +``` + +**Response**: `204 No Content` + +Changes take effect immediately — no restart needed. + +### Modes + +| Mode | Behavior | +|------|----------| +| `disabled` | Block all proxy requests. Return `403` for every unrecognized NSID. | +| `open` | Proxy everything (default). Current behavior on a fresh install. | +| `allowlist` | Proxy only NSIDs matching a pattern in `nsids`. Return `403` for the rest. | +| `blocklist` | Proxy everything except NSIDs matching a pattern in `nsids`. | + +When mode is `disabled` or `open`, any `nsids` in the request body are ignored and stored as `[]`. + +### NSID patterns + +Patterns are dotted NSID identifiers. Trailing wildcards are supported: + +- `com.example.feed.getHot` — exact match +- `com.example.feed.*` — matches any NSID starting with `com.example.feed.` +- `games.gamesgamesgamesgames.*` — matches the entire namespace + +Mid-segment wildcards (e.g., `com.*.feed`) are not supported. + +### Validation errors + +| Status | Cause | +|--------|-------| +| `400` | An NSID pattern is empty, has fewer than two segments, contains invalid characters, or uses an unsupported wildcard | +| `422` | `mode` is not one of `disabled`, `open`, `allowlist`, `blocklist` | + +## Blocked request response + +When the proxy denies a request, the client receives: + +``` +403 Forbidden +``` + +```json +{ + "error": "NSID not allowed by proxy policy" +} +``` diff --git a/packages/docs/docs/reference/xrpc-api.md b/packages/docs/docs/reference/xrpc-api.md index 8fd6644..c6aeb65 100644 --- a/packages/docs/docs/reference/xrpc-api.md +++ b/packages/docs/docs/reference/xrpc-api.md @@ -177,6 +177,10 @@ HappyView proxies this to the user's PDS as `com.atproto.repo.putRecord`, then u **Response** for both: proxied from the user's PDS. +## XRPC proxy + +When a request targets an NSID that has no locally registered lexicon, HappyView resolves the NSID's authority via DNS and forwards the request. Admins can restrict which NSIDs are proxied — see [XRPC Proxy settings](admin/xrpc-proxy.md). + ## Errors All error responses return JSON with an `error` field: diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index c64e5d1..f9925df 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -357,6 +357,11 @@ const sidebars: SidebarsConfig = { id: "reference/admin/settings", label: "Instance Settings", }, + { + type: "doc", + id: "reference/admin/xrpc-proxy", + label: "XRPC Proxy", + }, { type: "doc", id: "reference/admin/domains", -- 2.51.2 From 0d50accb44e2f0648af986a6f06e38ebae69463b Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 09:59:07 -0500 Subject: [PATCH 06/17] docs: update the dashboard docs --- .../docs/docs/getting-started/dashboard.md | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/packages/docs/docs/getting-started/dashboard.md b/packages/docs/docs/getting-started/dashboard.md index daf2e40..b5292bf 100644 --- a/packages/docs/docs/getting-started/dashboard.md +++ b/packages/docs/docs/getting-started/dashboard.md @@ -4,11 +4,13 @@ HappyView ships with a web dashboard that provides a visual interface for everyt On a fresh deployment with no users in the database, the first person to log in to the dashboard is automatically bootstrapped as the super user with all permissions — so log in with the handle you want to own the instance first. -## Lexicons +## Data + +### Lexicons Navigate to **Lexicons** to see all uploaded lexicons. Each entry shows the NSID, type (record, query, procedure), and whether a Lua script is attached. -### Adding a lexicon +#### Adding a lexicon Click **Add Lexicon** and choose **Local** or **Network**. @@ -25,7 +27,7 @@ Toggle **Enable backfill** to index historical records when uploading a record-t **Network** lexicons are fetched from the atproto network. Enter an NSID (e.g. `xyz.statusphere.status`) and HappyView resolves the schema automatically. If found, the lexicon JSON is displayed in a read-only editor. Click **Add** to track it. Network lexicons are kept up to date via the Jetstream subscription. See [Lexicons - Network lexicons](../guides/indexing/lexicons.md#network-lexicons) for how resolution works. -### JSON editor +#### JSON editor The JSON editor provides real-time validation against the atproto Lexicon v1 schema: @@ -33,47 +35,39 @@ The JSON editor provides real-time validation against the atproto Lexicon v1 sch - Auto-complete for definition types (`record`, `query`, `procedure`, `subscription`), property types (`string`, `integer`, `boolean`, `ref`, `union`, `blob`, `cid-link`, etc.), and schema structure (`defs`, `main`, `properties`, `required`) - Enforces the required top-level shape: `lexicon`, `id`, and `defs.main` -### Lua editor +#### Lua editor The Lua editor provides context-aware code completions, including suggestions for the `Record`, `db`, `input`, and `params` APIs as well as Lua keywords, builtins, and standard library functions. It also has snippets for `if`, `for`, `function`, etc. See [Lua Scripting](../guides/scripting.md) for the full runtime reference and examples. -## Records +### Records Navigate to **Records** to browse all indexed atproto records. Records are grouped by collection and searchable. Each record shows its AT URI, author DID, and the raw record JSON. -## Backfill +### Backfill Navigate to **Backfill** to view and manage backfill jobs. You can start a new backfill for any record-type lexicon to import historical records from the network. The page shows job status, progress (repos processed / total), and record counts. See [Backfill](../guides/indexing/backfill.md) for how the process works. -## Users - -Navigate to **Users** to manage who can access the admin API and dashboard. You can add users by DID, assign permissions individually or via a template (`viewer`, `operator`, `manager`, `full_access`), and remove users. The super user is highlighted and has all permissions by default. See [Permissions](../guides/admin/permissions.md) for what each permission grants. +### Dead Letters -## Events +Navigate to **Dead Letters** to view records that failed to index. Each entry shows the AT URI, error reason, and the raw record payload. You can retry, reindex, or dismiss individual dead letters, or use bulk actions to handle many at once. The sidebar badge shows the count of unresolved dead letters. -Navigate to **Events** to 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). +## Access -## Settings +### Users -The **Settings** section contains several sub-pages: +Navigate to **Users** to manage who can access the admin API and dashboard. You can add users by DID, assign permissions individually or via a template (`viewer`, `operator`, `manager`, `full_access`), and remove users. The super user is highlighted and has all permissions by default. See [Permissions](../guides/admin/permissions.md) for what each permission grants. -### General +### API Keys -Configure instance-level settings: application name, logo, terms of service URL, and privacy policy URL. These values appear on OAuth authorization screens and can also be set via environment variables — dashboard values take precedence. +Create and revoke admin API keys for automation. Each key is scoped to specific permissions and tied to the creating user. See [API Keys](../guides/admin/api-keys.md) for details. ### API Clients Register and manage third-party API clients. Each client gets an `hvc_…` client key and `hvs_…` client secret. You can configure the client type (confidential or public), allowed origins, scopes, and per-client rate limits. See [Authentication — API client identification](authentication.md#xrpc-api-client-identification) for how clients are used. -### API Keys - -Create and revoke admin API keys for automation. Each key is scoped to specific permissions and tied to the creating user. See [API Keys](../guides/admin/api-keys.md) for details. - -### Users - -An alternative path to the top-level Users page for managing user accounts and permissions. +## Integrations ### Plugins @@ -83,17 +77,27 @@ Manage installed plugins and configure plugin secrets. Plugins extend HappyView Configure labeler subscriptions for content labeling. See [Labelers](../guides/features/labelers.md) for details. +## System + +### General + +Configure instance-level settings: application name, logo, terms of service URL, and privacy policy URL. These values appear on OAuth authorization screens and can also be set via environment variables — dashboard values take precedence. + ### XRPC Proxy Control which unrecognized XRPC methods are forwarded to their resolved authority. Choose from four modes: **Disabled** (block all proxy requests), **Open** (proxy everything — the default), **Allowlist** (only proxy NSIDs matching your patterns), or **Blocklist** (proxy everything except matching patterns). Allowlist and blocklist modes accept NSID patterns with trailing wildcards (e.g. `com.example.*`). Locally registered lexicons are always served regardless of this setting. See [XRPC Proxy](../reference/admin/xrpc-proxy.md) for the full API reference. -### Environment Variables +### ENV Variables View the current values of all environment variables that affect HappyView's behavior. This is a read-only view — values are set via your deployment environment, not the dashboard. -### Accounts +### Event Logs + +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). + +## About -Manage connected atproto accounts used by the instance. +The **About** page shows the current HappyView version and instance configuration: public URL, database backend, Jetstream URL, relay URL, and PLC directory URL. ## Next steps -- 2.51.2 From 8e746353b59b6717fc893859bee4254a0336ec2c Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 10:00:29 -0500 Subject: [PATCH 07/17] docs: fix admin API page headers --- packages/docs/docs/reference/admin/api-clients.md | 2 +- packages/docs/docs/reference/admin/api-keys.md | 2 +- packages/docs/docs/reference/admin/backfill.md | 2 +- packages/docs/docs/reference/admin/domains.md | 2 +- packages/docs/docs/reference/admin/events.md | 2 +- packages/docs/docs/reference/admin/labelers.md | 2 +- packages/docs/docs/reference/admin/lexicons.md | 2 +- packages/docs/docs/reference/admin/plugins.md | 2 +- packages/docs/docs/reference/admin/script-variables.md | 2 +- packages/docs/docs/reference/admin/settings.md | 2 +- packages/docs/docs/reference/admin/stats.md | 2 +- packages/docs/docs/reference/admin/users.md | 2 +- packages/docs/docs/reference/admin/xrpc-proxy.md | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/docs/docs/reference/admin/api-clients.md b/packages/docs/docs/reference/admin/api-clients.md index afb1d47..79be48f 100644 --- a/packages/docs/docs/reference/admin/api-clients.md +++ b/packages/docs/docs/reference/admin/api-clients.md @@ -1,4 +1,4 @@ -# Admin API: API Clients +# API Clients API clients identify third-party applications that call HappyView's XRPC endpoints. Every request — authenticated or not — needs an `X-Client-Key` header (or `client_key` query param). Requests without one get `401 Unauthorized`. The client key is HappyView's rate-limit bucket. diff --git a/packages/docs/docs/reference/admin/api-keys.md b/packages/docs/docs/reference/admin/api-keys.md index 3a34d43..1280707 100644 --- a/packages/docs/docs/reference/admin/api-keys.md +++ b/packages/docs/docs/reference/admin/api-keys.md @@ -1,4 +1,4 @@ -# Admin API: API Keys +# API Keys Manage API keys for programmatic access. See the [API Keys guide](../../guides/admin/api-keys.md) for usage details. diff --git a/packages/docs/docs/reference/admin/backfill.md b/packages/docs/docs/reference/admin/backfill.md index 3dff0c2..03736f6 100644 --- a/packages/docs/docs/reference/admin/backfill.md +++ b/packages/docs/docs/reference/admin/backfill.md @@ -1,4 +1,4 @@ -# Admin API: Backfill +# Backfill Create and monitor historical backfill jobs. See the [Backfill guide](../../guides/indexing/backfill.md) for background. diff --git a/packages/docs/docs/reference/admin/domains.md b/packages/docs/docs/reference/admin/domains.md index c747564..711ce9c 100644 --- a/packages/docs/docs/reference/admin/domains.md +++ b/packages/docs/docs/reference/admin/domains.md @@ -1,4 +1,4 @@ -# Admin API: Domains +# Domains Manage the domains a HappyView instance serves. Each domain gets its own atproto OAuth client identity. The primary domain is set from `PUBLIC_URL` on first boot. All endpoints require the `settings:manage` permission. diff --git a/packages/docs/docs/reference/admin/events.md b/packages/docs/docs/reference/admin/events.md index e7da90a..f54a7f3 100644 --- a/packages/docs/docs/reference/admin/events.md +++ b/packages/docs/docs/reference/admin/events.md @@ -1,4 +1,4 @@ -# Admin API: Event Logs +# Event Logs HappyView logs system events — lexicon changes, record operations, script errors, user actions, and more. See the [Event Logs guide](../../guides/admin/event-logs.md) for details on event types and retention. diff --git a/packages/docs/docs/reference/admin/labelers.md b/packages/docs/docs/reference/admin/labelers.md index eb08ccb..18b942c 100644 --- a/packages/docs/docs/reference/admin/labelers.md +++ b/packages/docs/docs/reference/admin/labelers.md @@ -1,4 +1,4 @@ -# Admin API: Labelers +# Labelers Manage external labeler subscriptions. See the [Labelers guide](../../guides/features/labelers.md) for background. diff --git a/packages/docs/docs/reference/admin/lexicons.md b/packages/docs/docs/reference/admin/lexicons.md index 03a4514..a951116 100644 --- a/packages/docs/docs/reference/admin/lexicons.md +++ b/packages/docs/docs/reference/admin/lexicons.md @@ -1,4 +1,4 @@ -# Admin API: Lexicons +# Lexicons Manage lexicons and network lexicons. See the [Lexicons guide](../../guides/indexing/lexicons.md) for background on how lexicons drive indexing and XRPC routing. diff --git a/packages/docs/docs/reference/admin/plugins.md b/packages/docs/docs/reference/admin/plugins.md index d519b8f..30393fc 100644 --- a/packages/docs/docs/reference/admin/plugins.md +++ b/packages/docs/docs/reference/admin/plugins.md @@ -1,4 +1,4 @@ -# Admin API: Plugins +# Plugins Plugins extend HappyView with WebAssembly modules sourced from the [official plugin registry](../../guides/features/plugins.md) or any URL serving a `manifest.json`. Most endpoints take a plugin manifest URL and load (or reload) the plugin in place — no restart needed. Encrypted plugin secrets require `TOKEN_ENCRYPTION_KEY` to be configured. diff --git a/packages/docs/docs/reference/admin/script-variables.md b/packages/docs/docs/reference/admin/script-variables.md index 66a1df3..8850992 100644 --- a/packages/docs/docs/reference/admin/script-variables.md +++ b/packages/docs/docs/reference/admin/script-variables.md @@ -1,4 +1,4 @@ -# Admin API: Script Variables +# Script Variables Script variables are encrypted key/value pairs available to Lua scripts via the `vars` global. Use them for secrets like API tokens. diff --git a/packages/docs/docs/reference/admin/settings.md b/packages/docs/docs/reference/admin/settings.md index 156f73e..e88724b 100644 --- a/packages/docs/docs/reference/admin/settings.md +++ b/packages/docs/docs/reference/admin/settings.md @@ -1,4 +1,4 @@ -# Admin API: Instance Settings +# Instance Settings Instance settings override environment variables at runtime — things like app name, ToS URL, privacy policy URL, and logo. Settings stored here take precedence over their env var equivalents. All endpoints require the `settings:manage` permission. diff --git a/packages/docs/docs/reference/admin/stats.md b/packages/docs/docs/reference/admin/stats.md index 32c6e7a..cab57b4 100644 --- a/packages/docs/docs/reference/admin/stats.md +++ b/packages/docs/docs/reference/admin/stats.md @@ -1,4 +1,4 @@ -# Admin API: Stats +# Stats ```sh # All examples assume $TOKEN is an API key (hv_...) diff --git a/packages/docs/docs/reference/admin/users.md b/packages/docs/docs/reference/admin/users.md index 87d3317..b7b608e 100644 --- a/packages/docs/docs/reference/admin/users.md +++ b/packages/docs/docs/reference/admin/users.md @@ -1,4 +1,4 @@ -# Admin API: Users +# Users Manage admin users and their permissions. See the [Permissions guide](../../guides/admin/permissions.md) for available permissions and templates. diff --git a/packages/docs/docs/reference/admin/xrpc-proxy.md b/packages/docs/docs/reference/admin/xrpc-proxy.md index 1a98f29..8aa5cf5 100644 --- a/packages/docs/docs/reference/admin/xrpc-proxy.md +++ b/packages/docs/docs/reference/admin/xrpc-proxy.md @@ -1,4 +1,4 @@ -# Admin API: XRPC Proxy +# XRPC Proxy Control which unrecognized XRPC methods HappyView forwards to their resolved authority. Locally registered lexicons are always served regardless of this setting. -- 2.51.2 From 590e9268ed61edfca7b7630b2c6c268f1b35f747 Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 10:06:52 -0500 Subject: [PATCH 08/17] docs: update Changelog links and titles --- packages/docs/scripts/generate-changelogs.mjs | 16 +------ packages/docs/sidebars.ts | 48 +++++++++---------- 2 files changed, 26 insertions(+), 38 deletions(-) diff --git a/packages/docs/scripts/generate-changelogs.mjs b/packages/docs/scripts/generate-changelogs.mjs index efe9c0f..a2cd883 100644 --- a/packages/docs/scripts/generate-changelogs.mjs +++ b/packages/docs/scripts/generate-changelogs.mjs @@ -7,28 +7,24 @@ const CHANGELOGS = [ { name: "HappyView", output: "docs/reference/changelog.md", - sidebarLabel: "Changelog", match: (tag) => /^v\d/.test(tag), formatVersion: (tag) => tag, }, { name: "@happyview/oauth-client", output: "docs/sdk/changelog-oauth-client.md", - sidebarLabel: "Changelog", match: (tag) => tag.startsWith("@happyview/oauth-client-v"), formatVersion: (tag) => tag.replace("@happyview/oauth-client-", ""), }, { name: "@happyview/oauth-client-browser", output: "docs/sdk/changelog-oauth-client-browser.md", - sidebarLabel: "Changelog", match: (tag) => tag.startsWith("@happyview/oauth-client-browser-v"), formatVersion: (tag) => tag.replace("@happyview/oauth-client-browser-", ""), }, { name: "@happyview/lex-agent", output: "docs/sdk/changelog-lex-agent.md", - sidebarLabel: "Changelog", match: (tag) => tag.startsWith("@happyview/lex-agent-v"), formatVersion: (tag) => tag.replace("@happyview/lex-agent-", ""), }, @@ -82,11 +78,7 @@ function buildMarkdown(changelog, releases) { if (matching.length === 0) return null; const lines = [ - "---", - `sidebar_label: "${changelog.sidebarLabel}"`, - "---", - "", - `# ${changelog.name} Changelog`, + `# ${changelog.name}`, "", ``, "", @@ -141,11 +133,7 @@ async function main() { let md = buildMarkdown(changelog, releases); if (!md) { md = [ - "---", - `sidebar_label: "${changelog.sidebarLabel}"`, - "---", - "", - `# ${changelog.name} Changelog`, + `# ${changelog.name}`, "", ``, "", diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index f9925df..60dd611 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -276,27 +276,6 @@ const sidebars: SidebarsConfig = { id: "sdk/oauth-client-browser", label: "Browser Client", }, - { - type: "category", - label: "Changelogs", - items: [ - { - type: "doc", - id: "sdk/changelog-oauth-client", - label: "OAuth Client", - }, - { - type: "doc", - id: "sdk/changelog-oauth-client-browser", - label: "Browser Client", - }, - { - type: "doc", - id: "sdk/changelog-lex-agent", - label: "Lex Agent", - }, - ], - }, ], }, { @@ -452,9 +431,30 @@ const sidebars: SidebarsConfig = { label: "Troubleshooting", }, { - type: "doc", - id: "reference/changelog", - label: "Changelog", + type: "category", + label: "Changelogs", + items: [ + { + type: "doc", + id: "reference/changelog", + label: "HappyView", + }, + { + type: "doc", + id: "sdk/changelog-oauth-client", + label: "oauth-client", + }, + { + type: "doc", + id: "sdk/changelog-oauth-client-browser", + label: "oauth-client-browser", + }, + { + type: "doc", + id: "sdk/changelog-lex-agent", + label: "lex-agent", + }, + ], }, ], }, -- 2.51.2 From 17cc6593f9887164810c518810bd8f06ead48c6a Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 29 Apr 2026 17:16:10 -0500 Subject: [PATCH 09/17] feat: permissioned spaces implementation --- .../postgres/20260429000000_create_spaces.sql | 19 + .../20260429000001_create_space_members.sql | 13 + .../20260429000002_create_space_records.sql | 14 + ...0260429000003_create_space_credentials.sql | 10 + .../20260429000004_create_space_invites.sql | 14 + .../20260429000005_create_space_dids.sql | 12 + ...20260429000006_create_space_sync_state.sql | 12 + .../sqlite/20260429000000_create_spaces.sql | 19 + .../20260429000001_create_space_members.sql | 13 + .../20260429000002_create_space_records.sql | 14 + ...0260429000003_create_space_credentials.sql | 10 + .../20260429000004_create_space_invites.sql | 14 + .../20260429000005_create_space_dids.sql | 12 + ...20260429000006_create_space_sync_state.sql | 12 + src/admin/permissions.rs | 43 +- src/lexicon.rs | 47 + src/lib.rs | 1 + src/lua/atproto_api.rs | 192 ++++ src/lua/context.rs | 87 ++ src/lua/execute.rs | 26 +- src/lua/mod.rs | 2 + src/lua/xrpc_api.rs | 2 + src/profile.rs | 26 +- src/server.rs | 1 + src/spaces/auth.rs | 315 ++++++ src/spaces/credential.rs | 284 +++++ src/spaces/db.rs | 780 ++++++++++++++ src/spaces/members.rs | 142 +++ src/spaces/mod.rs | 212 ++++ src/spaces/notifications.rs | 86 ++ src/spaces/routes.rs | 982 ++++++++++++++++++ src/spaces/sync.rs | 293 ++++++ src/spaces/types.rs | 248 +++++ src/xrpc/procedure.rs | 2 +- src/xrpc/query.rs | 6 +- 35 files changed, 3948 insertions(+), 17 deletions(-) create mode 100644 migrations/postgres/20260429000000_create_spaces.sql create mode 100644 migrations/postgres/20260429000001_create_space_members.sql create mode 100644 migrations/postgres/20260429000002_create_space_records.sql create mode 100644 migrations/postgres/20260429000003_create_space_credentials.sql create mode 100644 migrations/postgres/20260429000004_create_space_invites.sql create mode 100644 migrations/postgres/20260429000005_create_space_dids.sql create mode 100644 migrations/postgres/20260429000006_create_space_sync_state.sql create mode 100644 migrations/sqlite/20260429000000_create_spaces.sql create mode 100644 migrations/sqlite/20260429000001_create_space_members.sql create mode 100644 migrations/sqlite/20260429000002_create_space_records.sql create mode 100644 migrations/sqlite/20260429000003_create_space_credentials.sql create mode 100644 migrations/sqlite/20260429000004_create_space_invites.sql create mode 100644 migrations/sqlite/20260429000005_create_space_dids.sql create mode 100644 migrations/sqlite/20260429000006_create_space_sync_state.sql create mode 100644 src/spaces/auth.rs create mode 100644 src/spaces/credential.rs create mode 100644 src/spaces/db.rs create mode 100644 src/spaces/members.rs create mode 100644 src/spaces/mod.rs create mode 100644 src/spaces/notifications.rs create mode 100644 src/spaces/routes.rs create mode 100644 src/spaces/sync.rs create mode 100644 src/spaces/types.rs diff --git a/migrations/postgres/20260429000000_create_spaces.sql b/migrations/postgres/20260429000000_create_spaces.sql new file mode 100644 index 0000000..5d9f534 --- /dev/null +++ b/migrations/postgres/20260429000000_create_spaces.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS spaces ( + id TEXT PRIMARY KEY, + owner_did TEXT NOT NULL, + type_nsid TEXT NOT NULL, + skey TEXT NOT NULL, + display_name TEXT, + description TEXT, + access_mode TEXT NOT NULL DEFAULT 'default_allow', + app_allowlist TEXT, + app_denylist TEXT, + managing_app_did TEXT, + config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(owner_did, type_nsid, skey) +); + +CREATE INDEX idx_spaces_owner_did ON spaces(owner_did); +CREATE INDEX idx_spaces_type_nsid ON spaces(type_nsid); diff --git a/migrations/postgres/20260429000001_create_space_members.sql b/migrations/postgres/20260429000001_create_space_members.sql new file mode 100644 index 0000000..269dd30 --- /dev/null +++ b/migrations/postgres/20260429000001_create_space_members.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS space_members ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + member_did TEXT NOT NULL, + access TEXT NOT NULL DEFAULT 'read', + is_delegation INTEGER NOT NULL DEFAULT 0, + granted_by TEXT, + created_at TEXT NOT NULL, + UNIQUE(space_id, member_did) +); + +CREATE INDEX idx_space_members_did ON space_members(member_did); +CREATE INDEX idx_space_members_space_id ON space_members(space_id); diff --git a/migrations/postgres/20260429000002_create_space_records.sql b/migrations/postgres/20260429000002_create_space_records.sql new file mode 100644 index 0000000..d267b46 --- /dev/null +++ b/migrations/postgres/20260429000002_create_space_records.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS space_records ( + uri TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + record TEXT NOT NULL, + cid TEXT NOT NULL, + indexed_at TEXT NOT NULL +); + +CREATE INDEX idx_space_records_space_id ON space_records(space_id); +CREATE INDEX idx_space_records_author ON space_records(author_did); +CREATE INDEX idx_space_records_collection ON space_records(space_id, collection); diff --git a/migrations/postgres/20260429000003_create_space_credentials.sql b/migrations/postgres/20260429000003_create_space_credentials.sql new file mode 100644 index 0000000..6bd6055 --- /dev/null +++ b/migrations/postgres/20260429000003_create_space_credentials.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS space_credentials ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + issued_to TEXT NOT NULL, + token_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_space_credentials_space_id ON space_credentials(space_id); diff --git a/migrations/postgres/20260429000004_create_space_invites.sql b/migrations/postgres/20260429000004_create_space_invites.sql new file mode 100644 index 0000000..8726ece --- /dev/null +++ b/migrations/postgres/20260429000004_create_space_invites.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS space_invites ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL, + access TEXT NOT NULL DEFAULT 'read', + max_uses INTEGER, + uses INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + revoked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_space_invites_space_id ON space_invites(space_id); diff --git a/migrations/postgres/20260429000005_create_space_dids.sql b/migrations/postgres/20260429000005_create_space_dids.sql new file mode 100644 index 0000000..6877b58 --- /dev/null +++ b/migrations/postgres/20260429000005_create_space_dids.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS space_dids ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL UNIQUE, + space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL, + signing_key_enc BYTEA NOT NULL, + rotation_key_enc BYTEA NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_space_dids_did ON space_dids(did); +CREATE INDEX idx_space_dids_space_id ON space_dids(space_id); diff --git a/migrations/postgres/20260429000006_create_space_sync_state.sql b/migrations/postgres/20260429000006_create_space_sync_state.sql new file mode 100644 index 0000000..a027df1 --- /dev/null +++ b/migrations/postgres/20260429000006_create_space_sync_state.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS space_sync_state ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + member_did TEXT NOT NULL, + cursor TEXT, + last_synced_at TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT, + UNIQUE(space_id, member_did) +); + +CREATE INDEX idx_space_sync_state_space_id ON space_sync_state(space_id); diff --git a/migrations/sqlite/20260429000000_create_spaces.sql b/migrations/sqlite/20260429000000_create_spaces.sql new file mode 100644 index 0000000..5d9f534 --- /dev/null +++ b/migrations/sqlite/20260429000000_create_spaces.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS spaces ( + id TEXT PRIMARY KEY, + owner_did TEXT NOT NULL, + type_nsid TEXT NOT NULL, + skey TEXT NOT NULL, + display_name TEXT, + description TEXT, + access_mode TEXT NOT NULL DEFAULT 'default_allow', + app_allowlist TEXT, + app_denylist TEXT, + managing_app_did TEXT, + config TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(owner_did, type_nsid, skey) +); + +CREATE INDEX idx_spaces_owner_did ON spaces(owner_did); +CREATE INDEX idx_spaces_type_nsid ON spaces(type_nsid); diff --git a/migrations/sqlite/20260429000001_create_space_members.sql b/migrations/sqlite/20260429000001_create_space_members.sql new file mode 100644 index 0000000..269dd30 --- /dev/null +++ b/migrations/sqlite/20260429000001_create_space_members.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS space_members ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + member_did TEXT NOT NULL, + access TEXT NOT NULL DEFAULT 'read', + is_delegation INTEGER NOT NULL DEFAULT 0, + granted_by TEXT, + created_at TEXT NOT NULL, + UNIQUE(space_id, member_did) +); + +CREATE INDEX idx_space_members_did ON space_members(member_did); +CREATE INDEX idx_space_members_space_id ON space_members(space_id); diff --git a/migrations/sqlite/20260429000002_create_space_records.sql b/migrations/sqlite/20260429000002_create_space_records.sql new file mode 100644 index 0000000..d267b46 --- /dev/null +++ b/migrations/sqlite/20260429000002_create_space_records.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS space_records ( + uri TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + author_did TEXT NOT NULL, + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + record TEXT NOT NULL, + cid TEXT NOT NULL, + indexed_at TEXT NOT NULL +); + +CREATE INDEX idx_space_records_space_id ON space_records(space_id); +CREATE INDEX idx_space_records_author ON space_records(author_did); +CREATE INDEX idx_space_records_collection ON space_records(space_id, collection); diff --git a/migrations/sqlite/20260429000003_create_space_credentials.sql b/migrations/sqlite/20260429000003_create_space_credentials.sql new file mode 100644 index 0000000..6bd6055 --- /dev/null +++ b/migrations/sqlite/20260429000003_create_space_credentials.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS space_credentials ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + issued_to TEXT NOT NULL, + token_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_space_credentials_space_id ON space_credentials(space_id); diff --git a/migrations/sqlite/20260429000004_create_space_invites.sql b/migrations/sqlite/20260429000004_create_space_invites.sql new file mode 100644 index 0000000..8726ece --- /dev/null +++ b/migrations/sqlite/20260429000004_create_space_invites.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS space_invites ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL, + access TEXT NOT NULL DEFAULT 'read', + max_uses INTEGER, + uses INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + revoked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_space_invites_space_id ON space_invites(space_id); diff --git a/migrations/sqlite/20260429000005_create_space_dids.sql b/migrations/sqlite/20260429000005_create_space_dids.sql new file mode 100644 index 0000000..5826cab --- /dev/null +++ b/migrations/sqlite/20260429000005_create_space_dids.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS space_dids ( + id TEXT PRIMARY KEY, + did TEXT NOT NULL UNIQUE, + space_id TEXT REFERENCES spaces(id) ON DELETE SET NULL, + signing_key_enc BLOB NOT NULL, + rotation_key_enc BLOB NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_space_dids_did ON space_dids(did); +CREATE INDEX idx_space_dids_space_id ON space_dids(space_id); diff --git a/migrations/sqlite/20260429000006_create_space_sync_state.sql b/migrations/sqlite/20260429000006_create_space_sync_state.sql new file mode 100644 index 0000000..a027df1 --- /dev/null +++ b/migrations/sqlite/20260429000006_create_space_sync_state.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS space_sync_state ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + member_did TEXT NOT NULL, + cursor TEXT, + last_synced_at TEXT, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT, + UNIQUE(space_id, member_did) +); + +CREATE INDEX idx_space_sync_state_space_id ON space_sync_state(space_id); diff --git a/src/admin/permissions.rs b/src/admin/permissions.rs index 0674ca0..3a8406f 100644 --- a/src/admin/permissions.rs +++ b/src/admin/permissions.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; -/// All 29 permissions in the system. +/// All 37 permissions in the system. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Permission { #[serde(rename = "lexicons:create")] @@ -83,6 +83,23 @@ pub enum Permission { DeadLettersRead, #[serde(rename = "dead-letters:manage")] DeadLettersManage, + + #[serde(rename = "spaces:create")] + SpacesCreate, + #[serde(rename = "spaces:read")] + SpacesRead, + #[serde(rename = "spaces:update")] + SpacesUpdate, + #[serde(rename = "spaces:delete")] + SpacesDelete, + #[serde(rename = "spaces:manage-members")] + SpacesManageMembers, + #[serde(rename = "spaces:manage-invites")] + SpacesManageInvites, + #[serde(rename = "spaces:manage-records")] + SpacesManageRecords, + #[serde(rename = "spaces:manage-credentials")] + SpacesManageCredentials, } impl Permission { @@ -122,6 +139,14 @@ impl Permission { Self::ApiClientsDelete => "api-clients:delete", Self::DeadLettersRead => "dead-letters:read", Self::DeadLettersManage => "dead-letters:manage", + Self::SpacesCreate => "spaces:create", + Self::SpacesRead => "spaces:read", + Self::SpacesUpdate => "spaces:update", + Self::SpacesDelete => "spaces:delete", + Self::SpacesManageMembers => "spaces:manage-members", + Self::SpacesManageInvites => "spaces:manage-invites", + Self::SpacesManageRecords => "spaces:manage-records", + Self::SpacesManageCredentials => "spaces:manage-credentials", } } @@ -161,6 +186,14 @@ impl Permission { Self::ApiClientsDelete, Self::DeadLettersRead, Self::DeadLettersManage, + Self::SpacesCreate, + Self::SpacesRead, + Self::SpacesUpdate, + Self::SpacesDelete, + Self::SpacesManageMembers, + Self::SpacesManageInvites, + Self::SpacesManageRecords, + Self::SpacesManageCredentials, ]) } } @@ -215,6 +248,14 @@ impl Template { perms.insert(Permission::ApiClientsCreate); perms.insert(Permission::ApiClientsEdit); perms.insert(Permission::ApiClientsDelete); + perms.insert(Permission::SpacesCreate); + perms.insert(Permission::SpacesRead); + perms.insert(Permission::SpacesUpdate); + perms.insert(Permission::SpacesDelete); + perms.insert(Permission::SpacesManageMembers); + perms.insert(Permission::SpacesManageInvites); + perms.insert(Permission::SpacesManageRecords); + perms.insert(Permission::SpacesManageCredentials); perms } Self::FullAccess => Permission::all(), diff --git a/src/lexicon.rs b/src/lexicon.rs index b52bdd1..f39823b 100644 --- a/src/lexicon.rs +++ b/src/lexicon.rs @@ -85,6 +85,8 @@ pub struct ParsedLexicon { pub index_hook: Option, /// Optional per-NSID token cost for rate limiting. pub token_cost: Option, + /// Optional space type NSID indicating this lexicon is designed for use within spaces of that type. + pub space_type: Option, } impl ParsedLexicon { @@ -127,6 +129,11 @@ impl ParsedLexicon { let output = main_def.and_then(|m| m.get("output")).cloned(); let record_schema = main_def.and_then(|m| m.get("record")).cloned(); + let space_type = raw + .get("spaceType") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Ok(Self { id, lexicon_type, @@ -142,6 +149,7 @@ impl ParsedLexicon { script, index_hook, token_cost, + space_type, }) } } @@ -793,4 +801,43 @@ mod tests { let script = reg.get_index_hook("nonexistent").await; assert!(script.is_none()); } + + #[test] + fn parse_space_type_from_lexicon() { + let raw = json!({ + "lexicon": 1, + "id": "com.example.forum.post", + "spaceType": "com.example.forum", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "properties": { + "text": { "type": "string" } + } + } + } + } + }); + let parsed = + ParsedLexicon::parse(raw, 1, None, ProcedureAction::Upsert, None, None, None).unwrap(); + assert_eq!(parsed.space_type.as_deref(), Some("com.example.forum")); + } + + #[test] + fn parse_space_type_none_by_default() { + let parsed = ParsedLexicon::parse( + record_lexicon_json(), + 1, + None, + ProcedureAction::Upsert, + None, + None, + None, + ) + .unwrap(); + assert!(parsed.space_type.is_none()); + } } diff --git a/src/lib.rs b/src/lib.rs index e6c615a..ef26bcd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,7 @@ pub mod record_refs; pub mod repo; pub mod resolve; pub mod server; +pub mod spaces; pub mod xrpc; use auth::oauth_store::{DbSessionStore, DbStateStore}; diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index 7aa10d0..58e64d6 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -257,6 +257,181 @@ pub fn register_atproto_api( atproto_table.set("verify_signature", verify_fn)?; } + // atproto.spaces sub-table + let spaces_table = lua.create_table()?; + + // atproto.spaces.is_member(space_uri, did) -> boolean + let state_clone = state.clone(); + let is_member_fn = + lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { + let state = state_clone.clone(); + async move { + let uri = crate::spaces::SpaceUri::parse(&space_uri) + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; + let space = crate::spaces::db::get_space_by_address( + &state.db, + state.db_backend, + &uri.owner_did, + &uri.type_nsid, + &uri.skey, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; + let space = match space { + Some(s) => s, + None => return Ok(false), + }; + let access = + crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did) + .await + .map_err(|e| { + mlua::Error::runtime(format!("membership check failed: {e}")) + })?; + Ok(access.is_some()) + } + })?; + spaces_table.set("is_member", is_member_fn)?; + + // atproto.spaces.get_access(space_uri, did) -> 'read' | 'write' | nil + let state_clone = state.clone(); + let get_access_fn = + lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { + let state = state_clone.clone(); + async move { + let uri = crate::spaces::SpaceUri::parse(&space_uri) + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; + let space = crate::spaces::db::get_space_by_address( + &state.db, + state.db_backend, + &uri.owner_did, + &uri.type_nsid, + &uri.skey, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; + let space = match space { + Some(s) => s, + None => return Ok(None), + }; + let access = + crate::spaces::members::is_member(&state.db, state.db_backend, &space.id, &did) + .await + .map_err(|e| { + mlua::Error::runtime(format!("membership check failed: {e}")) + })?; + Ok(access.map(|a| a.as_str().to_string())) + } + })?; + spaces_table.set("get_access", get_access_fn)?; + + // atproto.spaces.list_members(space_uri) -> array of { did, access } + let state_clone = state.clone(); + let list_members_fn = lua.create_async_function(move |lua, space_uri: String| { + let state = state_clone.clone(); + async move { + let uri = crate::spaces::SpaceUri::parse(&space_uri) + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; + let space = crate::spaces::db::get_space_by_address( + &state.db, + state.db_backend, + &uri.owner_did, + &uri.type_nsid, + &uri.skey, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; + let space = match space { + Some(s) => s, + None => { + return Err(mlua::Error::runtime("space not found")); + } + }; + let members = + crate::spaces::members::resolve_members(&state.db, state.db_backend, &space.id) + .await + .map_err(|e| mlua::Error::runtime(format!("member resolution failed: {e}")))?; + + let result = lua.create_table()?; + for (i, member) in members.iter().enumerate() { + let entry = lua.create_table()?; + entry.set("did", member.did.as_str())?; + entry.set("access", member.access.as_str())?; + result.set(i + 1, entry)?; + } + Ok(mlua::Value::Table(result)) + } + })?; + spaces_table.set("list_members", list_members_fn)?; + + // atproto.spaces.query({ space_uri, collection, limit, cursor }) -> { records, cursor } + let state_clone = state.clone(); + let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { + let state = state_clone.clone(); + async move { + let space_uri: String = opts + .get("space_uri") + .map_err(|_| mlua::Error::runtime("space_uri is required"))?; + let collection: Option = opts.get("collection").ok(); + let limit: i64 = opts.get("limit").unwrap_or(50); + let cursor: Option = opts.get("cursor").ok(); + + let uri = crate::spaces::SpaceUri::parse(&space_uri) + .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; + let space = crate::spaces::db::get_space_by_address( + &state.db, + state.db_backend, + &uri.owner_did, + &uri.type_nsid, + &uri.skey, + ) + .await + .map_err(|e| mlua::Error::runtime(format!("space lookup failed: {e}")))?; + let space = match space { + Some(s) => s, + None => { + return Err(mlua::Error::runtime("space not found")); + } + }; + + let records = crate::spaces::db::list_space_records( + &state.db, + state.db_backend, + &space.id, + collection.as_deref(), + limit.min(100), + cursor.as_deref(), + ) + .await + .map_err(|e| mlua::Error::runtime(format!("record query failed: {e}")))?; + + let next_cursor = records.last().map(|r| r.indexed_at.clone()); + + let result = lua.create_table()?; + let records_table = lua.create_table()?; + for (i, record) in records.iter().enumerate() { + let entry = lua.to_value(&serde_json::json!({ + "uri": record.uri, + "collection": record.collection, + "rkey": record.rkey, + "record": record.record, + "cid": record.cid, + "authorDid": record.author_did, + }))?; + records_table.set(i + 1, entry)?; + } + result.set("records", records_table)?; + match next_cursor { + Some(c) => result.set("cursor", c)?, + None => result.set("cursor", mlua::Value::Nil)?, + } + + Ok(mlua::Value::Table(result)) + } + })?; + spaces_table.set("query", query_fn)?; + + atproto_table.set("spaces", spaces_table)?; + lua.globals().set("atproto", atproto_table)?; Ok(()) } @@ -517,4 +692,21 @@ mod tests { let result: bool = lua.load(chunk).eval_async().await.unwrap(); assert!(!result); } + + #[tokio::test] + async fn spaces_api_is_registered() { + let state = test_state_with_plc(""); + let lua = mlua::Lua::new(); + register_atproto_api(&lua, Arc::new(state), None).unwrap(); + + let chunk = r#" + return type(atproto.spaces) == "table" + and type(atproto.spaces.is_member) == "function" + and type(atproto.spaces.get_access) == "function" + and type(atproto.spaces.list_members) == "function" + and type(atproto.spaces.query) == "function" + "#; + let result: bool = lua.load(chunk).eval_async().await.unwrap(); + assert!(result); + } } diff --git a/src/lua/context.rs b/src/lua/context.rs index fbb3429..f63e0bc 100644 --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -2,6 +2,35 @@ use mlua::{Lua, LuaSerdeExt, Result as LuaResult}; use serde_json::Value; use std::collections::HashMap; +/// Optional space context passed to Lua scripts when the request is space-scoped. +#[derive(Debug, Clone)] +pub struct SpaceContext { + pub space_uri: String, + pub space_id: String, + pub owner_did: String, + pub type_nsid: String, + pub skey: String, +} + +fn set_space_context(lua: &Lua, space: Option<&SpaceContext>) -> LuaResult<()> { + let globals = lua.globals(); + match space { + Some(ctx) => { + let table = lua.create_table()?; + table.set("space_uri", ctx.space_uri.as_str())?; + table.set("space_id", ctx.space_id.as_str())?; + table.set("owner_did", ctx.owner_did.as_str())?; + table.set("type_nsid", ctx.type_nsid.as_str())?; + table.set("skey", ctx.skey.as_str())?; + globals.set("space", table)?; + } + None => { + globals.set("space", mlua::Value::Nil)?; + } + } + Ok(()) +} + /// Set global context variables for a procedure script. pub fn set_procedure_context( lua: &Lua, @@ -10,6 +39,7 @@ pub fn set_procedure_context( params: &HashMap, caller_did: &str, collection: &str, + space: Option<&SpaceContext>, ) -> LuaResult<()> { let globals = lua.globals(); globals.set("method", method.to_string())?; @@ -17,6 +47,7 @@ pub fn set_procedure_context( globals.set("params", lua.to_value(params)?)?; globals.set("caller_did", caller_did.to_string())?; globals.set("collection", collection.to_string())?; + set_space_context(lua, space)?; Ok(()) } @@ -27,6 +58,7 @@ pub fn set_query_context( params: &HashMap, collection: &str, caller_did: Option<&str>, + space: Option<&SpaceContext>, ) -> LuaResult<()> { let globals = lua.globals(); globals.set("method", method.to_string())?; @@ -36,6 +68,7 @@ pub fn set_query_context( Some(did) => globals.set("caller_did", did.to_string())?, None => globals.set("caller_did", mlua::Value::Nil)?, } + set_space_context(lua, space)?; Ok(()) } @@ -117,6 +150,7 @@ mod tests { ¶ms, "did:plc:test", "com.example.thing", + None, ) .unwrap(); @@ -150,6 +184,7 @@ mod tests { ¶ms, "com.example.thing", Some("did:plc:test"), + None, ) .unwrap(); @@ -193,6 +228,58 @@ mod tests { assert!(env.get::("anything").unwrap().is_nil()); } + #[test] + fn query_context_with_space() { + let lua = create_sandbox().unwrap(); + let params = HashMap::new(); + let space = SpaceContext { + space_uri: "ats://did:plc:owner/com.example.forum/main".into(), + space_id: "space-123".into(), + owner_did: "did:plc:owner".into(), + type_nsid: "com.example.forum".into(), + skey: "main".into(), + }; + set_query_context( + &lua, + "com.example.listPosts", + ¶ms, + "com.example.forum.post", + Some("did:plc:test"), + Some(&space), + ) + .unwrap(); + + let globals = lua.globals(); + let space_table: mlua::Table = globals.get("space").unwrap(); + assert_eq!( + space_table.get::("space_uri").unwrap(), + "ats://did:plc:owner/com.example.forum/main" + ); + assert_eq!(space_table.get::("space_id").unwrap(), "space-123"); + assert_eq!( + space_table.get::("owner_did").unwrap(), + "did:plc:owner" + ); + } + + #[test] + fn query_context_without_space() { + let lua = create_sandbox().unwrap(); + let params = HashMap::new(); + set_query_context( + &lua, + "com.example.listThings", + ¶ms, + "com.example.thing", + None, + None, + ) + .unwrap(); + + let globals = lua.globals(); + assert!(globals.get::("space").unwrap().is_nil()); + } + #[test] fn hook_context_record_nil_on_delete() { let lua = create_sandbox().unwrap(); diff --git a/src/lua/execute.rs b/src/lua/execute.rs index 8f0d79d..23199ee 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -33,6 +33,7 @@ async fn load_env_vars(db: &sqlx::AnyPool, backend: DatabaseBackend) -> HashMap< } /// Execute a Lua script for a procedure endpoint. +#[allow(clippy::too_many_arguments)] pub async fn execute_procedure_script( state: &AppState, method: &str, @@ -41,6 +42,7 @@ pub async fn execute_procedure_script( params: &std::collections::HashMap, lexicon: &ParsedLexicon, script: &str, + space_ctx: Option<&context::SpaceContext>, ) -> Result { let start = Instant::now(); let backend = state.db_backend; @@ -275,9 +277,15 @@ pub async fn execute_procedure_script( return Err(AppError::Internal(error_message)); } - if let Err(e) = - context::set_procedure_context(&lua, method, input, params, claims.did(), collection) - { + if let Err(e) = context::set_procedure_context( + &lua, + method, + input, + params, + claims.did(), + collection, + space_ctx, + ) { let error_message = format!("failed to set context: {e}"); log_event( &state.db, @@ -503,6 +511,7 @@ pub async fn execute_query_script( lexicon: &ParsedLexicon, script: &str, claims: Option<&Claims>, + space_ctx: Option<&context::SpaceContext>, ) -> Result { let start = Instant::now(); let backend = state.db_backend; @@ -632,9 +641,14 @@ pub async fn execute_query_script( return Err(AppError::Internal(error_message)); } - if let Err(e) = - context::set_query_context(&lua, method, params, collection, claims.map(|c| c.did())) - { + if let Err(e) = context::set_query_context( + &lua, + method, + params, + collection, + claims.map(|c| c.did()), + space_ctx, + ) { let error_message = format!("failed to set context: {e}"); log_event( &state.db, diff --git a/src/lua/mod.rs b/src/lua/mod.rs index bbf7ddb..69cb3cc 100644 --- a/src/lua/mod.rs +++ b/src/lua/mod.rs @@ -8,6 +8,8 @@ pub(crate) mod sandbox; mod tid; mod xrpc_api; +#[allow(unused_imports)] +pub(crate) use context::SpaceContext; pub(crate) use execute::{ HookEvent, execute_hook_script, execute_procedure_script, execute_query_script, run_hook_once, }; diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs index 295ae62..0b78ae6 100644 --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -310,6 +310,7 @@ mod tests { script: script.map(|s| s.to_string()), index_hook: None, token_cost: None, + space_type: None, } } @@ -329,6 +330,7 @@ mod tests { script: script.map(|s| s.to_string()), index_hook: None, token_cost: None, + space_type: None, } } diff --git a/src/profile.rs b/src/profile.rs index 5fffbcc..a31bede 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -27,18 +27,30 @@ pub struct AvatarBlob { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct DidDocument { +pub struct DidDocument { #[serde(default)] - also_known_as: Vec, + pub also_known_as: Vec, #[serde(default)] - service: Vec, + pub verification_method: Vec, + #[serde(default)] + pub service: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DidVerificationMethod { + pub id: String, + #[serde(rename = "type")] + pub method_type: String, + #[serde(default)] + pub public_key_multibase: Option, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct DidService { - id: String, - service_endpoint: String, +pub struct DidService { + pub id: String, + pub service_endpoint: String, } #[derive(Deserialize)] @@ -117,7 +129,7 @@ pub async fn resolve_labeler_endpoint( } /// Fetch a DID document from the PLC directory or via `did:web` resolution. -async fn resolve_did_document( +pub async fn resolve_did_document( http: &reqwest::Client, plc_url: &str, did: &str, diff --git a/src/server.rs b/src/server.rs index 1e1512a..7c0700d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -62,6 +62,7 @@ pub fn router(state: AppState) -> Router { let serve_dir = ServeDir::new(&static_dir).not_found_service(spa_fallback); let domain_routes = Router::new() + .merge(crate::spaces::routes::space_routes()) .nest("/auth", crate::auth::routes::routes()) .nest("/external-auth", crate::external_auth::routes()) .nest("/oauth", crate::oauth::routes::routes()) diff --git a/src/spaces/auth.rs b/src/spaces/auth.rs new file mode 100644 index 0000000..2b70089 --- /dev/null +++ b/src/spaces/auth.rs @@ -0,0 +1,315 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::SigningKey; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +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, verify_credential, +}; +use crate::spaces::types::{AccessMode, Space}; + +pub struct IssuedCredential { + pub token: String, + pub expires_at: String, +} + +pub async fn issue_credential( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + space: &Space, + subject_did: &str, + client_id: Option<&str>, +) -> Result { + check_app_access(space, client_id)?; + + let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let exp = now + DEFAULT_CREDENTIAL_TTL_SECS; + + let claims = SpaceCredentialClaims { + iss: space.owner_did.clone(), + sub: subject_did.to_string(), + space: format!("{}/{}/{}", space.owner_did, space.type_nsid, space.skey), + scope: "read".into(), + iat: now, + exp, + }; + + let token = sign_credential(&claims, &private_jwk)?; + + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); + store_credential_record(pool, backend, &space.id, subject_did, &token_hash, exp).await?; + + let expires_at = chrono::DateTime::from_timestamp(exp as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); + + Ok(IssuedCredential { token, expires_at }) +} + +pub async fn refresh_credential( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + space: &Space, + current_token: &str, +) -> Result { + let public_jwk = get_public_key(pool, backend, encryption_key, space).await?; + let claims = verify_credential(current_token, &public_jwk)?; + + issue_credential(pool, backend, encryption_key, space, &claims.sub, None).await +} + +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(), + )); + } + } else { + return Err(AppError::Forbidden( + "Space is in default_deny mode with no allowlist".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(), + )); + } + } + } + + Ok(()) +} + +async fn get_or_create_signing_key( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + space: &Space, +) -> Result { + let sql = adapt_sql( + "SELECT signing_key_enc FROM space_dids WHERE space_id = ?", + backend, + ); + let row: Option<(Vec,)> = sqlx::query_as(&sql) + .bind(&space.id) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to look up space signing key: {e}")))?; + + if let Some((encrypted,)) = row { + let decrypted = decrypt(encryption_key, &encrypted) + .map_err(|e| AppError::Internal(format!("failed to decrypt signing key: {e}")))?; + let jwk: serde_json::Value = serde_json::from_slice(&decrypted) + .map_err(|e| AppError::Internal(format!("failed to parse signing key: {e}")))?; + return Ok(jwk); + } + + let keypair = generate_space_keypair()?; + let key_bytes = serde_json::to_vec(&keypair.private_jwk) + .map_err(|e| AppError::Internal(format!("failed to serialize signing key: {e}")))?; + let encrypted_signing = encrypt(encryption_key, &key_bytes) + .map_err(|e| AppError::Internal(format!("failed to encrypt signing key: {e}")))?; + + // Rotation key is a separate keypair for recovery + let rotation_keypair = generate_space_keypair()?; + let rotation_bytes = serde_json::to_vec(&rotation_keypair.private_jwk) + .map_err(|e| AppError::Internal(format!("failed to serialize rotation key: {e}")))?; + let encrypted_rotation = encrypt(encryption_key, &rotation_bytes) + .map_err(|e| AppError::Internal(format!("failed to encrypt rotation key: {e}")))?; + + let now = now_rfc3339(); + let insert_sql = adapt_sql( + "INSERT INTO space_dids (id, did, space_id, signing_key_enc, rotation_key_enc, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&insert_sql) + .bind(Uuid::new_v4().to_string()) + .bind(&space.owner_did) + .bind(&space.id) + .bind(&encrypted_signing) + .bind(&encrypted_rotation) + .bind(&space.owner_did) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to store space signing key: {e}")))?; + + Ok(keypair.private_jwk) +} + +async fn get_public_key( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + encryption_key: &[u8; 32], + space: &Space, +) -> Result { + let private_jwk = get_or_create_signing_key(pool, backend, encryption_key, space).await?; + Ok(serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": private_jwk["x"], + "y": private_jwk["y"], + })) +} + +struct SpaceKeypair { + private_jwk: serde_json::Value, +} + +fn generate_space_keypair() -> Result { + 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 signing key: {e}")))?; + + let verifying_key = signing_key.verifying_key(); + let public_point = verifying_key.to_encoded_point(false); + + let x_bytes = public_point + .x() + .ok_or_else(|| AppError::Internal("missing x coordinate".into()))?; + let y_bytes = public_point + .y() + .ok_or_else(|| AppError::Internal("missing y coordinate".into()))?; + + let x_b64 = URL_SAFE_NO_PAD.encode(x_bytes); + let y_b64 = URL_SAFE_NO_PAD.encode(y_bytes); + let d_b64 = URL_SAFE_NO_PAD.encode(rng_bytes); + + let private_jwk = serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": x_b64, + "y": y_b64, + "d": d_b64, + }); + + Ok(SpaceKeypair { private_jwk }) +} + +async fn store_credential_record( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + issued_to: &str, + token_hash: &str, + expires_at_epoch: u64, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let expires_at = chrono::DateTime::from_timestamp(expires_at_epoch as i64, 0) + .map(|dt| dt.to_rfc3339()) + .unwrap_or_default(); + + let sql = adapt_sql( + "INSERT INTO space_credentials (id, space_id, issued_to, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(Uuid::new_v4().to_string()) + .bind(space_id) + .bind(issued_to) + .bind(token_hash) + .bind(&expires_at) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to store credential record: {e}")))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spaces::types::{AccessMode, Space, SpaceConfig}; + + fn test_space(access_mode: AccessMode) -> Space { + Space { + id: "test-space".into(), + owner_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, + managing_app_did: None, + config: SpaceConfig::default(), + created_at: String::new(), + updated_at: String::new(), + } + } + + #[test] + fn app_access_default_allow_no_lists() { + let space = test_space(AccessMode::DefaultAllow); + 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()]); + + assert!(check_app_access(&space, Some("good-app")).is_ok()); + assert!(check_app_access(&space, Some("bad-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()); + } + + #[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()); + } + + #[test] + fn app_access_no_client_id_always_passes() { + let space = test_space(AccessMode::DefaultDeny); + assert!(check_app_access(&space, None).is_ok()); + } + + #[test] + fn generate_keypair_produces_valid_jwk() { + let kp = generate_space_keypair().unwrap(); + assert_eq!(kp.private_jwk["kty"], "EC"); + assert_eq!(kp.private_jwk["crv"], "P-256"); + assert!(kp.private_jwk["d"].is_string()); + assert!(kp.private_jwk["x"].is_string()); + assert!(kp.private_jwk["y"].is_string()); + } +} diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs new file mode 100644 index 0000000..a2bbbc3 --- /dev/null +++ b/src/spaces/credential.rs @@ -0,0 +1,284 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::{Signature, SigningKey, VerifyingKey, signature::Signer, signature::Verifier}; +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; +use crate::profile; + +pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 4 * 60 * 60; // 4 hours + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceCredentialClaims { + pub iss: String, + pub sub: String, + pub space: String, + pub scope: String, + pub iat: u64, + pub exp: u64, +} + +pub fn sign_credential( + claims: &SpaceCredentialClaims, + private_jwk: &serde_json::Value, +) -> Result { + let d_b64 = private_jwk["d"] + .as_str() + .ok_or_else(|| AppError::Internal("signing key missing d parameter".into()))?; + + let d_bytes = URL_SAFE_NO_PAD + .decode(d_b64) + .map_err(|_| AppError::Internal("invalid signing key d parameter".into()))?; + + let signing_key = SigningKey::from_bytes((&d_bytes[..]).into()) + .map_err(|e| AppError::Internal(format!("invalid signing key: {e}")))?; + + let header = serde_json::json!({ + "alg": "ES256", + "typ": "JWT", + }); + + 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: Signature = 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_credential( + token: &str, + public_jwk: &serde_json::Value, +) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Auth("invalid credential format".into())); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|_| AppError::Auth("invalid credential header encoding".into()))?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|_| AppError::Auth("invalid credential header".into()))?; + + if header["alg"].as_str() != Some("ES256") { + return Err(AppError::Auth("credential alg must be ES256".into())); + } + + 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 message = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|_| AppError::Auth("invalid credential signature encoding".into()))?; + let signature = Signature::from_bytes(sig_bytes.as_slice().into()) + .map_err(|_| AppError::Auth("invalid credential signature format".into()))?; + + verifying_key + .verify(message.as_bytes(), &signature) + .map_err(|_| AppError::Auth("credential signature verification failed".into()))?; + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid credential payload encoding".into()))?; + let claims: SpaceCredentialClaims = serde_json::from_slice(&payload_bytes) + .map_err(|_| AppError::Auth("invalid credential payload".into()))?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + if now > claims.exp { + return Err(AppError::Auth("credential has expired".into())); + } + + Ok(claims) +} + +/// 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 { + let (_base, key_bytes) = multibase::decode(public_key_multibase) + .map_err(|e| AppError::Auth(format!("invalid multibase encoding: {e}")))?; + + // P-256 multicodec prefix: varint 0x1200 → bytes [0x80, 0x24] + if key_bytes.len() < 2 || key_bytes[0] != 0x80 || key_bytes[1] != 0x24 { + return Err(AppError::Auth( + "public key is not a P-256 multicodec key".into(), + )); + } + + let compressed = &key_bytes[2..]; + let verifying_key = VerifyingKey::from_sec1_bytes(compressed) + .map_err(|_| AppError::Auth("invalid P-256 public key bytes".into()))?; + + let point = verifying_key.to_encoded_point(false); + let x = point + .x() + .ok_or_else(|| AppError::Auth("failed to extract x coordinate".into()))?; + let y = point + .y() + .ok_or_else(|| AppError::Auth("failed to extract y coordinate".into()))?; + + Ok(serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": URL_SAFE_NO_PAD.encode(x), + "y": URL_SAFE_NO_PAD.encode(y), + })) +} + +/// Verify a space credential JWT issued by an external space host. +/// +/// Resolves the issuer's DID document, extracts the `#atproto` signing key, +/// and verifies the JWT signature and expiry. +pub async fn verify_external_credential( + token: &str, + http: &reqwest::Client, + plc_url: &str, +) -> 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())); + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid credential payload encoding".into()))?; + let peek: SpaceCredentialClaims = serde_json::from_slice(&payload_bytes) + .map_err(|_| AppError::Auth("invalid credential payload".into()))?; + + let did_doc = profile::resolve_did_document(http, plc_url, &peek.iss).await?; + + 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()))?; + + let multibase = vm + .public_key_multibase + .as_deref() + .ok_or_else(|| AppError::Auth("verification method missing publicKeyMultibase".into()))?; + + let jwk = multikey_to_p256_jwk(multibase)?; + verify_credential(token, &jwk) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oauth::keys::generate_dpop_keypair; + + fn make_claims() -> SpaceCredentialClaims { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .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(), + iat: now, + exp: now + DEFAULT_CREDENTIAL_TTL_SECS, + } + } + + #[test] + fn sign_and_verify_roundtrip() { + let keypair = generate_dpop_keypair().unwrap(); + let claims = make_claims(); + + 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.space, claims.space); + assert_eq!(verified.scope, claims.scope); + assert_eq!(verified.iat, claims.iat); + assert_eq!(verified.exp, claims.exp); + } + + #[test] + fn verify_rejects_tampered_payload() { + let keypair = generate_dpop_keypair().unwrap(); + 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; + let tampered_payload = URL_SAFE_NO_PAD.encode(&payload_bytes); + let tampered = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]); + + let result = verify_credential(&tampered, &keypair.public_jwk); + assert!(result.is_err()); + } + + #[test] + fn verify_rejects_wrong_key() { + let keypair1 = generate_dpop_keypair().unwrap(); + let keypair2 = generate_dpop_keypair().unwrap(); + let claims = make_claims(); + let token = sign_credential(&claims, &keypair1.private_jwk).unwrap(); + + let result = verify_credential(&token, &keypair2.public_jwk); + assert!(result.is_err()); + } + + #[test] + fn verify_rejects_expired() { + let keypair = generate_dpop_keypair().unwrap(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .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(), + iat: now - 7200, + exp: now - 3600, // expired 1 hour ago + }; + + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); + let result = verify_credential(&token, &keypair.public_jwk); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("expired")); + } + + #[test] + fn verify_rejects_invalid_format() { + let keypair = generate_dpop_keypair().unwrap(); + let result = verify_credential("not-a-jwt", &keypair.public_jwk); + assert!(result.is_err()); + } +} diff --git a/src/spaces/db.rs b/src/spaces/db.rs new file mode 100644 index 0000000..173a053 --- /dev/null +++ b/src/spaces/db.rs @@ -0,0 +1,780 @@ +use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; +use crate::error::AppError; +use crate::spaces::types::*; + +// --------------------------------------------------------------------------- +// Spaces +// --------------------------------------------------------------------------- + +pub async fn create_space( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space: &Space, +) -> Result<(), AppError> { + 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 sql = adapt_sql( + "INSERT INTO spaces (id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(&space.id) + .bind(&space.owner_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.managing_app_did) + .bind(&config_json) + .bind(&now) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to create space: {e}")))?; + + Ok(()) +} + +pub async fn get_space( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE id = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get space: {e}")))?; + + row.map(parse_space_row).transpose() +} + +pub async fn get_space_by_address( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + owner_did: &str, + type_nsid: &str, + skey: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE owner_did = ? AND type_nsid = ? AND skey = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(owner_did) + .bind(type_nsid) + .bind(skey) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get space: {e}")))?; + + row.map(parse_space_row).transpose() +} + +pub async fn list_spaces_by_owner( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + owner_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, owner_did, type_nsid, skey, display_name, description, access_mode, app_allowlist, app_denylist, managing_app_did, config, created_at, updated_at FROM spaces WHERE owner_did = ? ORDER BY created_at DESC", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .bind(owner_did) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list spaces: {e}")))?; + + rows.into_iter().map(parse_space_row).collect() +} + +pub async fn update_space( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space: &Space, +) -> Result { + 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 sql = adapt_sql( + "UPDATE spaces SET display_name = ?, description = ?, access_mode = ?, app_allowlist = ?, app_denylist = ?, 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.managing_app_did) + .bind(&config_json) + .bind(&now) + .bind(&space.id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to update space: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +pub async fn delete_space( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + id: &str, +) -> Result { + let sql = adapt_sql("DELETE FROM spaces WHERE id = ?", backend); + + let result = sqlx::query(&sql) + .bind(id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete space: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +type SpaceRow = ( + String, + String, + String, + String, + Option, + Option, + String, + Option, + Option, + Option, + String, + String, + String, +); + +fn parse_space_row(r: SpaceRow) -> Result { + let access_mode = AccessMode::parse(&r.6) + .ok_or_else(|| AppError::Internal(format!("invalid access_mode: {}", r.6)))?; + let app_allowlist: Option> = + r.7.as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| AppError::Internal(format!("invalid app_allowlist: {e}")))?; + let app_denylist: Option> = + r.8.as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| AppError::Internal(format!("invalid app_denylist: {e}")))?; + let config: SpaceConfig = serde_json::from_str(&r.10) + .map_err(|e| AppError::Internal(format!("invalid space config: {e}")))?; + + Ok(Space { + id: r.0, + owner_did: r.1, + type_nsid: r.2, + skey: r.3, + display_name: r.4, + description: r.5, + access_mode, + app_allowlist, + app_denylist, + managing_app_did: r.9, + config, + created_at: r.11, + updated_at: r.12, + }) +} + +// --------------------------------------------------------------------------- +// Space Members +// --------------------------------------------------------------------------- + +pub async fn add_member( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + member: &SpaceMember, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO space_members (id, space_id, member_did, access, is_delegation, granted_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(&member.id) + .bind(&member.space_id) + .bind(&member.member_did) + .bind(member.access.as_str()) + .bind(member.is_delegation as i32) + .bind(&member.granted_by) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to add member: {e}")))?; + + Ok(()) +} + +pub async fn remove_member( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + member_did: &str, +) -> Result { + let sql = adapt_sql( + "DELETE FROM space_members WHERE space_id = ? AND member_did = ?", + backend, + ); + + let result = sqlx::query(&sql) + .bind(space_id) + .bind(member_did) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to remove member: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +pub async fn get_member( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + member_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? AND member_did = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(space_id) + .bind(member_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get member: {e}")))?; + + row.map(parse_member_row).transpose() +} + +pub async fn list_direct_members( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE space_id = ? ORDER BY created_at ASC", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .bind(space_id) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list members: {e}")))?; + + rows.into_iter().map(parse_member_row).collect() +} + +pub async fn list_spaces_for_member( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + member_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, space_id, member_did, access, is_delegation, granted_by, created_at FROM space_members WHERE member_did = ? ORDER BY created_at ASC", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .bind(member_did) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list spaces for member: {e}")))?; + + rows.into_iter().map(parse_member_row).collect() +} + +type MemberRow = (String, String, String, String, i32, Option, String); + +fn parse_member_row(r: MemberRow) -> Result { + let access = SpaceAccess::parse(&r.3) + .ok_or_else(|| AppError::Internal(format!("invalid access: {}", r.3)))?; + + Ok(SpaceMember { + id: r.0, + space_id: r.1, + member_did: r.2, + access, + is_delegation: r.4 != 0, + granted_by: r.5, + created_at: r.6, + }) +} + +// --------------------------------------------------------------------------- +// Space Records +// --------------------------------------------------------------------------- + +pub async fn upsert_space_record( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + record: &SpaceRecord, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let record_json = serde_json::to_string(&record.record) + .map_err(|e| AppError::Internal(format!("failed to serialize record: {e}")))?; + + let sql = match backend { + DatabaseBackend::Sqlite => { + "INSERT OR REPLACE INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)".to_string() + } + DatabaseBackend::Postgres => adapt_sql( + "INSERT INTO space_records (uri, space_id, author_did, collection, rkey, record, cid, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (uri) DO UPDATE SET record = EXCLUDED.record, cid = EXCLUDED.cid, indexed_at = EXCLUDED.indexed_at", + backend, + ), + }; + + sqlx::query(&sql) + .bind(&record.uri) + .bind(&record.space_id) + .bind(&record.author_did) + .bind(&record.collection) + .bind(&record.rkey) + .bind(&record_json) + .bind(&record.cid) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to upsert space record: {e}")))?; + + Ok(()) +} + +pub async fn get_space_record( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + uri: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE uri = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(uri) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get space record: {e}")))?; + + row.map(parse_record_row).transpose() +} + +pub async fn get_space_record_by_parts( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + collection: &str, + rkey: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? AND rkey = ? LIMIT 1", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(space_id) + .bind(collection) + .bind(rkey) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get space record: {e}")))?; + + row.map(parse_record_row).transpose() +} + +pub async fn list_space_records( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + collection: Option<&str>, + limit: i64, + cursor: Option<&str>, +) -> Result, AppError> { + let (sql, has_collection, has_cursor) = match (collection, cursor) { + (Some(_), Some(_)) => ( + adapt_sql( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? AND indexed_at > ? ORDER BY indexed_at ASC LIMIT ?", + backend, + ), + true, + true, + ), + (Some(_), None) => ( + adapt_sql( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND collection = ? ORDER BY indexed_at ASC LIMIT ?", + backend, + ), + true, + false, + ), + (None, Some(_)) => ( + adapt_sql( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? AND indexed_at > ? ORDER BY indexed_at ASC LIMIT ?", + backend, + ), + false, + true, + ), + (None, None) => ( + adapt_sql( + "SELECT uri, space_id, author_did, collection, rkey, record, cid, indexed_at FROM space_records WHERE space_id = ? ORDER BY indexed_at ASC LIMIT ?", + backend, + ), + false, + false, + ), + }; + + let mut query = sqlx::query_as::<_, RecordRow>(&sql).bind(space_id); + + if has_collection { + query = query.bind(collection.unwrap()); + } + if has_cursor { + query = query.bind(cursor.unwrap()); + } + query = query.bind(limit); + + let rows = query + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list space records: {e}")))?; + + rows.into_iter().map(parse_record_row).collect() +} + +pub async fn delete_space_record( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + uri: &str, +) -> Result { + let sql = adapt_sql("DELETE FROM space_records WHERE uri = ?", backend); + + let result = sqlx::query(&sql) + .bind(uri) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete space record: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +type RecordRow = ( + String, + String, + String, + String, + String, + String, + String, + String, +); + +fn parse_record_row(r: RecordRow) -> Result { + let record: serde_json::Value = serde_json::from_str(&r.5) + .map_err(|e| AppError::Internal(format!("invalid record JSON: {e}")))?; + + Ok(SpaceRecord { + uri: r.0, + space_id: r.1, + author_did: r.2, + collection: r.3, + rkey: r.4, + record, + cid: r.6, + indexed_at: r.7, + }) +} + +// --------------------------------------------------------------------------- +// Space Invites +// --------------------------------------------------------------------------- + +pub async fn create_invite( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + invite: &SpaceInvite, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO space_invites (id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + backend, + ); + + sqlx::query(&sql) + .bind(&invite.id) + .bind(&invite.space_id) + .bind(&invite.token_hash) + .bind(&invite.created_by) + .bind(invite.access.as_str()) + .bind(invite.max_uses) + .bind(invite.uses) + .bind(&invite.expires_at) + .bind(invite.revoked as i32) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to create invite: {e}")))?; + + Ok(()) +} + +pub async fn get_invite_by_token_hash( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + token_hash: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM space_invites WHERE token_hash = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(token_hash) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get invite: {e}")))?; + + row.map(parse_invite_row).transpose() +} + +pub async fn increment_invite_uses( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + invite_id: &str, +) -> Result<(), AppError> { + let sql = adapt_sql( + "UPDATE space_invites SET uses = uses + 1 WHERE id = ?", + backend, + ); + + sqlx::query(&sql) + .bind(invite_id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to increment invite uses: {e}")))?; + + Ok(()) +} + +pub async fn revoke_invite( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + invite_id: &str, +) -> Result { + let sql = adapt_sql("UPDATE space_invites SET revoked = 1 WHERE id = ?", backend); + + let result = sqlx::query(&sql) + .bind(invite_id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to revoke invite: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +pub async fn list_invites( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, space_id, token_hash, created_by, access, max_uses, uses, expires_at, revoked, created_at FROM space_invites WHERE space_id = ? ORDER BY created_at DESC", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .bind(space_id) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list invites: {e}")))?; + + rows.into_iter().map(parse_invite_row).collect() +} + +type InviteRow = ( + String, + String, + String, + String, + String, + Option, + i64, + Option, + i32, + String, +); + +fn parse_invite_row(r: InviteRow) -> Result { + let access = SpaceAccess::parse(&r.4) + .ok_or_else(|| AppError::Internal(format!("invalid invite access: {}", r.4)))?; + + Ok(SpaceInvite { + id: r.0, + space_id: r.1, + token_hash: r.2, + created_by: r.3, + access, + max_uses: r.5, + uses: r.6, + expires_at: r.7, + revoked: r.8 != 0, + created_at: r.9, + }) +} + +// --------------------------------------------------------------------------- +// Space Sync State +// --------------------------------------------------------------------------- + +pub async fn get_sync_state( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + member_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE space_id = ? AND member_did = ?", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .bind(space_id) + .bind(member_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get sync state: {e}")))?; + + row.map(parse_sync_state_row).transpose() +} + +pub async fn upsert_sync_state( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + state: &SpaceSyncState, +) -> Result<(), AppError> { + let sql = match backend { + DatabaseBackend::Sqlite => { + "INSERT OR REPLACE INTO space_sync_state (id, space_id, member_did, cursor, last_synced_at, status, error) VALUES (?, ?, ?, ?, ?, ?, ?)".to_string() + } + DatabaseBackend::Postgres => adapt_sql( + "INSERT INTO space_sync_state (id, space_id, member_did, cursor, last_synced_at, status, error) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (space_id, member_did) DO UPDATE SET cursor = EXCLUDED.cursor, last_synced_at = EXCLUDED.last_synced_at, status = EXCLUDED.status, error = EXCLUDED.error", + backend, + ), + }; + + sqlx::query(&sql) + .bind(&state.id) + .bind(&state.space_id) + .bind(&state.member_did) + .bind(&state.cursor) + .bind(&state.last_synced_at) + .bind(state.status.as_str()) + .bind(&state.error) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to upsert sync state: {e}")))?; + + Ok(()) +} + +pub async fn list_sync_states_for_space( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE space_id = ? ORDER BY member_did ASC", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .bind(space_id) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list sync states: {e}")))?; + + rows.into_iter().map(parse_sync_state_row).collect() +} + +pub async fn list_pending_syncs( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE status = 'pending' OR status = 'error' ORDER BY last_synced_at ASC NULLS FIRST LIMIT 50", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list pending syncs: {e}")))?; + + rows.into_iter().map(parse_sync_state_row).collect() +} + +type SyncStateRow = ( + String, + String, + String, + Option, + Option, + String, + Option, +); + +fn parse_sync_state_row(r: SyncStateRow) -> Result { + let status = SyncStatus::parse(&r.5) + .ok_or_else(|| AppError::Internal(format!("invalid sync status: {}", r.5)))?; + + Ok(SpaceSyncState { + id: r.0, + space_id: r.1, + member_did: r.2, + cursor: r.3, + last_synced_at: r.4, + status, + error: r.6, + }) +} diff --git a/src/spaces/members.rs b/src/spaces/members.rs new file mode 100644 index 0000000..a7212d7 --- /dev/null +++ b/src/spaces/members.rs @@ -0,0 +1,142 @@ +use std::collections::{HashMap, HashSet}; + +use crate::db::DatabaseBackend; +use crate::error::AppError; +use crate::spaces::SpaceUri; +use crate::spaces::db; +use crate::spaces::types::{ResolvedMember, SpaceAccess, SpaceMember}; + +const MAX_DELEGATION_DEPTH: usize = 10; + +/// Resolve the full member list for a space, traversing delegation references. +/// +/// When a space delegates to another space (is_delegation=true), the delegated +/// space's members are included in the result. If both a direct membership and +/// a delegated membership exist for the same DID, the higher access level wins +/// (write > read). +pub async fn resolve_members( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, +) -> Result, AppError> { + let mut resolved: HashMap = HashMap::new(); + let mut visited: HashSet = HashSet::new(); + + resolve_members_recursive(pool, backend, space_id, &mut resolved, &mut visited, 0).await?; + + let mut members: Vec = resolved + .into_iter() + .map(|(did, access)| ResolvedMember { did, access }) + .collect(); + members.sort_by(|a, b| a.did.cmp(&b.did)); + Ok(members) +} + +/// Check if a DID is a member of a space (resolving delegations). +pub async fn is_member( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + did: &str, +) -> Result, AppError> { + let members = resolve_members(pool, backend, space_id).await?; + Ok(members.into_iter().find(|m| m.did == did).map(|m| m.access)) +} + +fn resolve_members_recursive<'a>( + pool: &'a sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &'a str, + resolved: &'a mut HashMap, + visited: &'a mut HashSet, + depth: usize, +) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + if depth >= MAX_DELEGATION_DEPTH { + return Ok(()); + } + + if !visited.insert(space_id.to_string()) { + return Ok(()); + } + + let direct_members = db::list_direct_members(pool, backend, space_id).await?; + + for member in direct_members { + if member.is_delegation { + let delegated_space_id = resolve_delegation_target(pool, backend, &member).await?; + if let Some(target_id) = delegated_space_id { + resolve_members_recursive( + pool, + backend, + &target_id, + resolved, + visited, + depth + 1, + ) + .await?; + } + } else { + merge_access(resolved, &member.member_did, member.access); + } + } + + Ok(()) + }) +} + +/// Resolve a delegation member entry to the target space ID. +/// +/// Delegation entries store either an ats:// URI or a space ID directly. +async fn resolve_delegation_target( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + member: &SpaceMember, +) -> Result, AppError> { + if member.member_did.starts_with("ats://") { + let uri = SpaceUri::parse(&member.member_did)?; + let space = + db::get_space_by_address(pool, backend, &uri.owner_did, &uri.type_nsid, &uri.skey) + .await?; + Ok(space.map(|s| s.id)) + } else { + let space = db::get_space(pool, backend, &member.member_did).await?; + Ok(space.map(|s| s.id)) + } +} + +fn merge_access(resolved: &mut HashMap, did: &str, access: SpaceAccess) { + let entry = resolved.entry(did.to_string()).or_insert(SpaceAccess::Read); + if access.can_write() { + *entry = SpaceAccess::Write; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_access_write_wins() { + let mut map = HashMap::new(); + merge_access(&mut map, "did:plc:user1", SpaceAccess::Read); + assert_eq!(map["did:plc:user1"], SpaceAccess::Read); + + merge_access(&mut map, "did:plc:user1", SpaceAccess::Write); + assert_eq!(map["did:plc:user1"], SpaceAccess::Write); + + // Write should not be downgraded to Read + merge_access(&mut map, "did:plc:user1", SpaceAccess::Read); + assert_eq!(map["did:plc:user1"], SpaceAccess::Write); + } + + #[test] + fn merge_access_multiple_users() { + let mut map = HashMap::new(); + merge_access(&mut map, "did:plc:alice", SpaceAccess::Write); + merge_access(&mut map, "did:plc:bob", SpaceAccess::Read); + assert_eq!(map.len(), 2); + assert_eq!(map["did:plc:alice"], SpaceAccess::Write); + assert_eq!(map["did:plc:bob"], SpaceAccess::Read); + } +} diff --git a/src/spaces/mod.rs b/src/spaces/mod.rs new file mode 100644 index 0000000..a59c5d0 --- /dev/null +++ b/src/spaces/mod.rs @@ -0,0 +1,212 @@ +pub mod auth; +pub mod credential; +pub mod db; +pub mod members; +pub mod notifications; +pub mod routes; +pub mod sync; +pub mod types; + +use crate::error::AppError; +use std::fmt; + +/// A parsed `ats://` URI for addressing permissioned data. +/// +/// Full form: `ats:///////` +/// Space-only form: `ats:////` +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SpaceUri { + pub owner_did: String, + pub type_nsid: String, + pub skey: String, + pub user_did: Option, + pub collection: Option, + pub rkey: Option, +} + +impl SpaceUri { + pub fn parse(uri: &str) -> Result { + let stripped = uri + .strip_prefix("ats://") + .ok_or_else(|| AppError::BadRequest("SpaceUri must start with ats://".into()))?; + + let parts: Vec<&str> = stripped.split('/').collect(); + + if parts.len() < 3 { + return Err(AppError::BadRequest( + "SpaceUri requires at least owner_did/type_nsid/skey".into(), + )); + } + + if parts[0].is_empty() || parts[1].is_empty() || parts[2].is_empty() { + return Err(AppError::BadRequest( + "SpaceUri components must not be empty".into(), + )); + } + + let owner_did = parts[0].to_string(); + let type_nsid = parts[1].to_string(); + let skey = parts[2].to_string(); + + let (user_did, collection, rkey) = if parts.len() >= 6 { + ( + Some(parts[3].to_string()), + Some(parts[4].to_string()), + Some(parts[5].to_string()), + ) + } else if parts.len() == 3 { + (None, None, None) + } else { + return Err(AppError::BadRequest( + "SpaceUri must have 3 components (space) or 6 components (record)".into(), + )); + }; + + Ok(SpaceUri { + owner_did, + type_nsid, + skey, + user_did, + collection, + rkey, + }) + } + + pub fn space_uri(&self) -> String { + format!("ats://{}/{}/{}", self.owner_did, self.type_nsid, self.skey) + } + + pub fn is_record_uri(&self) -> bool { + self.user_did.is_some() + } + + pub fn is_space_uri(&self) -> bool { + self.user_did.is_none() + } +} + +impl fmt::Display for SpaceUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "ats://{}/{}/{}", + self.owner_did, self.type_nsid, self.skey + )?; + if let (Some(user), Some(col), Some(rkey)) = (&self.user_did, &self.collection, &self.rkey) + { + write!(f, "/{}/{}/{}", user, col, rkey)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_space_uri() { + let uri = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main").unwrap(); + assert_eq!(uri.owner_did, "did:plc:abc123"); + assert_eq!(uri.type_nsid, "com.example.forum"); + assert_eq!(uri.skey, "main"); + assert!(uri.is_space_uri()); + assert!(!uri.is_record_uri()); + assert_eq!(uri.user_did, None); + } + + #[test] + fn parse_record_uri() { + let uri = SpaceUri::parse( + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", + ) + .unwrap(); + assert_eq!(uri.owner_did, "did:plc:abc123"); + assert_eq!(uri.type_nsid, "com.example.forum"); + assert_eq!(uri.skey, "main"); + assert_eq!(uri.user_did.as_deref(), Some("did:plc:user1")); + assert_eq!(uri.collection.as_deref(), Some("com.example.forum.post")); + assert_eq!(uri.rkey.as_deref(), Some("3k2abc")); + assert!(uri.is_record_uri()); + assert!(!uri.is_space_uri()); + } + + #[test] + fn display_space_uri() { + let uri = SpaceUri { + owner_did: "did:plc:abc123".into(), + type_nsid: "com.example.forum".into(), + skey: "main".into(), + user_did: None, + collection: None, + rkey: None, + }; + assert_eq!( + uri.to_string(), + "ats://did:plc:abc123/com.example.forum/main" + ); + } + + #[test] + fn display_record_uri() { + let uri = SpaceUri { + owner_did: "did:plc:abc123".into(), + type_nsid: "com.example.forum".into(), + skey: "main".into(), + user_did: Some("did:plc:user1".into()), + collection: Some("com.example.forum.post".into()), + rkey: Some("3k2abc".into()), + }; + assert_eq!( + uri.to_string(), + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc" + ); + } + + #[test] + fn space_uri_extracts_space_part() { + let uri = SpaceUri::parse( + "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc", + ) + .unwrap(); + assert_eq!( + uri.space_uri(), + "ats://did:plc:abc123/com.example.forum/main" + ); + } + + #[test] + fn reject_at_scheme() { + let result = SpaceUri::parse("at://did:plc:abc123/com.example.forum/main"); + assert!(result.is_err()); + } + + #[test] + fn reject_too_few_components() { + let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum"); + assert!(result.is_err()); + } + + #[test] + fn reject_wrong_component_count() { + let result = SpaceUri::parse("ats://did:plc:abc123/com.example.forum/main/did:plc:user1"); + assert!(result.is_err()); + } + + #[test] + fn reject_empty_components() { + let result = SpaceUri::parse("ats:///com.example.forum/main"); + assert!(result.is_err()); + } + + #[test] + fn roundtrip_parse_display() { + let original = "ats://did:plc:abc123/com.example.forum/main"; + let uri = SpaceUri::parse(original).unwrap(); + assert_eq!(uri.to_string(), original); + + let original_record = "ats://did:plc:abc123/com.example.forum/main/did:plc:user1/com.example.forum.post/3k2abc"; + let uri = SpaceUri::parse(original_record).unwrap(); + assert_eq!(uri.to_string(), original_record); + } +} diff --git a/src/spaces/notifications.rs b/src/spaces/notifications.rs new file mode 100644 index 0000000..3d17e12 --- /dev/null +++ b/src/spaces/notifications.rs @@ -0,0 +1,86 @@ +use serde::Deserialize; +use uuid::Uuid; + +use crate::db::DatabaseBackend; +use crate::error::AppError; +use crate::spaces::db; +use crate::spaces::types::*; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WriteNotification { + pub space_uri: String, + pub author_did: String, + pub collection: String, + pub rkey: String, + pub action: WriteAction, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WriteAction { + Create, + Update, + Delete, +} + +/// Process a write notification by queuing a sync pull for the affected member. +/// +/// This marks the member's sync state as pending so the next sync pass picks it up. +pub async fn handle_write_notification( + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + space_id: &str, + notification: &WriteNotification, +) -> Result<(), AppError> { + let existing = db::get_sync_state(pool, backend, space_id, ¬ification.author_did).await?; + + let state = SpaceSyncState { + id: existing + .map(|s| s.id) + .unwrap_or_else(|| Uuid::new_v4().to_string()), + space_id: space_id.to_string(), + member_did: notification.author_did.clone(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Pending, + error: None, + }; + + db::upsert_sync_state(pool, backend, &state).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_action_deserializes() { + let action: WriteAction = serde_json::from_str("\"create\"").unwrap(); + assert!(matches!(action, WriteAction::Create)); + + let action: WriteAction = serde_json::from_str("\"update\"").unwrap(); + assert!(matches!(action, WriteAction::Update)); + + let action: WriteAction = serde_json::from_str("\"delete\"").unwrap(); + assert!(matches!(action, WriteAction::Delete)); + } + + #[test] + fn write_notification_deserializes() { + let json = r#"{ + "spaceUri": "ats://did:plc:owner/com.example.forum/main", + "authorDid": "did:plc:alice", + "collection": "com.example.forum.post", + "rkey": "3k2abc", + "action": "create" + }"#; + + let notif: WriteNotification = serde_json::from_str(json).unwrap(); + assert_eq!(notif.author_did, "did:plc:alice"); + assert_eq!(notif.collection, "com.example.forum.post"); + assert!(matches!(notif.action, WriteAction::Create)); + } +} diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs new file mode 100644 index 0000000..235e2bb --- /dev/null +++ b/src/spaces/routes.rs @@ -0,0 +1,982 @@ +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::db::{adapt_sql, 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")] +struct CreateSpaceInput { + type_nsid: String, + skey: String, + display_name: Option, + description: Option, + access_mode: Option, + managing_app_did: Option, + config: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SpaceUriQuery { + space_uri: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListSpacesQuery { + owner_did: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteSpaceInput { + space_uri: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpdateSpaceInput { + space_uri: 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 PutRecordInput { + space_uri: String, + collection: String, + rkey: String, + record: serde_json::Value, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeleteRecordInput { + space_uri: String, + collection: String, + rkey: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetRecordQuery { + space_uri: String, + collection: String, + rkey: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListRecordsQuery { + space_uri: String, + collection: Option, + limit: Option, + cursor: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AddMemberInput { + space_uri: String, + member_did: String, + access: Option, + is_delegation: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RemoveMemberInput { + space_uri: String, + member_did: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateInviteInput { + space_uri: String, + access: Option, + max_uses: Option, + expires_at: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RedeemInviteInput { + token: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RevokeInviteInput { + space_uri: String, + invite_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct GetCredentialInput { + space_uri: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RefreshCredentialInput { + space_uri: String, + credential: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct WriteNotificationInput { + space_uri: String, + author_did: String, + collection: String, + rkey: String, + action: crate::spaces::notifications::WriteAction, +} + +// --------------------------------------------------------------------------- +// Route registration +// --------------------------------------------------------------------------- + +const NS: &str = "dev.happyview"; + +pub fn space_routes() -> Router { + Router::new() + // Space CRUD + .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)) + // Records + .route(&format!("/xrpc/{NS}.space.putRecord"), post(put_record)) + .route( + &format!("/xrpc/{NS}.space.deleteRecord"), + post(delete_record), + ) + .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), + ) + // Invites + .route( + &format!("/xrpc/{NS}.space.invite.create"), + post(create_invite), + ) + .route( + &format!("/xrpc/{NS}.space.invite.redeem"), + post(redeem_invite), + ) + .route( + &format!("/xrpc/{NS}.space.invite.revoke"), + post(revoke_invite), + ) + .route(&format!("/xrpc/{NS}.space.invite.list"), get(list_invites)) + // Credentials + .route( + &format!("/xrpc/{NS}.space.getCredential"), + post(get_credential), + ) + .route( + &format!("/xrpc/{NS}.space.refreshCredential"), + post(refresh_credential), + ) + // Notifications + .route( + &format!("/xrpc/{NS}.space.writeNotification"), + post(write_notification), + ) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { + claims + .0 + .as_ref() + .ok_or_else(|| AppError::Auth("This endpoint requires DPoP 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.owner_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> { + if space.owner_did == did { + return Ok(()); + } + let sql = adapt_sql("SELECT is_super FROM 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(), + )) +} + +fn extract_space_credential(headers: &HeaderMap) -> Option { + headers + .get("x-space-credential") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) +} + +async fn require_membership( + state: &AppState, + space: &Space, + did: &str, + require_write: bool, + space_credential: Option<&str>, +) -> Result { + if let Some(token) = space_credential { + let space_uri = format!( + "ats://{}/{}/{}", + space.owner_did, space.type_nsid, space.skey + ); + match crate::spaces::credential::verify_external_credential( + token, + &state.http, + &state.config.plc_url, + ) + .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() { + return Err(AppError::Forbidden( + "Write access is required for this action".into(), + )); + } + return Ok(access); + } + Ok(_) => { + // Credential is valid but for a different space — fall through + } + Err(_) => { + // External verification failed — fall through to local check + } + } + } + + let access = members::is_member(&state.db, state.db_backend, &space.id, did) + .await? + .ok_or_else(|| AppError::Forbidden("You are not a member of this space".into()))?; + if require_write && !access.can_write() { + return Err(AppError::Forbidden( + "Write access is required for this action".into(), + )); + } + Ok(access) +} + +fn content_cid(record: &serde_json::Value) -> String { + let bytes = serde_json::to_vec(record).unwrap_or_default(); + let hash = Sha256::digest(&bytes); + format!("bafyrei{}", hex::encode(&hash[..20])) +} + +// --------------------------------------------------------------------------- +// Space CRUD 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_nsid 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(), + 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(), + 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(), + member_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.owner_did, space.type_nsid, space.skey + ); + let body = serde_json::json!({ + "spaceUri": space_uri, + "space": space, + }); + + let mut response = Json(body).into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + +async fn get_space( + State(state): State, + xrpc_claims: XrpcClaims, + Query(query): Query, +) -> Result, AppError> { + let space = resolve_space(&state, &query.space_uri).await?; + + // If the space's membership is not public, require auth + membership + if !space.config.membership_public { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did(); + if space.owner_did != did { + members::is_member(&state.db, state.db_backend, &space.id, did) + .await? + .ok_or_else(|| AppError::NotFound("Space not found".into()))?; + } + } + + let space_uri = format!( + "ats://{}/{}/{}", + space.owner_did, space.type_nsid, space.skey + ); + Ok(Json(serde_json::json!({ + "spaceUri": space_uri, + "space": space, + }))) +} + +async fn list_spaces( + State(state): State, + xrpc_claims: XrpcClaims, + Query(query): Query, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + + let owner = query.owner_did.as_deref().unwrap_or(&did); + let spaces = db::list_spaces_by_owner(&state.db, state.db_backend, owner).await?; + + let spaces_with_uris: Vec = spaces + .into_iter() + .map(|s| { + let uri = format!("ats://{}/{}/{}", s.owner_did, s.type_nsid, s.skey); + serde_json::json!({ "spaceUri": uri, "space": s }) + }) + .collect(); + + Ok(Json(serde_json::json!({ "spaces": spaces_with_uris }))) +} + +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_uri).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_uri).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.owner_did, space.type_nsid, space.skey + ); + Ok(Json(serde_json::json!({ + "spaceUri": space_uri, + "space": space, + }))) +} + +// --------------------------------------------------------------------------- +// Record handlers +// --------------------------------------------------------------------------- + +async fn put_record( + State(state): State, + xrpc_claims: XrpcClaims, + headers: HeaderMap, + Json(input): Json, +) -> Result { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + let space = resolve_space(&state, &input.space_uri).await?; + let cred = extract_space_credential(&headers); + require_membership(&state, &space, &did, true, cred.as_deref()).await?; + + let cid = content_cid(&input.record); + let record_uri = format!( + "ats://{}/{}/{}/{}/{}/{}", + space.owner_did, space.type_nsid, space.skey, did, input.collection, input.rkey + ); + + let record = SpaceRecord { + uri: record_uri.clone(), + space_id: space.id, + author_did: did, + collection: input.collection, + rkey: input.rkey, + record: input.record, + cid: cid.clone(), + indexed_at: now_rfc3339(), + }; + + db::upsert_space_record(&state.db, state.db_backend, &record).await?; + + let body = serde_json::json!({ + "uri": record_uri, + "cid": cid, + }); + + let mut response = Json(body).into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + +async fn delete_record( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + let space = resolve_space(&state, &input.space_uri).await?; + + let record_uri = format!( + "ats://{}/{}/{}/{}/{}/{}", + space.owner_did, space.type_nsid, space.skey, did, input.collection, input.rkey + ); + + let record = db::get_space_record(&state.db, state.db_backend, &record_uri).await?; + match record { + Some(r) if r.author_did != did => { + return Err(AppError::Forbidden( + "You can only delete your own records".into(), + )); + } + None => { + return Err(AppError::NotFound("Record not found".into())); + } + _ => {} + } + + db::delete_space_record(&state.db, state.db_backend, &record_uri).await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} + +async fn get_record( + State(state): State, + xrpc_claims: XrpcClaims, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &query.space_uri).await?; + let cred = extract_space_credential(&headers); + require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + + let record = db::get_space_record_by_parts( + &state.db, + state.db_backend, + &space.id, + &query.collection, + &query.rkey, + ) + .await? + .ok_or_else(|| AppError::NotFound("Record not found".into()))?; + + Ok(Json(serde_json::json!({ + "uri": record.uri, + "space": query.space_uri, + "collection": record.collection, + "record": record.record, + "cid": record.cid, + }))) +} + +async fn list_records( + State(state): State, + xrpc_claims: XrpcClaims, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &query.space_uri).await?; + let cred = extract_space_credential(&headers); + require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + + let limit = query.limit.unwrap_or(50).min(100); + let records = db::list_space_records( + &state.db, + state.db_backend, + &space.id, + query.collection.as_deref(), + limit, + query.cursor.as_deref(), + ) + .await?; + + let cursor = records.last().map(|r| r.indexed_at.clone()); + + let records_json: Vec = records + .into_iter() + .map(|r| { + serde_json::json!({ + "uri": r.uri, + "space": query.space_uri, + "collection": r.collection, + "record": r.record, + "cid": r.cid, + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "records": records_json, + "cursor": cursor, + }))) +} + +// --------------------------------------------------------------------------- +// Member handlers +// --------------------------------------------------------------------------- + +async fn list_members( + State(state): State, + xrpc_claims: XrpcClaims, + headers: HeaderMap, + Query(query): Query, +) -> Result, AppError> { + let space = resolve_space(&state, &query.space_uri).await?; + + if !space.config.membership_public { + let claims = require_auth(&xrpc_claims)?; + let cred = extract_space_credential(&headers); + require_membership(&state, &space, claims.did(), false, cred.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_uri).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let existing = + db::get_member(&state.db, state.db_backend, &space.id, &input.member_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, + member_did: input.member_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_uri).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let removed = + db::remove_member(&state.db, state.db_backend, &space.id, &input.member_did).await?; + + if !removed { + return Err(AppError::NotFound("Member not found in this space".into())); + } + + Ok(Json(serde_json::json!({ "success": true }))) +} + +// --------------------------------------------------------------------------- +// Invite handlers +// --------------------------------------------------------------------------- + +async fn create_invite( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space_uri).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let mut token_bytes = [0u8; 24]; + rand::Fill::fill(&mut token_bytes, &mut rand::rng()); + let token = hex::encode(token_bytes); + let token_hash = hex::encode(Sha256::digest(token.as_bytes())); + + let invite = SpaceInvite { + id: Uuid::new_v4().to_string(), + space_id: space.id, + token_hash, + created_by: claims.did().to_string(), + access: input.access.unwrap_or(SpaceAccess::Read), + max_uses: input.max_uses, + uses: 0, + expires_at: input.expires_at, + revoked: false, + created_at: now_rfc3339(), + }; + + db::create_invite(&state.db, state.db_backend, &invite).await?; + + let mut response = Json(serde_json::json!({ + "inviteId": invite.id, + "token": token, + "access": invite.access, + "maxUses": invite.max_uses, + "expiresAt": invite.expires_at, + })) + .into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + +async fn redeem_invite( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + + let token_hash = hex::encode(Sha256::digest(input.token.as_bytes())); + let invite = db::get_invite_by_token_hash(&state.db, state.db_backend, &token_hash) + .await? + .ok_or_else(|| AppError::NotFound("Invalid invite token".into()))?; + + if invite.revoked { + return Err(AppError::BadRequest("This invite has been revoked".into())); + } + + if let Some(max) = invite.max_uses + && invite.uses >= max + { + return Err(AppError::BadRequest( + "This invite has reached its maximum uses".into(), + )); + } + + if let Some(ref expires) = invite.expires_at { + let now = now_rfc3339(); + if now > *expires { + return Err(AppError::BadRequest("This invite has expired".into())); + } + } + + let existing = db::get_member(&state.db, state.db_backend, &invite.space_id, &did).await?; + if existing.is_some() { + return Err(AppError::Conflict( + "You are already a member of this space".into(), + )); + } + + let member = SpaceMember { + id: Uuid::new_v4().to_string(), + space_id: invite.space_id.clone(), + member_did: did, + access: invite.access, + is_delegation: false, + granted_by: Some(invite.created_by.clone()), + created_at: now_rfc3339(), + }; + + db::add_member(&state.db, state.db_backend, &member).await?; + db::increment_invite_uses(&state.db, state.db_backend, &invite.id).await?; + + let space = db::get_space(&state.db, state.db_backend, &invite.space_id).await?; + let space_uri = space.map(|s| format!("ats://{}/{}/{}", s.owner_did, s.type_nsid, s.skey)); + + let mut response = Json(serde_json::json!({ + "spaceUri": space_uri, + "access": member.access, + })) + .into_response(); + *response.status_mut() = StatusCode::CREATED; + Ok(response) +} + +async fn revoke_invite( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space_uri).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let revoked = db::revoke_invite(&state.db, state.db_backend, &input.invite_id).await?; + if !revoked { + return Err(AppError::NotFound("Invite not found".into())); + } + + Ok(Json(serde_json::json!({ "success": true }))) +} + +async fn list_invites( + State(state): State, + xrpc_claims: XrpcClaims, + Query(query): Query, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &query.space_uri).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let invites = db::list_invites(&state.db, state.db_backend, &space.id).await?; + + let invites_json: Vec = invites + .into_iter() + .map(|i| { + serde_json::json!({ + "id": i.id, + "access": i.access, + "maxUses": i.max_uses, + "uses": i.uses, + "expiresAt": i.expires_at, + "revoked": i.revoked, + "createdBy": i.created_by, + "createdAt": i.created_at, + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ "invites": invites_json }))) +} + +// --------------------------------------------------------------------------- +// Credential handlers +// --------------------------------------------------------------------------- + +async fn get_credential( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let did = claims.did().to_string(); + let space = resolve_space(&state, &input.space_uri).await?; + + require_membership(&state, &space, &did, false, None).await?; + + 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 client_id = claims.client_key().map(|k| k.to_string()); + let issued = crate::spaces::auth::issue_credential( + &state.db, + state.db_backend, + encryption_key, + &space, + &did, + client_id.as_deref(), + ) + .await?; + + Ok(Json(serde_json::json!({ + "credential": issued.token, + "expiresAt": issued.expires_at, + }))) +} + +async fn refresh_credential( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let _claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space_uri).await?; + + 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 issued = crate::spaces::auth::refresh_credential( + &state.db, + state.db_backend, + encryption_key, + &space, + &input.credential, + ) + .await?; + + Ok(Json(serde_json::json!({ + "credential": issued.token, + "expiresAt": issued.expires_at, + }))) +} + +// --------------------------------------------------------------------------- +// Notification handlers +// --------------------------------------------------------------------------- + +async fn write_notification( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result, AppError> { + let claims = require_auth(&xrpc_claims)?; + let space = resolve_space(&state, &input.space_uri).await?; + require_space_admin(&state, &space, claims.did()).await?; + + let notification = crate::spaces::notifications::WriteNotification { + space_uri: input.space_uri, + author_did: input.author_did, + collection: input.collection, + rkey: input.rkey, + action: input.action, + }; + + crate::spaces::notifications::handle_write_notification( + &state.db, + state.db_backend, + &space.id, + ¬ification, + ) + .await?; + + Ok(Json(serde_json::json!({ "success": true }))) +} diff --git a/src/spaces/sync.rs b/src/spaces/sync.rs new file mode 100644 index 0000000..40d5f72 --- /dev/null +++ b/src/spaces/sync.rs @@ -0,0 +1,293 @@ +use uuid::Uuid; + +use crate::db::DatabaseBackend; +use crate::db::now_rfc3339; +use crate::error::AppError; +use crate::profile::resolve_pds_endpoint; +use crate::spaces::types::*; +use crate::spaces::{db, members}; + +/// Sync all members of a space by pulling records from their PDSes. +pub async fn sync_space( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + collections: &[String], +) -> Result { + let resolved = members::resolve_members(pool, backend, space_id).await?; + let mut results = Vec::new(); + + for member in &resolved { + let result = sync_member( + http, + pool, + backend, + plc_url, + space_id, + &member.did, + collections, + ) + .await; + + results.push(MemberSyncResult { + did: member.did.clone(), + records_synced: result.as_ref().map(|r| r.records_synced).unwrap_or(0), + error: result.err().map(|e| e.to_string()), + }); + } + + let total = results.iter().map(|r| r.records_synced).sum(); + + Ok(SyncSpaceResult { + members_processed: results.len(), + total_records_synced: total, + member_results: results, + }) +} + +/// Sync records from a single member's PDS for a given space. +pub async fn sync_member( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + member_did: &str, + collections: &[String], +) -> Result { + let state_id = match db::get_sync_state(pool, backend, space_id, member_did).await? { + Some(s) => s.id, + None => { + let id = Uuid::new_v4().to_string(); + let initial = SpaceSyncState { + id: id.clone(), + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Pending, + error: None, + }; + db::upsert_sync_state(pool, backend, &initial).await?; + id + } + }; + + // Mark as syncing + let syncing_state = SpaceSyncState { + id: state_id.clone(), + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: None, + status: SyncStatus::Syncing, + error: None, + }; + db::upsert_sync_state(pool, backend, &syncing_state).await?; + + let result = pull_member_records( + http, + pool, + backend, + plc_url, + space_id, + member_did, + collections, + ) + .await; + + match result { + Ok(summary) => { + let done = SpaceSyncState { + id: state_id, + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: summary.cursor.clone(), + last_synced_at: Some(now_rfc3339()), + status: SyncStatus::Synced, + error: None, + }; + db::upsert_sync_state(pool, backend, &done).await?; + Ok(summary) + } + Err(e) => { + let err_state = SpaceSyncState { + id: state_id, + space_id: space_id.to_string(), + member_did: member_did.to_string(), + cursor: None, + last_synced_at: Some(now_rfc3339()), + status: SyncStatus::Error, + error: Some(e.to_string()), + }; + db::upsert_sync_state(pool, backend, &err_state).await?; + Err(e) + } + } +} + +async fn pull_member_records( + http: &reqwest::Client, + pool: &sqlx::AnyPool, + backend: DatabaseBackend, + plc_url: &str, + space_id: &str, + member_did: &str, + collections: &[String], +) -> Result { + let pds_url = resolve_pds_endpoint(http, plc_url, member_did).await?; + let mut total_records = 0usize; + let mut last_cursor = None; + + for collection in collections { + let mut cursor: Option = None; + loop { + let (records, next_cursor) = fetch_records_page( + http, + &pds_url, + member_did, + collection, + cursor.as_deref(), + 100, + ) + .await?; + + if records.is_empty() { + break; + } + + for record in &records { + let uri = record["uri"].as_str().unwrap_or(""); + let rkey = extract_rkey(uri); + let cid = record["cid"].as_str().unwrap_or("").to_string(); + let value = record + .get("value") + .cloned() + .unwrap_or(serde_json::Value::Null); + + let space_record_uri = format!("ats://{space_id}/{member_did}/{collection}/{rkey}"); + + let space_record = SpaceRecord { + uri: space_record_uri, + space_id: space_id.to_string(), + author_did: member_did.to_string(), + collection: collection.clone(), + rkey: rkey.to_string(), + record: value, + cid, + indexed_at: now_rfc3339(), + }; + + db::upsert_space_record(pool, backend, &space_record).await?; + total_records += 1; + } + + last_cursor = next_cursor.clone(); + cursor = next_cursor; + + if cursor.is_none() { + break; + } + } + } + + Ok(MemberSyncSummary { + records_synced: total_records, + cursor: last_cursor, + }) +} + +async fn fetch_records_page( + http: &reqwest::Client, + pds_url: &str, + repo: &str, + collection: &str, + cursor: Option<&str>, + limit: u32, +) -> Result<(Vec, Option), AppError> { + let mut url = format!( + "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", + pds_url.trim_end_matches('/'), + repo, + collection, + limit, + ); + + if let Some(c) = cursor { + url.push_str(&format!("&cursor={c}")); + } + + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + return Err(AppError::Internal(format!( + "PDS listRecords failed with {status} for {repo}/{collection}" + ))); + } + + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; + + let records = body["records"].as_array().cloned().unwrap_or_default(); + + let next_cursor = body["cursor"].as_str().map(|s| s.to_string()); + + Ok((records, next_cursor)) +} + +fn extract_rkey(uri: &str) -> &str { + uri.rsplit('/').next().unwrap_or("") +} + +// --------------------------------------------------------------------------- +// Result types +// --------------------------------------------------------------------------- + +pub struct SyncSpaceResult { + pub members_processed: usize, + pub total_records_synced: usize, + pub member_results: Vec, +} + +pub struct MemberSyncResult { + pub did: String, + pub records_synced: usize, + pub error: Option, +} + +pub struct MemberSyncSummary { + pub records_synced: usize, + pub cursor: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_rkey_from_at_uri() { + assert_eq!( + extract_rkey("at://did:plc:abc/app.bsky.feed.post/3k2abc"), + "3k2abc" + ); + } + + #[test] + fn extract_rkey_from_empty() { + assert_eq!(extract_rkey(""), ""); + } + + #[test] + fn extract_rkey_no_slash() { + assert_eq!(extract_rkey("singlevalue"), "singlevalue"); + } +} diff --git a/src/spaces/types.rs b/src/spaces/types.rs new file mode 100644 index 0000000..3d55ebb --- /dev/null +++ b/src/spaces/types.rs @@ -0,0 +1,248 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SpaceAccess { + Read, + Write, +} + +impl SpaceAccess { + pub fn as_str(&self) -> &'static str { + match self { + SpaceAccess::Read => "read", + SpaceAccess::Write => "write", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "read" => Some(SpaceAccess::Read), + "write" => Some(SpaceAccess::Write), + _ => None, + } + } + + pub fn can_write(&self) -> bool { + matches!(self, SpaceAccess::Write) + } + + pub fn can_read(&self) -> bool { + true + } +} + +impl fmt::Display for SpaceAccess { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccessMode { + DefaultAllow, + DefaultDeny, +} + +impl AccessMode { + pub fn as_str(&self) -> &'static str { + match self { + AccessMode::DefaultAllow => "default_allow", + AccessMode::DefaultDeny => "default_deny", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "default_allow" => Some(AccessMode::DefaultAllow), + "default_deny" => Some(AccessMode::DefaultDeny), + _ => None, + } + } +} + +impl fmt::Display for AccessMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Space { + pub id: String, + pub owner_did: String, + 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 managing_app_did: Option, + pub config: SpaceConfig, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SpaceConfig { + #[serde(default)] + pub membership_public: bool, + #[serde(default)] + pub records_public: bool, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceMember { + pub id: String, + pub space_id: String, + pub member_did: String, + pub access: SpaceAccess, + pub is_delegation: bool, + pub granted_by: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolvedMember { + pub did: String, + pub access: SpaceAccess, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceRecord { + pub uri: String, + pub space_id: String, + pub author_did: String, + pub collection: String, + pub rkey: String, + pub record: serde_json::Value, + pub cid: String, + pub indexed_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceInvite { + pub id: String, + pub space_id: String, + pub token_hash: String, + pub created_by: String, + pub access: SpaceAccess, + pub max_uses: Option, + pub uses: i64, + pub expires_at: Option, + pub revoked: bool, + pub created_at: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SyncStatus { + Pending, + Syncing, + Synced, + Error, +} + +impl SyncStatus { + pub fn as_str(&self) -> &'static str { + match self { + SyncStatus::Pending => "pending", + SyncStatus::Syncing => "syncing", + SyncStatus::Synced => "synced", + SyncStatus::Error => "error", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "pending" => Some(SyncStatus::Pending), + "syncing" => Some(SyncStatus::Syncing), + "synced" => Some(SyncStatus::Synced), + "error" => Some(SyncStatus::Error), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpaceSyncState { + pub id: String, + pub space_id: String, + pub member_did: String, + pub cursor: Option, + pub last_synced_at: Option, + pub status: SyncStatus, + pub error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn space_access_roundtrip() { + assert_eq!(SpaceAccess::parse("read"), Some(SpaceAccess::Read)); + assert_eq!(SpaceAccess::parse("write"), Some(SpaceAccess::Write)); + assert_eq!(SpaceAccess::parse("admin"), None); + + assert_eq!(SpaceAccess::Read.as_str(), "read"); + assert_eq!(SpaceAccess::Write.as_str(), "write"); + } + + #[test] + fn space_access_permissions() { + assert!(SpaceAccess::Read.can_read()); + assert!(!SpaceAccess::Read.can_write()); + assert!(SpaceAccess::Write.can_read()); + assert!(SpaceAccess::Write.can_write()); + } + + #[test] + fn access_mode_roundtrip() { + assert_eq!( + AccessMode::parse("default_allow"), + Some(AccessMode::DefaultAllow) + ); + assert_eq!( + AccessMode::parse("default_deny"), + Some(AccessMode::DefaultDeny) + ); + assert_eq!(AccessMode::parse("open"), None); + } + + #[test] + fn space_config_defaults() { + let config: SpaceConfig = serde_json::from_str("{}").unwrap(); + assert!(!config.membership_public); + assert!(!config.records_public); + } + + #[test] + fn space_config_with_extra_fields() { + let config: SpaceConfig = + serde_json::from_str(r#"{"membership_public": true, "custom_field": 42}"#).unwrap(); + assert!(config.membership_public); + assert!(!config.records_public); + assert_eq!(config.extra.get("custom_field").unwrap(), &42); + } + + #[test] + fn space_access_serialization() { + let json = serde_json::to_string(&SpaceAccess::Read).unwrap(); + assert_eq!(json, "\"read\""); + + 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("\"write\"").unwrap(); + assert_eq!(parsed, SpaceAccess::Write); + } +} diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index c24dc4b..1bf9879 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -20,7 +20,7 @@ pub(crate) async fn handle_procedure( ) -> Result { if let Some(ref script) = lexicon.script { return crate::lua::execute_procedure_script( - state, method, claims, input, params, lexicon, script, + state, method, claims, input, params, lexicon, script, None, ) .await; } diff --git a/src/xrpc/query.rs b/src/xrpc/query.rs index c860915..1c2a126 100644 --- a/src/xrpc/query.rs +++ b/src/xrpc/query.rs @@ -16,8 +16,10 @@ pub(crate) async fn handle_query( claims: Option<&Claims>, ) -> Result { if let Some(ref script) = lexicon.script { - return crate::lua::execute_query_script(state, method, params, lexicon, script, claims) - .await; + return crate::lua::execute_query_script( + state, method, params, lexicon, script, claims, None, + ) + .await; } // Single-record query: has a `uri` parameter -- 2.51.2 From 90262329a777a44cbb778facd700ea99aaca00ed Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 30 Apr 2026 09:10:11 -0500 Subject: [PATCH 10/17] feat: add support for account write delegation --- ...260429100000_create_delegated_accounts.sql | 6 + ...0260429100001_create_account_delegates.sql | 8 + ...260429100000_create_delegated_accounts.sql | 6 + ...0260429100001_create_account_delegates.sql | 8 + src/delegation/add_delegate.rs | 103 ++ src/delegation/db.rs | 223 +++ src/delegation/get_account.rs | 46 + src/delegation/link_account.rs | 98 ++ src/delegation/list_accounts.rs | 27 + src/delegation/list_delegates.rs | 48 + src/delegation/mod.rs | 91 + src/delegation/remove_delegate.rs | 92 + src/delegation/unlink_account.rs | 85 + src/lib.rs | 1 + src/lua/context.rs | 35 + src/lua/execute.rs | 10 +- src/lua/record.rs | 26 +- src/server.rs | 29 + src/xrpc/procedure.rs | 122 +- tests/common/db.rs | 4 +- tests/e2e_delegation.rs | 1534 +++++++++++++++++ 21 files changed, 2589 insertions(+), 13 deletions(-) create mode 100644 migrations/postgres/20260429100000_create_delegated_accounts.sql create mode 100644 migrations/postgres/20260429100001_create_account_delegates.sql create mode 100644 migrations/sqlite/20260429100000_create_delegated_accounts.sql create mode 100644 migrations/sqlite/20260429100001_create_account_delegates.sql create mode 100644 src/delegation/add_delegate.rs create mode 100644 src/delegation/db.rs create mode 100644 src/delegation/get_account.rs create mode 100644 src/delegation/link_account.rs create mode 100644 src/delegation/list_accounts.rs create mode 100644 src/delegation/list_delegates.rs create mode 100644 src/delegation/mod.rs create mode 100644 src/delegation/remove_delegate.rs create mode 100644 src/delegation/unlink_account.rs create mode 100644 tests/e2e_delegation.rs diff --git a/migrations/postgres/20260429100000_create_delegated_accounts.sql b/migrations/postgres/20260429100000_create_delegated_accounts.sql new file mode 100644 index 0000000..b6cd282 --- /dev/null +++ b/migrations/postgres/20260429100000_create_delegated_accounts.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS delegated_accounts ( + account_did TEXT PRIMARY KEY, + linked_by TEXT NOT NULL, + api_client_id TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/migrations/postgres/20260429100001_create_account_delegates.sql b/migrations/postgres/20260429100001_create_account_delegates.sql new file mode 100644 index 0000000..10ecf71 --- /dev/null +++ b/migrations/postgres/20260429100001_create_account_delegates.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS account_delegates ( + account_did TEXT NOT NULL REFERENCES delegated_accounts(account_did) ON DELETE CASCADE, + user_did TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), + granted_by TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (account_did, user_did) +); diff --git a/migrations/sqlite/20260429100000_create_delegated_accounts.sql b/migrations/sqlite/20260429100000_create_delegated_accounts.sql new file mode 100644 index 0000000..b6cd282 --- /dev/null +++ b/migrations/sqlite/20260429100000_create_delegated_accounts.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS delegated_accounts ( + account_did TEXT PRIMARY KEY, + linked_by TEXT NOT NULL, + api_client_id TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/migrations/sqlite/20260429100001_create_account_delegates.sql b/migrations/sqlite/20260429100001_create_account_delegates.sql new file mode 100644 index 0000000..10ecf71 --- /dev/null +++ b/migrations/sqlite/20260429100001_create_account_delegates.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS account_delegates ( + account_did TEXT NOT NULL REFERENCES delegated_accounts(account_did) ON DELETE CASCADE, + user_did TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), + granted_by TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (account_did, user_did) +); diff --git a/src/delegation/add_delegate.rs b/src/delegation/add_delegate.rs new file mode 100644 index 0000000..a4ecf0f --- /dev/null +++ b/src/delegation/add_delegate.rs @@ -0,0 +1,103 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::DelegateRole; +use super::db; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddDelegateInput { + pub account_did: String, + pub user_did: String, + pub role: String, +} + +pub async fn add_delegate( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("addDelegate requires authentication".into()))?; + + let caller_did = claims.did().to_string(); + + super::verify_client_scope(&state, &claims, &input.account_did).await?; + + let caller_role = + db::get_delegate_role(&state.db, state.db_backend, &input.account_did, &caller_did) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !caller_role.can_manage_members() { + return Err(AppError::Forbidden( + "only owners and admins can add delegates".into(), + )); + } + + let target_role = match input.role.as_str() { + "admin" => DelegateRole::Admin, + "member" => DelegateRole::Member, + "owner" => { + return Err(AppError::BadRequest( + "cannot add a second owner — use unlinkAccount and re-link instead".into(), + )); + } + _ => { + return Err(AppError::BadRequest( + "role must be 'admin' or 'member'".into(), + )); + } + }; + + let existing = db::get_delegate_role( + &state.db, + state.db_backend, + &input.account_did, + &input.user_did, + ) + .await?; + if existing.is_some() { + return Err(AppError::Conflict( + "user is already a delegate — remove them first to change role".into(), + )); + } + + db::add_delegate( + &state.db, + state.db_backend, + &input.account_did, + &input.user_did, + target_role, + &caller_did, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "delegation.delegate_added".to_string(), + severity: Severity::Info, + actor_did: Some(caller_did), + subject: Some(input.user_did.clone()), + detail: json!({ + "account_did": input.account_did, + "role": input.role, + }), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::CREATED, Json(json!({}))).into_response()) +} diff --git a/src/delegation/db.rs b/src/delegation/db.rs new file mode 100644 index 0000000..4127916 --- /dev/null +++ b/src/delegation/db.rs @@ -0,0 +1,223 @@ +use crate::db::{adapt_sql, now_rfc3339}; +use crate::error::AppError; +use sqlx::AnyPool; + +use super::{DelegateRole, DelegateView, DelegatedAccountView}; + +pub async fn create_delegated_account( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + linked_by: &str, + api_client_id: &str, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO delegated_accounts (account_did, linked_by, api_client_id, created_at) VALUES (?, ?, ?, ?)", + backend, + ); + sqlx::query(&sql) + .bind(account_did) + .bind(linked_by) + .bind(api_client_id) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to create delegated account: {e}")))?; + Ok(()) +} + +pub async fn delete_delegated_account( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result<(), AppError> { + let sql = adapt_sql( + "DELETE FROM delegated_accounts WHERE account_did = ?", + backend, + ); + sqlx::query(&sql) + .bind(account_did) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to delete delegated account: {e}")))?; + Ok(()) +} + +pub async fn get_delegated_account_owner( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT linked_by FROM delegated_accounts WHERE account_did = ?", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(account_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to query delegated account: {e}")))?; + Ok(row.map(|r| r.0)) +} + +pub async fn is_account_linked( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result { + let owner = get_delegated_account_owner(pool, backend, account_did).await?; + Ok(owner.is_some()) +} + +pub async fn get_api_client_id( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT api_client_id FROM delegated_accounts WHERE account_did = ?", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(account_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to query delegated account: {e}")))?; + Ok(row.map(|r| r.0)) +} + +pub async fn add_delegate( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + user_did: &str, + role: DelegateRole, + granted_by: &str, +) -> Result<(), AppError> { + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO account_delegates (account_did, user_did, role, granted_by, created_at) VALUES (?, ?, ?, ?, ?)", + backend, + ); + sqlx::query(&sql) + .bind(account_did) + .bind(user_did) + .bind(role.as_str()) + .bind(granted_by) + .bind(&now) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to add delegate: {e}")))?; + Ok(()) +} + +pub async fn remove_delegate( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + user_did: &str, +) -> Result<(), AppError> { + let sql = adapt_sql( + "DELETE FROM account_delegates WHERE account_did = ? AND user_did = ?", + backend, + ); + sqlx::query(&sql) + .bind(account_did) + .bind(user_did) + .execute(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to remove delegate: {e}")))?; + Ok(()) +} + +pub async fn get_delegate_role( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + user_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT role FROM account_delegates WHERE account_did = ? AND user_did = ?", + backend, + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(account_did) + .bind(user_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to query delegate role: {e}")))?; + Ok(row.and_then(|r| DelegateRole::from_str(&r.0))) +} + +pub async fn list_accounts_for_user( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + user_did: &str, + api_client_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT ad.account_did, ad.role, ad.created_at FROM account_delegates ad JOIN delegated_accounts da ON da.account_did = ad.account_did WHERE ad.user_did = ? AND da.api_client_id = ? ORDER BY ad.created_at DESC", + backend, + ); + let rows: Vec<(String, String, String)> = sqlx::query_as(&sql) + .bind(user_did) + .bind(api_client_id) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list delegated accounts: {e}")))?; + + Ok(rows + .into_iter() + .map(|(did, role, created_at)| DelegatedAccountView { + did, + role, + created_at, + }) + .collect()) +} + +pub async fn get_account_for_user( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, + user_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT da.linked_by, ad.role, ad.created_at FROM delegated_accounts da JOIN account_delegates ad ON da.account_did = ad.account_did WHERE da.account_did = ? AND ad.user_did = ?", + backend, + ); + let row: Option<(String, String, String)> = sqlx::query_as(&sql) + .bind(account_did) + .bind(user_did) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to get delegated account: {e}")))?; + Ok(row) +} + +pub async fn list_delegates( + pool: &AnyPool, + backend: crate::db::DatabaseBackend, + account_did: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT user_did, role, granted_by, created_at FROM account_delegates WHERE account_did = ? ORDER BY created_at ASC", + backend, + ); + let rows: Vec<(String, String, String, String)> = sqlx::query_as(&sql) + .bind(account_did) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(format!("failed to list delegates: {e}")))?; + + Ok(rows + .into_iter() + .map(|(user_did, role, granted_by, created_at)| DelegateView { + user_did, + role, + granted_by, + created_at, + }) + .collect()) +} diff --git a/src/delegation/get_account.rs b/src/delegation/get_account.rs new file mode 100644 index 0000000..bc93b87 --- /dev/null +++ b/src/delegation/get_account.rs @@ -0,0 +1,46 @@ +use axum::Json; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; + +use super::db; + +#[derive(Deserialize)] +pub struct GetAccountParams { + pub did: String, +} + +pub async fn get_account( + State(state): State, + xrpc_claims: XrpcClaims, + Query(params): Query, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("getAccount requires authentication".into()))?; + + super::verify_client_scope(&state, &claims, ¶ms.did).await?; + + let is_linked = db::is_account_linked(&state.db, state.db_backend, ¶ms.did).await?; + if !is_linked { + return Err(AppError::NotFound("delegated account not found".into())); + } + + let (linked_by, role, created_at) = + db::get_account_for_user(&state.db, state.db_backend, ¶ms.did, claims.did()) + .await? + .ok_or_else(|| AppError::NotFound("you are not a delegate of this account".into()))?; + + Ok(Json(json!({ + "did": params.did, + "role": role, + "linkedBy": linked_by, + "createdAt": created_at, + })) + .into_response()) +} diff --git a/src/delegation/link_account.rs b/src/delegation/link_account.rs new file mode 100644 index 0000000..0e80f44 --- /dev/null +++ b/src/delegation/link_account.rs @@ -0,0 +1,98 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::DelegateRole; +use super::db; + +#[derive(Deserialize)] +pub struct LinkAccountInput { + pub did: String, +} + +pub async fn link_account( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("linkAccount requires authentication".into()))?; + + let caller_did = claims.did().to_string(); + let account_did = &input.did; + + if caller_did == *account_did { + return Err(AppError::BadRequest( + "cannot link your own account as a delegate".into(), + )); + } + + if db::is_account_linked(&state.db, state.db_backend, account_did).await? { + return Err(AppError::Conflict("account is already linked".into())); + } + + // Verify a DPoP session exists for the target DID + let client_key = claims + .client_key() + .ok_or_else(|| AppError::Auth("linkAccount requires DPoP authentication".into()))?; + let api_client_id = crate::repo::get_dpop_client_id(&state, client_key).await?; + + let session_check_sql = crate::db::adapt_sql( + "SELECT id FROM dpop_sessions WHERE api_client_id = ? AND user_did = ?", + state.db_backend, + ); + let session_exists: Option<(String,)> = sqlx::query_as(&session_check_sql) + .bind(&api_client_id) + .bind(account_did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to check session: {e}")))?; + + if session_exists.is_none() { + return Err(AppError::BadRequest( + "no DPoP session found for the target account — complete OAuth first".into(), + )); + } + + db::create_delegated_account( + &state.db, + state.db_backend, + account_did, + &caller_did, + &api_client_id, + ) + .await?; + db::add_delegate( + &state.db, + state.db_backend, + account_did, + &caller_did, + DelegateRole::Owner, + &caller_did, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "delegation.account_linked".to_string(), + severity: Severity::Info, + actor_did: Some(caller_did), + subject: Some(account_did.clone()), + detail: json!({}), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::CREATED, Json(json!({ "did": account_did }))).into_response()) +} diff --git a/src/delegation/list_accounts.rs b/src/delegation/list_accounts.rs new file mode 100644 index 0000000..96d2fb0 --- /dev/null +++ b/src/delegation/list_accounts.rs @@ -0,0 +1,27 @@ +use axum::Json; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; + +use super::db; + +pub async fn list_accounts( + State(state): State, + xrpc_claims: XrpcClaims, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("listAccounts requires authentication".into()))?; + + let caller_client_id = super::resolve_caller_client_id(&state, &claims).await?; + + let accounts = + db::list_accounts_for_user(&state.db, state.db_backend, claims.did(), &caller_client_id) + .await?; + + Ok(Json(json!({ "accounts": accounts })).into_response()) +} diff --git a/src/delegation/list_delegates.rs b/src/delegation/list_delegates.rs new file mode 100644 index 0000000..5a0299f --- /dev/null +++ b/src/delegation/list_delegates.rs @@ -0,0 +1,48 @@ +use axum::Json; +use axum::extract::{Query, State}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; + +use super::db; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListDelegatesParams { + pub account_did: String, +} + +pub async fn list_delegates( + State(state): State, + xrpc_claims: XrpcClaims, + Query(params): Query, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("listDelegates requires authentication".into()))?; + + super::verify_client_scope(&state, &claims, ¶ms.account_did).await?; + + let caller_role = db::get_delegate_role( + &state.db, + state.db_backend, + ¶ms.account_did, + claims.did(), + ) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !caller_role.can_manage_members() { + return Err(AppError::Forbidden( + "only owners and admins can list delegates".into(), + )); + } + + let delegates = db::list_delegates(&state.db, state.db_backend, ¶ms.account_did).await?; + + Ok(Json(json!({ "delegates": delegates })).into_response()) +} diff --git a/src/delegation/mod.rs b/src/delegation/mod.rs new file mode 100644 index 0000000..f366ff3 --- /dev/null +++ b/src/delegation/mod.rs @@ -0,0 +1,91 @@ +pub mod add_delegate; +pub mod db; +pub mod get_account; +pub mod link_account; +pub mod list_accounts; +pub mod list_delegates; +pub mod remove_delegate; +pub mod unlink_account; + +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegatedAccountView { + pub did: String, + pub role: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DelegateView { + pub user_did: String, + pub role: String, + pub granted_by: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DelegateRole { + Owner, + Admin, + Member, +} + +impl DelegateRole { + pub fn as_str(&self) -> &'static str { + match self { + DelegateRole::Owner => "owner", + DelegateRole::Admin => "admin", + DelegateRole::Member => "member", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "owner" => Some(DelegateRole::Owner), + "admin" => Some(DelegateRole::Admin), + "member" => Some(DelegateRole::Member), + _ => None, + } + } + + pub fn can_write(&self) -> bool { + matches!(self, DelegateRole::Owner | DelegateRole::Admin) + } + + pub fn can_manage_members(&self) -> bool { + matches!(self, DelegateRole::Owner | DelegateRole::Admin) + } +} + +pub(crate) async fn resolve_caller_client_id( + state: &crate::AppState, + claims: &crate::auth::Claims, +) -> Result { + let client_key = claims.client_key().ok_or_else(|| { + crate::error::AppError::Auth("delegation requires DPoP authentication".into()) + })?; + crate::repo::get_dpop_client_id(state, client_key).await +} + +pub(crate) async fn verify_client_scope( + state: &crate::AppState, + claims: &crate::auth::Claims, + account_did: &str, +) -> Result<(), crate::error::AppError> { + let caller_client_id = resolve_caller_client_id(state, claims).await?; + + let stored_client_id = db::get_api_client_id(&state.db, state.db_backend, account_did) + .await? + .ok_or_else(|| crate::error::AppError::NotFound("delegated account not found".into()))?; + + if caller_client_id != stored_client_id { + return Err(crate::error::AppError::Forbidden( + "delegation is scoped to a different application".into(), + )); + } + + Ok(()) +} diff --git a/src/delegation/remove_delegate.rs b/src/delegation/remove_delegate.rs new file mode 100644 index 0000000..456dd7f --- /dev/null +++ b/src/delegation/remove_delegate.rs @@ -0,0 +1,92 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::DelegateRole; +use super::db; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveDelegateInput { + pub account_did: String, + pub user_did: String, +} + +pub async fn remove_delegate( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("removeDelegate requires authentication".into()))?; + + let caller_did = claims.did().to_string(); + + super::verify_client_scope(&state, &claims, &input.account_did).await?; + + let caller_role = + db::get_delegate_role(&state.db, state.db_backend, &input.account_did, &caller_did) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !caller_role.can_manage_members() { + return Err(AppError::Forbidden( + "only owners and admins can remove delegates".into(), + )); + } + + let target_role = db::get_delegate_role( + &state.db, + state.db_backend, + &input.account_did, + &input.user_did, + ) + .await? + .ok_or_else(|| AppError::NotFound("user is not a delegate of this account".into()))?; + + if target_role == DelegateRole::Owner { + return Err(AppError::Forbidden( + "cannot remove the owner — use unlinkAccount instead".into(), + )); + } + + if caller_role == DelegateRole::Admin && target_role == DelegateRole::Admin { + return Err(AppError::Forbidden( + "admins cannot remove other admins — only the owner can".into(), + )); + } + + db::remove_delegate( + &state.db, + state.db_backend, + &input.account_did, + &input.user_did, + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "delegation.delegate_removed".to_string(), + severity: Severity::Info, + actor_did: Some(caller_did), + subject: Some(input.user_did.clone()), + detail: json!({ + "account_did": input.account_did, + }), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::OK, Json(json!({}))).into_response()) +} diff --git a/src/delegation/unlink_account.rs b/src/delegation/unlink_account.rs new file mode 100644 index 0000000..2d19477 --- /dev/null +++ b/src/delegation/unlink_account.rs @@ -0,0 +1,85 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; +use serde_json::json; + +use crate::AppState; +use crate::auth::XrpcClaims; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; + +use super::DelegateRole; +use super::db; + +#[derive(Deserialize)] +pub struct UnlinkAccountInput { + pub did: String, +} + +pub async fn unlink_account( + State(state): State, + xrpc_claims: XrpcClaims, + Json(input): Json, +) -> Result { + let claims = xrpc_claims + .0 + .ok_or_else(|| AppError::Auth("unlinkAccount requires authentication".into()))?; + + let caller_did = claims.did().to_string(); + let account_did = &input.did; + + super::verify_client_scope(&state, &claims, account_did).await?; + + let is_linked = db::is_account_linked(&state.db, state.db_backend, account_did).await?; + if !is_linked { + return Err(AppError::NotFound("delegated account not found".into())); + } + + let role = db::get_delegate_role(&state.db, state.db_backend, account_did, &caller_did) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if role != DelegateRole::Owner { + return Err(AppError::Forbidden( + "only the owner can unlink an account".into(), + )); + } + + // Look up the stored api_client_id before deleting the account + let stored_api_client_id = + db::get_api_client_id(&state.db, state.db_backend, account_did).await?; + + // Delete delegated account (CASCADE deletes all delegates) + db::delete_delegated_account(&state.db, state.db_backend, account_did).await?; + + // Delete the DPoP session for the target account using the stored api_client_id + if let Some(api_client_id) = stored_api_client_id { + if let Err(e) = crate::oauth::sessions::delete_dpop_session( + &state.db, + state.db_backend, + &api_client_id, + account_did, + ) + .await + { + tracing::warn!(account_did, %e, "failed to clean up DPoP session on unlink"); + } + } + + log_event( + &state.db, + EventLog { + event_type: "delegation.account_unlinked".to_string(), + severity: Severity::Info, + actor_did: Some(caller_did), + subject: Some(account_did.clone()), + detail: json!({}), + }, + state.db_backend, + ) + .await; + + Ok((StatusCode::OK, Json(json!({}))).into_response()) +} diff --git a/src/lib.rs b/src/lib.rs index ef26bcd..daf60e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod auth; pub mod config; pub mod db; +pub mod delegation; pub mod dev_happyview; pub mod dns; pub mod domain; diff --git a/src/lua/context.rs b/src/lua/context.rs index f63e0bc..b62a882 100644 --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -40,6 +40,7 @@ pub fn set_procedure_context( caller_did: &str, collection: &str, space: Option<&SpaceContext>, + delegate_did: Option<&str>, ) -> LuaResult<()> { let globals = lua.globals(); globals.set("method", method.to_string())?; @@ -47,6 +48,10 @@ pub fn set_procedure_context( globals.set("params", lua.to_value(params)?)?; globals.set("caller_did", caller_did.to_string())?; globals.set("collection", collection.to_string())?; + match delegate_did { + Some(did) => globals.set("delegate_did", did.to_string())?, + None => globals.set("delegate_did", mlua::Value::Nil)?, + } set_space_context(lua, space)?; Ok(()) } @@ -151,6 +156,7 @@ mod tests { "did:plc:test", "com.example.thing", None, + None, ) .unwrap(); @@ -164,6 +170,7 @@ mod tests { globals.get::("collection").unwrap(), "com.example.thing" ); + assert!(globals.get::("delegate_did").unwrap().is_nil()); let input_table: mlua::Table = globals.get("input").unwrap(); assert_eq!(input_table.get::("key").unwrap(), "val"); @@ -203,6 +210,34 @@ mod tests { assert_eq!(params_table.get::("cursor").unwrap(), "abc"); } + #[test] + fn procedure_context_with_delegate_did() { + let lua = create_sandbox().unwrap(); + let input = json!({"key": "val"}); + let params = HashMap::new(); + set_procedure_context( + &lua, + "com.example.doThing", + &input, + ¶ms, + "did:plc:caller", + "com.example.thing", + None, + Some("did:plc:delegate"), + ) + .unwrap(); + + let globals = lua.globals(); + assert_eq!( + globals.get::("delegate_did").unwrap(), + "did:plc:delegate" + ); + assert_eq!( + globals.get::("caller_did").unwrap(), + "did:plc:caller" + ); + } + #[test] fn env_context_sets_table() { let lua = create_sandbox().unwrap(); diff --git a/src/lua/execute.rs b/src/lua/execute.rs index 23199ee..51bdbb2 100644 --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -43,6 +43,7 @@ pub async fn execute_procedure_script( lexicon: &ParsedLexicon, script: &str, space_ctx: Option<&context::SpaceContext>, + delegate_did: Option<&str>, ) -> Result { let start = Instant::now(); let backend = state.db_backend; @@ -253,7 +254,13 @@ pub async fn execute_procedure_script( return Err(AppError::Internal(error_message)); } - if let Err(e) = record::register_record_api(&lua, state_arc, claims_arc, pds_auth_arc) { + if let Err(e) = record::register_record_api( + &lua, + state_arc, + claims_arc, + pds_auth_arc, + delegate_did.map(|s| s.to_string()), + ) { let error_message = format!("failed to register Record API: {e}"); log_event( &state.db, @@ -285,6 +292,7 @@ pub async fn execute_procedure_script( claims.did(), collection, space_ctx, + delegate_did, ) { let error_message = format!("failed to set context: {e}"); log_event( diff --git a/src/lua/record.rs b/src/lua/record.rs index 601be7b..654c68a 100644 --- a/src/lua/record.rs +++ b/src/lua/record.rs @@ -23,11 +23,15 @@ const INTERNAL_FIELDS: &[&str] = &[ /// Register the `Record` global constructor and static methods. /// Only registered for procedure scripts (not queries). +/// +/// When `delegate_did` is `Some`, record writes default to the delegate's repo +/// instead of the caller's DID. Scripts can still override via `record:set_repo()`. pub fn register_record_api( lua: &Lua, state: Arc, claims: Arc, pds_auth: Arc, + delegate_did: Option, ) -> LuaResult<()> { // -- methods table (shared by all Record instances) -- let methods = lua.create_table()?; @@ -37,16 +41,21 @@ pub fn register_record_api( let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); let save_fn = lua.create_async_function(move |lua, this: mlua::Table| { let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); async move { let backend = state.db_backend; let collection: String = this.raw_get("_collection")?; let schema: mlua::Value = this.raw_get("_schema")?; let repo_override: Option = this.raw_get("_repo_override")?; - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); + let repo = repo_override + .as_deref() + .or(delegate_did.as_deref()) + .unwrap_or_else(|| claims.did()); // Validate required fields against schema if let mlua::Value::Table(ref schema_table) = schema { @@ -207,10 +216,12 @@ pub fn register_record_api( let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); let delete_fn = lua.create_async_function(move |_lua, this: mlua::Table| { let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); async move { let backend = state.db_backend; let uri: String = this.raw_get::>("_uri")?.ok_or_else(|| { @@ -218,7 +229,10 @@ pub fn register_record_api( })?; let collection: String = this.raw_get("_collection")?; let repo_override: Option = this.raw_get("_repo_override")?; - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); + let repo = repo_override + .as_deref() + .or(delegate_did.as_deref()) + .unwrap_or_else(|| claims.did()); let rkey = uri .split('/') @@ -439,11 +453,13 @@ pub fn register_record_api( let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); let save_all_fn = lua.create_async_function(move |lua, records_table: mlua::Table| { let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); async move { let backend = state.db_backend; // Extract save data from each record (sync) @@ -472,13 +488,17 @@ pub fn register_record_api( let state = state.clone(); let claims = claims.clone(); let pds_auth = pds_auth.clone(); + let delegate_did = delegate_did.clone(); let collection = collection.clone(); let existing_uri = existing_uri.clone(); let rkey = rkey.clone(); let repo_override = repo_override.clone(); let data = data.clone(); async move { - let repo = repo_override.as_deref().unwrap_or_else(|| claims.did()); + let repo = repo_override + .as_deref() + .or(delegate_did.as_deref()) + .unwrap_or_else(|| claims.did()); if let Some(ref uri) = existing_uri { let rkey = uri .split('/') diff --git a/src/server.rs b/src/server.rs index 7c0700d..72cae9d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -89,6 +89,35 @@ pub fn router(state: AppState) -> Router { "/xrpc/dev.happyview.deleteApiClient", post(crate::dev_happyview::delete_api_client), ) + // Delegation + .route( + "/xrpc/dev.happyview.delegation.linkAccount", + post(crate::delegation::link_account::link_account), + ) + .route( + "/xrpc/dev.happyview.delegation.unlinkAccount", + post(crate::delegation::unlink_account::unlink_account), + ) + .route( + "/xrpc/dev.happyview.delegation.addDelegate", + post(crate::delegation::add_delegate::add_delegate), + ) + .route( + "/xrpc/dev.happyview.delegation.removeDelegate", + post(crate::delegation::remove_delegate::remove_delegate), + ) + .route( + "/xrpc/dev.happyview.delegation.listAccounts", + get(crate::delegation::list_accounts::list_accounts), + ) + .route( + "/xrpc/dev.happyview.delegation.getAccount", + get(crate::delegation::get_account::get_account), + ) + .route( + "/xrpc/dev.happyview.delegation.listDelegates", + get(crate::delegation::list_delegates::list_delegates), + ) // Catch-all for dynamically registered lexicons .route("/xrpc/{method}", get(xrpc::xrpc_get).post(xrpc::xrpc_post)) .route("/config", get(config_endpoint)) diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index 1bf9879..13850d0 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -19,8 +19,61 @@ pub(crate) async fn handle_procedure( lexicon: &crate::lexicon::ParsedLexicon, ) -> Result { if let Some(ref script) = lexicon.script { + let delegate_did = input + .get("delegateDid") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if let Some(ref did) = delegate_did { + let client_key = claims + .client_key() + .ok_or_else(|| AppError::Auth("delegation requires DPoP authentication".into()))?; + let api_client_id = repo::get_dpop_client_id(state, client_key).await?; + + let role = crate::delegation::db::get_delegate_role( + &state.db, + state.db_backend, + did, + claims.did(), + ) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !role.can_write() { + return Err(AppError::Forbidden( + "your role does not have write access to this account".into(), + )); + } + + let stored_client_id = + crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) + .await? + .ok_or_else(|| { + AppError::Internal("delegated account missing api_client_id".into()) + })?; + + if api_client_id != stored_client_id { + return Err(AppError::Forbidden( + "delegation is scoped to a different application".into(), + )); + } + } + + let mut script_input = input.clone(); + if let Some(obj) = script_input.as_object_mut() { + obj.remove("delegateDid"); + } + return crate::lua::execute_procedure_script( - state, method, claims, input, params, lexicon, script, None, + state, + method, + claims, + &script_input, + params, + lexicon, + script, + None, + delegate_did.as_deref(), ) .await; } @@ -40,6 +93,11 @@ pub(crate) async fn handle_procedure( let api_client_id = repo::get_dpop_client_id(state, client_key).await?; + let delegate_did = input + .get("delegateDid") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + return handle_dpop_procedure( state, claims, @@ -48,6 +106,7 @@ pub(crate) async fn handle_procedure( &lexicon.action, &api_client_id, encryption_key, + delegate_did.as_deref(), ) .await; } @@ -295,18 +354,62 @@ async fn handle_dpop_procedure( action: &ProcedureAction, api_client_id: &str, encryption_key: &[u8; 32], + delegate_did: Option<&str>, ) -> Result { + // If delegating, verify the caller has write access and resolve the + // api_client_id that owns the delegated session. + let (target_did, effective_api_client_id) = if let Some(did) = delegate_did { + let role = crate::delegation::db::get_delegate_role( + &state.db, + state.db_backend, + did, + claims.did(), + ) + .await? + .ok_or_else(|| AppError::Forbidden("you are not a delegate of this account".into()))?; + + if !role.can_write() { + return Err(AppError::Forbidden( + "your role does not have write access to this account".into(), + )); + } + + let stored_client_id = + crate::delegation::db::get_api_client_id(&state.db, state.db_backend, did) + .await? + .ok_or_else(|| { + AppError::Internal("delegated account missing api_client_id".into()) + })?; + + if api_client_id != stored_client_id { + return Err(AppError::Forbidden( + "delegation is scoped to a different application".into(), + )); + } + + (did, stored_client_id) + } else { + (claims.did(), api_client_id.to_string()) + }; + + // Strip delegateDid from input — it's a control field, not record data + let mut input = input.clone(); + if let Some(obj) = input.as_object_mut() { + obj.remove("delegateDid"); + } + let (xrpc_method, pds_body) = match action { ProcedureAction::Create => { let mut record = input.clone(); if let Some(obj) = record.as_object_mut() { obj.insert("$type".to_string(), json!(collection)); obj.remove("shouldPublish"); + obj.remove("delegateDid"); } ( "com.atproto.repo.createRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "record": record, }), @@ -326,11 +429,12 @@ async fn handle_dpop_procedure( obj.insert("$type".to_string(), json!(collection)); obj.remove("uri"); obj.remove("shouldPublish"); + obj.remove("delegateDid"); } ( "com.atproto.repo.putRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "rkey": rkey, "record": record, @@ -349,7 +453,7 @@ async fn handle_dpop_procedure( ( "com.atproto.repo.deleteRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "rkey": rkey, }), @@ -368,11 +472,12 @@ async fn handle_dpop_procedure( obj.insert("$type".to_string(), json!(collection)); obj.remove("uri"); obj.remove("shouldPublish"); + obj.remove("delegateDid"); } ( "com.atproto.repo.putRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "rkey": rkey, "record": record, @@ -383,11 +488,12 @@ async fn handle_dpop_procedure( if let Some(obj) = record.as_object_mut() { obj.insert("$type".to_string(), json!(collection)); obj.remove("shouldPublish"); + obj.remove("delegateDid"); } ( "com.atproto.repo.createRecord", json!({ - "repo": claims.did(), + "repo": target_did, "collection": collection, "record": record, }), @@ -403,8 +509,8 @@ async fn handle_dpop_procedure( encryption_key, &state.oauth, &state.config.plc_url, - api_client_id, - claims.did(), + &effective_api_client_id, + target_did, xrpc_method, &pds_body, ) diff --git a/tests/common/db.rs b/tests/common/db.rs index 5b2d63b..d4fc0df 100644 --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -20,7 +20,7 @@ pub async fn truncate_all(pool: &AnyPool) { match backend { DatabaseBackend::Postgres => { sqlx::query( - "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients RESTART IDENTITY CASCADE", + "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients, delegated_accounts, account_delegates RESTART IDENTITY CASCADE", ) .execute(pool) .await @@ -28,6 +28,8 @@ pub async fn truncate_all(pool: &AnyPool) { } DatabaseBackend::Sqlite => { let tables = [ + "account_delegates", + "delegated_accounts", "dpop_sessions", "dpop_keys", "api_clients", diff --git a/tests/e2e_delegation.rs b/tests/e2e_delegation.rs new file mode 100644 index 0000000..f1316eb --- /dev/null +++ b/tests/e2e_delegation.rs @@ -0,0 +1,1534 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use happyview::db::adapt_sql; +use happyview::oauth::pds_write::generate_dpop_proof; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, ResponseTemplate}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn response_json(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap_or(json!(null)) +} + +fn post_json_with_headers(uri: &str, body: &Value, headers: Vec<(&str, &str)>) -> Request { + let mut builder = Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .header("host", "127.0.0.1:0"); + for (name, value) in headers { + builder = builder.header(name, value); + } + builder + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +fn get_with_headers(uri: &str, headers: Vec<(&str, &str)>) -> Request { + let mut builder = Request::builder() + .method("GET") + .uri(uri) + .header("host", "127.0.0.1:0"); + for (name, value) in headers { + builder = builder.header(name, value); + } + builder.body(Body::empty()).unwrap() +} + +/// Set up a full DPoP session and return `(client_key, dpop_key, access_token)`. +async fn setup_dpop_session(app: &common::app::TestApp, user_did: &str) -> (String, Value, String) { + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + let dpop_key = key_body["dpop_key"].clone(); + + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": user_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + (client_key, dpop_key, access_token) +} + +/// Build DPoP auth headers for a request. +fn dpop_auth_headers<'a>( + client_key: &'a str, + dpop_key: &Value, + access_token: &'a str, + method: &str, + url: &str, +) -> Vec<(&'static str, String)> { + // DPoP htu must not include query/fragment + let htu = url.split('?').next().unwrap_or(url); + let proof = generate_dpop_proof(dpop_key, method, htu, access_token, None) + .expect("failed to generate DPoP proof"); + vec![ + ("x-client-key", client_key.to_string()), + ("authorization", format!("DPoP {}", access_token)), + ("dpop", proof), + ] +} + +/// Make an authenticated POST request. +async fn dpop_post( + app: &common::app::TestApp, + path: &str, + body: &Value, + client_key: &str, + dpop_key: &Value, + access_token: &str, +) -> axum::response::Response { + let url = format!("http://127.0.0.1:0{}", path); + let headers = dpop_auth_headers(client_key, dpop_key, access_token, "POST", &url); + let str_headers: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let req = post_json_with_headers(path, body, str_headers); + app.router.clone().oneshot(req).await.unwrap() +} + +/// Make an authenticated GET request. +async fn dpop_get( + app: &common::app::TestApp, + path: &str, + client_key: &str, + dpop_key: &Value, + access_token: &str, +) -> axum::response::Response { + let url = format!("http://127.0.0.1:0{}", path); + let headers = dpop_auth_headers(client_key, dpop_key, access_token, "GET", &url); + let str_headers: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let req = get_with_headers(path, str_headers); + app.router.clone().oneshot(req).await.unwrap() +} + +/// Register a DPoP session for a target DID using the same API client. +/// This simulates the client completing OAuth for the target account. +async fn register_target_session( + app: &common::app::TestApp, + client_key: &str, + client_secret: &str, + target_did: &str, +) { + // Look up the client secret from the DB — we need it for the dpop-keys endpoint. + // Actually, setup_dpop_session already provisions a key, but we need a separate session + // for the target DID under the same api_client. + // We can reuse the same provision_id (DPoP key) — register another session with a + // different DID. + + // Provision a new DPoP key for this target session + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + + let access_token = format!("test-target-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": target_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!( + session_resp.status(), + StatusCode::CREATED, + "failed to register target session" + ); +} + +/// Full setup: create an API client, register DPoP sessions for both the owner +/// and the target account, then call linkAccount. +/// Returns `(client_key, client_secret, dpop_key, access_token)`. +async fn setup_linked_account( + app: &common::app::TestApp, + owner_did: &str, + target_did: &str, +) -> (String, String, Value, String) { + let (client_key, client_secret, _id) = app.create_api_client("confidential", None).await; + + // Provision DPoP key + session for the owner + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + let dpop_key = key_body["dpop_key"].clone(); + + let access_token = format!("test-owner-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": owner_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", &client_key), + ("x-client-secret", &client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + // Register a session for the target account under the same API client + register_target_session(app, &client_key, &client_secret, target_did).await; + + // Link the account + let resp = dpop_post( + app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": target_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED, "linkAccount failed"); + + (client_key, client_secret, dpop_key, access_token) +} + +/// Provision a DPoP session for a user under an existing API client. +/// Returns `(dpop_key, access_token)` — use the shared `client_key` for requests. +async fn setup_session_for_client( + app: &common::app::TestApp, + client_key: &str, + client_secret: &str, + user_did: &str, +) -> (Value, String) { + let key_req = post_json_with_headers( + "/oauth/dpop-keys", + &json!({}), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let key_resp = app.router.clone().oneshot(key_req).await.unwrap(); + assert_eq!(key_resp.status(), StatusCode::CREATED); + let key_body = response_json(key_resp).await; + let provision_id = key_body["provision_id"].as_str().unwrap().to_string(); + let dpop_key = key_body["dpop_key"].clone(); + + let access_token = format!("test-access-{}", uuid::Uuid::new_v4()); + let session_req = post_json_with_headers( + "/oauth/sessions", + &json!({ + "provision_id": provision_id, + "did": user_did, + "access_token": &access_token, + "scopes": "atproto", + "pds_url": "https://pds.example.com", + }), + vec![ + ("x-client-key", client_key), + ("x-client-secret", client_secret), + ], + ); + let session_resp = app.router.clone().oneshot(session_req).await.unwrap(); + assert_eq!(session_resp.status(), StatusCode::CREATED); + + (dpop_key, access_token) +} + +// --------------------------------------------------------------------------- +// linkAccount +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn link_account_success() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:owner1"; + let target_did = "did:plc:studio1"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Verify via getAccount + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + assert_eq!(body["did"], target_did); + assert_eq!(body["role"], "owner"); + assert_eq!(body["linkedBy"], owner_did); +} + +#[tokio::test] +#[serial] +async fn link_account_already_linked() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:owner2"; + let target_did = "did:plc:studio2"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Try to link again + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": target_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +#[serial] +async fn link_account_self_link_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:selflinker"; + + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": user_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn link_account_no_session_for_target() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:owner3"; + let target_did = "did:plc:nosession"; + + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, owner_did).await; + + // Don't register a session for target_did — should fail + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": target_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// --------------------------------------------------------------------------- +// unlinkAccount +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unlink_account_success() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:unlink_owner"; + let target_did = "did:plc:unlink_studio"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.unlinkAccount", + &json!({ "did": target_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify account is gone + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn unlink_account_non_owner_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:unlink_owner2"; + let admin_did = "did:plc:unlink_admin2"; + let target_did = "did:plc:unlink_studio2"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add an admin + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Admin tries to unlink — should be rejected (owner-only) + let (admin_dpop, admin_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.unlinkAccount", + &json!({ "did": target_did }), + &owner_key, + &admin_dpop, + &admin_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// addDelegate +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn add_delegate_success() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:add_owner"; + let member_did = "did:plc:add_member"; + let target_did = "did:plc:add_studio"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Verify via listDelegates + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 2); // owner + member + let member = delegates + .iter() + .find(|d| d["userDid"] == member_did) + .unwrap(); + assert_eq!(member["role"], "member"); + assert_eq!(member["grantedBy"], owner_did); +} + +#[tokio::test] +#[serial] +async fn add_delegate_owner_role_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:add_owner2"; + let target_did = "did:plc:add_studio2"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": "did:plc:someone", "role": "owner" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn add_delegate_already_exists() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:add_owner3"; + let member_did = "did:plc:add_member3"; + let target_did = "did:plc:add_studio3"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add member + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Try to add again + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "admin" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CONFLICT); +} + +#[tokio::test] +#[serial] +async fn add_delegate_member_cannot_add() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:add_owner4"; + let member_did = "did:plc:add_member4"; + let target_did = "did:plc:add_studio4"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add a member + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Member tries to add another delegate — should fail (members can't manage) + let (member_dpop, member_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": "did:plc:someone", "role": "member" }), + &owner_key, + &member_dpop, + &member_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// removeDelegate +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn remove_delegate_success() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:rm_owner"; + let member_did = "did:plc:rm_member"; + let target_did = "did:plc:rm_studio"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add then remove a member + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": member_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify only owner remains + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &client_key, + &dpop_key, + &access_token, + ) + .await; + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 1); + assert_eq!(delegates[0]["role"], "owner"); +} + +#[tokio::test] +#[serial] +async fn remove_delegate_cannot_remove_owner() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:rm_owner2"; + let target_did = "did:plc:rm_studio2"; + + let (client_key, _client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, target_did).await; + + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": owner_did }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn remove_delegate_admin_cannot_remove_admin() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:rm_owner3"; + let admin1_did = "did:plc:rm_admin3a"; + let admin2_did = "did:plc:rm_admin3b"; + let target_did = "did:plc:rm_studio3"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add two admins + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin1_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin2_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Admin1 tries to remove admin2 + let (a1_dpop, a1_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin1_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": admin2_did }), + &owner_key, + &a1_dpop, + &a1_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// listAccounts +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn list_accounts_returns_linked_accounts() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:list_owner"; + let studio1 = "did:plc:list_studio1"; + let studio2 = "did:plc:list_studio2"; + + let (client_key, client_secret, dpop_key, access_token) = + setup_linked_account(&app, owner_did, studio1).await; + + // Link a second account under the same API client + register_target_session(&app, &client_key, &client_secret, studio2).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.linkAccount", + &json!({ "did": studio2 }), + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // List accounts — should include both + let resp = dpop_get( + &app, + "/xrpc/dev.happyview.delegation.listAccounts", + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let accounts = body["accounts"].as_array().unwrap(); + assert_eq!(accounts.len(), 2); +} + +#[tokio::test] +#[serial] +async fn list_accounts_empty() { + let app = common::app::TestApp::new_with_encryption().await; + let user_did = "did:plc:no_accounts"; + + let (client_key, dpop_key, access_token) = setup_dpop_session(&app, user_did).await; + + let resp = dpop_get( + &app, + "/xrpc/dev.happyview.delegation.listAccounts", + &client_key, + &dpop_key, + &access_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let accounts = body["accounts"].as_array().unwrap(); + assert!(accounts.is_empty()); +} + +// --------------------------------------------------------------------------- +// getAccount +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_account_not_a_delegate() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:ga_owner"; + let target_did = "did:plc:ga_studio"; + + let (owner_key, owner_secret, _owner_dpop, _owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Different user (same app, but not a delegate) tries to get account details + let outsider_did = "did:plc:ga_outsider"; + let (out_dpop, out_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, outsider_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &owner_key, + &out_dpop, + &out_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +// --------------------------------------------------------------------------- +// listDelegates +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn list_delegates_member_cannot_list() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:ld_owner"; + let member_did = "did:plc:ld_member"; + let target_did = "did:plc:ld_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add a member + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Member tries to list delegates (same client, but member role can't list) + let (member_dpop, member_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &member_dpop, + &member_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// DelegateRole unit tests (no TestApp needed) +// --------------------------------------------------------------------------- + +#[test] +fn delegate_role_from_str_roundtrip() { + use happyview::delegation::DelegateRole; + for role in &[ + DelegateRole::Owner, + DelegateRole::Admin, + DelegateRole::Member, + ] { + let s = role.as_str(); + assert_eq!(DelegateRole::from_str(s), Some(*role)); + } + assert_eq!(DelegateRole::from_str("invalid"), None); +} + +#[test] +fn delegate_role_can_write() { + use happyview::delegation::DelegateRole; + assert!(DelegateRole::Owner.can_write()); + assert!(DelegateRole::Admin.can_write()); + assert!(!DelegateRole::Member.can_write()); +} + +#[test] +fn delegate_role_can_manage_members() { + use happyview::delegation::DelegateRole; + assert!(DelegateRole::Owner.can_manage_members()); + assert!(DelegateRole::Admin.can_manage_members()); + assert!(!DelegateRole::Member.can_manage_members()); +} + +// --------------------------------------------------------------------------- +// Helpers for delegated write tests +// --------------------------------------------------------------------------- + +fn admin_post_request( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .header("host", "127.0.0.1:0") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +async fn seed_procedure_lexicon(app: &common::app::TestApp) { + let resp = app + .router + .clone() + .oneshot(admin_post_request( + "/admin/lexicons", + app.admin_cookie(), + &json!({ + "lexicon_json": common::fixtures::create_game_procedure_lexicon(), + "target_collection": "games.gamesgamesgamesgames.game" + }), + )) + .await + .unwrap(); + assert!( + resp.status().is_success(), + "failed to seed procedure lexicon: {}", + resp.status() + ); +} + +async fn update_session_pds_url(app: &common::app::TestApp, user_did: &str, pds_url: &str) { + let sql = adapt_sql( + "UPDATE dpop_sessions SET pds_url = ? WHERE user_did = ?", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(pds_url) + .bind(user_did) + .execute(&app.state.db) + .await + .expect("failed to update session pds_url"); +} + +// --------------------------------------------------------------------------- +// Delegated writes — auth gates +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn delegated_write_non_delegate_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:dw_owner1"; + let target_did = "did:plc:dw_studio1"; + setup_linked_account(&app, owner_did, target_did).await; + + // Outsider (not a delegate) tries a delegated write + let outsider_did = "did:plc:dw_outsider1"; + let (out_key, out_dpop, out_token) = setup_dpop_session(&app, outsider_did).await; + + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Hacked Game", "delegateDid": target_did }), + &out_key, + &out_dpop, + &out_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn delegated_write_member_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:dw_owner2"; + let member_did = "did:plc:dw_member2"; + let target_did = "did:plc:dw_studio2"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add a member (cannot write) + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Member tries a delegated write (same client, but member role can't write) + let (mem_dpop, mem_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Member Game", "delegateDid": target_did }), + &owner_key, + &mem_dpop, + &mem_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +// --------------------------------------------------------------------------- +// Delegated writes — happy path +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn delegated_write_owner_success() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:dw_owner3"; + let target_did = "did:plc:dw_studio3"; + + let (owner_key, _owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Point the target's DPoP session at the mock server so dpop_pds_post + // reaches wiremock instead of a real PDS. + let mock_url = app.mock_server.uri(); + update_session_pds_url(&app, target_did, &mock_url).await; + + // Mock PDS createRecord + Mock::given(method("POST")) + .and(path("/xrpc/com.atproto.repo.createRecord")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": format!("at://{target_did}/games.gamesgamesgamesgames.game/abc123"), + "cid": "bafytest123" + }))) + .expect(1) + .mount(&app.mock_server) + .await; + + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Studio Game", "delegateDid": target_did }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let body = response_json(resp).await; + assert_eq!( + body["uri"], + format!("at://{target_did}/games.gamesgamesgamesgames.game/abc123") + ); + assert_eq!(body["cid"], "bafytest123"); +} + +#[tokio::test] +#[serial] +async fn delegated_write_admin_success() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:dw_owner4"; + let admin_did = "did:plc:dw_admin4"; + let target_did = "did:plc:dw_studio4"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add an admin + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Point the target's DPoP session at the mock server + let mock_url = app.mock_server.uri(); + update_session_pds_url(&app, target_did, &mock_url).await; + + // Mock PDS createRecord + Mock::given(method("POST")) + .and(path("/xrpc/com.atproto.repo.createRecord")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "uri": format!("at://{target_did}/games.gamesgamesgamesgames.game/def456"), + "cid": "bafyadmin456" + }))) + .expect(1) + .mount(&app.mock_server) + .await; + + // Admin does a delegated write (same client) + let (adm_dpop, adm_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Admin Game", "delegateDid": target_did }), + &owner_key, + &adm_dpop, + &adm_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let body = response_json(resp).await; + assert_eq!(body["cid"], "bafyadmin456"); +} + +// --------------------------------------------------------------------------- +// Positive-path coverage for admin / member operations +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn admin_can_add_delegate() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:acd_owner"; + let admin_did = "did:plc:acd_admin"; + let new_member_did = "did:plc:acd_newmember"; + let target_did = "did:plc:acd_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds admin + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Admin adds a member (same client) + let (adm_dpop, adm_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": new_member_did, "role": "member" }), + &owner_key, + &adm_dpop, + &adm_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); + + // Verify the member exists via listDelegates (as owner) + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 3); // owner + admin + member + let member = delegates + .iter() + .find(|d| d["userDid"] == new_member_did) + .unwrap(); + assert_eq!(member["role"], "member"); + assert_eq!(member["grantedBy"], admin_did); +} + +#[tokio::test] +#[serial] +async fn admin_can_remove_member() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:arm_owner"; + let admin_did = "did:plc:arm_admin"; + let member_did = "did:plc:arm_member"; + let target_did = "did:plc:arm_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds admin and member + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Admin removes member (same client) + let (adm_dpop, adm_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": member_did }), + &owner_key, + &adm_dpop, + &adm_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify member is gone + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 2); // owner + admin only + assert!(delegates.iter().all(|d| d["userDid"] != member_did)); +} + +#[tokio::test] +#[serial] +async fn admin_can_list_delegates() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:ald_owner"; + let admin_did = "did:plc:ald_admin"; + let target_did = "did:plc:ald_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds admin + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Admin lists delegates (same client) + let (adm_dpop, adm_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, admin_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &adm_dpop, + &adm_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 2); // owner + admin +} + +#[tokio::test] +#[serial] +async fn member_can_view_account() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:mva_owner"; + let member_did = "did:plc:mva_member"; + let target_did = "did:plc:mva_studio"; + + let (owner_key, owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds member + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": member_did, "role": "member" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Member calls getAccount (same client) + let (mem_dpop, mem_token) = + setup_session_for_client(&app, &owner_key, &owner_secret, member_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &owner_key, + &mem_dpop, + &mem_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + assert_eq!(body["did"], target_did); + assert_eq!(body["role"], "member"); +} + +#[tokio::test] +#[serial] +async fn owner_can_remove_admin() { + let app = common::app::TestApp::new_with_encryption().await; + + let owner_did = "did:plc:ora_owner"; + let admin_did = "did:plc:ora_admin"; + let target_did = "did:plc:ora_studio"; + + let (owner_key, _owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Owner adds admin + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Owner removes admin + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.removeDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // Verify only owner remains + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.listDelegates?accountDid={}", + target_did + ), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + let body = response_json(resp).await; + let delegates = body["delegates"].as_array().unwrap(); + assert_eq!(delegates.len(), 1); + assert_eq!(delegates[0]["role"], "owner"); +} + +// --------------------------------------------------------------------------- +// Cross-client scoping — operations from a different API client are rejected +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn cross_client_get_account_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:xc_ga_owner"; + let target_did = "did:plc:xc_ga_studio"; + + setup_linked_account(&app, owner_did, target_did).await; + + // Different API client tries to access the account + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, owner_did).await; + let resp = dpop_get( + &app, + &format!( + "/xrpc/dev.happyview.delegation.getAccount?did={}", + target_did + ), + &other_key, + &other_dpop, + &other_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn cross_client_add_delegate_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:xc_ad_owner"; + let target_did = "did:plc:xc_ad_studio"; + + setup_linked_account(&app, owner_did, target_did).await; + + // Different API client tries to add a delegate + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, owner_did).await; + let resp = dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": "did:plc:xc_someone", "role": "member" }), + &other_key, + &other_dpop, + &other_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn cross_client_delegated_write_rejected() { + let app = common::app::TestApp::new_with_encryption().await; + seed_procedure_lexicon(&app).await; + + let owner_did = "did:plc:xc_dw_owner"; + let admin_did = "did:plc:xc_dw_admin"; + let target_did = "did:plc:xc_dw_studio"; + + let (owner_key, _owner_secret, owner_dpop, owner_token) = + setup_linked_account(&app, owner_did, target_did).await; + + // Add an admin under the correct client + dpop_post( + &app, + "/xrpc/dev.happyview.delegation.addDelegate", + &json!({ "accountDid": target_did, "userDid": admin_did, "role": "admin" }), + &owner_key, + &owner_dpop, + &owner_token, + ) + .await; + + // Admin authenticates via a different API client and tries a delegated write + let (other_key, other_dpop, other_token) = setup_dpop_session(&app, admin_did).await; + let resp = dpop_post( + &app, + "/xrpc/games.gamesgamesgamesgames.createGame", + &json!({ "title": "Cross-client Game", "delegateDid": target_did }), + &other_key, + &other_dpop, + &other_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +#[serial] +async fn cross_client_list_accounts_isolated() { + let app = common::app::TestApp::new_with_encryption().await; + let owner_did = "did:plc:xc_la_owner"; + let studio1 = "did:plc:xc_la_studio1"; + let studio2 = "did:plc:xc_la_studio2"; + + // Link studio1 under client A + setup_linked_account(&app, owner_did, studio1).await; + + // Link studio2 under client B (different API client) + let (client_b_key, _client_b_secret, client_b_dpop, client_b_token) = + setup_linked_account(&app, owner_did, studio2).await; + + // listAccounts from client B should only show studio2 + let resp = dpop_get( + &app, + "/xrpc/dev.happyview.delegation.listAccounts", + &client_b_key, + &client_b_dpop, + &client_b_token, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = response_json(resp).await; + let accounts = body["accounts"].as_array().unwrap(); + assert_eq!(accounts.len(), 1); + assert_eq!(accounts[0]["did"], studio2); +} -- 2.51.2 From 6b666d7f25374b36434a7accb08ff0571b383780 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 30 Apr 2026 10:20:57 -0500 Subject: [PATCH 11/17] feat: put permissioned spaces behind an experimental flag --- src/admin/feature_flags.rs | 18 ++ src/admin/mod.rs | 2 + src/admin/settings.rs | 1 + src/delegation/mod.rs | 1 + src/delegation/unlink_account.rs | 9 +- src/error.rs | 23 +++ src/feature_flags.rs | 45 +++++ src/feature_middleware.rs | 35 ++++ src/lib.rs | 2 + src/lua/atproto_api.rs | 36 ++++ src/lua/context.rs | 1 + src/server.rs | 17 +- src/xrpc/procedure.rs | 1 + tests/e2e_feature_flags.rs | 302 +++++++++++++++++++++++++++++++ 14 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 src/admin/feature_flags.rs create mode 100644 src/feature_flags.rs create mode 100644 src/feature_middleware.rs create mode 100644 tests/e2e_feature_flags.rs diff --git a/src/admin/feature_flags.rs b/src/admin/feature_flags.rs new file mode 100644 index 0000000..5e6a972 --- /dev/null +++ b/src/admin/feature_flags.rs @@ -0,0 +1,18 @@ +use axum::Json; +use axum::extract::State; + +use crate::AppState; +use crate::error::AppError; +use crate::feature_flags; + +use super::auth::UserAuth; +use super::permissions::Permission; + +pub(super) async fn list( + State(state): State, + auth: UserAuth, +) -> Result>, AppError> { + auth.require(Permission::SettingsManage).await?; + let flags = feature_flags::list_flags(&state.db, state.db_backend).await; + Ok(Json(flags)) +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 5dec953..6a65c66 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -5,6 +5,7 @@ mod backfill; mod dead_letters; mod domains; mod events; +mod feature_flags; mod labelers; mod lexicons; mod network_lexicons; @@ -72,6 +73,7 @@ pub fn admin_routes(_state: AppState) -> Router { "/labelers/{did}", patch(labelers::update).delete(labelers::delete), ) + .route("/feature-flags", get(feature_flags::list)) .route("/settings", get(settings::list)) .route( "/settings/logo", diff --git a/src/admin/settings.rs b/src/admin/settings.rs index 77b059d..f86c065 100644 --- a/src/admin/settings.rs +++ b/src/admin/settings.rs @@ -17,6 +17,7 @@ use super::types::{SettingEntry, UpsertSettingBody}; const ENV_FALLBACKS: &[(&str, &str)] = &[ ("app_name", "APP_NAME"), ("client_uri", "CLIENT_URI"), + ("feature.spaces_enabled", "FEATURE_SPACES_ENABLED"), ("logo_uri", "LOGO_URI"), ("tos_uri", "TOS_URI"), ("policy_uri", "POLICY_URI"), diff --git a/src/delegation/mod.rs b/src/delegation/mod.rs index f366ff3..9af9c0c 100644 --- a/src/delegation/mod.rs +++ b/src/delegation/mod.rs @@ -42,6 +42,7 @@ impl DelegateRole { } } + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Option { match s { "owner" => Some(DelegateRole::Owner), diff --git a/src/delegation/unlink_account.rs b/src/delegation/unlink_account.rs index 2d19477..3251af1 100644 --- a/src/delegation/unlink_account.rs +++ b/src/delegation/unlink_account.rs @@ -55,17 +55,16 @@ pub async fn unlink_account( db::delete_delegated_account(&state.db, state.db_backend, account_did).await?; // Delete the DPoP session for the target account using the stored api_client_id - if let Some(api_client_id) = stored_api_client_id { - if let Err(e) = crate::oauth::sessions::delete_dpop_session( + if let Some(api_client_id) = stored_api_client_id + && let Err(e) = crate::oauth::sessions::delete_dpop_session( &state.db, state.db_backend, &api_client_id, account_did, ) .await - { - tracing::warn!(account_did, %e, "failed to clean up DPoP session on unlink"); - } + { + tracing::warn!(account_did, %e, "failed to clean up DPoP session on unlink"); } log_event( diff --git a/src/error.rs b/src/error.rs index 8cd3a2e..5a814fb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -52,6 +52,7 @@ pub enum AppError { BadGateway(String), BadRequest(String), Conflict(String), + FeatureDisabled(String), Forbidden(String), InsufficientPermissions(String), Internal(String), @@ -78,6 +79,7 @@ impl std::fmt::Display for AppError { AppError::BadGateway(msg) => write!(f, "bad gateway: {msg}"), AppError::BadRequest(msg) => write!(f, "bad request: {msg}"), AppError::Conflict(msg) => write!(f, "conflict: {msg}"), + AppError::FeatureDisabled(msg) => write!(f, "feature disabled: {msg}"), AppError::Forbidden(msg) => write!(f, "forbidden: {msg}"), AppError::InsufficientPermissions(perm) => write!(f, "Missing permission: {perm}"), AppError::Internal(msg) => write!(f, "internal error: {msg}"), @@ -142,6 +144,13 @@ impl IntoResponse for AppError { }); (status, axum::Json(body)).into_response() } + AppError::FeatureDisabled(msg) => { + let body = serde_json::json!({ + "error": "FeatureDisabled", + "message": msg, + }); + (StatusCode::NOT_FOUND, axum::Json(body)).into_response() + } AppError::InsufficientPermissions(perm) => { let body = serde_json::json!({ "error": "InsufficientPermissions", @@ -181,6 +190,7 @@ impl IntoResponse for AppError { AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), AppError::PdsError(..) | AppError::AuthDpopNonce(..) + | AppError::FeatureDisabled(..) | AppError::InsufficientPermissions(..) | AppError::RateLimited { .. } | AppError::ScriptError { .. } => unreachable!(), @@ -275,6 +285,15 @@ mod tests { assert_eq!(body["line"], 5); } + #[tokio::test] + async fn feature_disabled_returns_404() { + let (status, body) = + response_parts(AppError::FeatureDisabled("spaces not enabled".into())).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["error"], "FeatureDisabled"); + assert_eq!(body["message"], "spaces not enabled"); + } + #[tokio::test] async fn not_found_returns_404() { let (status, body) = response_parts(AppError::NotFound("no such thing".into())).await; @@ -343,6 +362,10 @@ mod tests { AppError::Internal("z".into()).to_string(), "internal error: z" ); + assert_eq!( + AppError::FeatureDisabled("x".into()).to_string(), + "feature disabled: x" + ); assert_eq!(AppError::NotFound("w".into()).to_string(), "not found: w"); assert_eq!( AppError::PdsError(StatusCode::BAD_GATEWAY, Bytes::new()).to_string(), diff --git a/src/feature_flags.rs b/src/feature_flags.rs new file mode 100644 index 0000000..28cb2ff --- /dev/null +++ b/src/feature_flags.rs @@ -0,0 +1,45 @@ +use sqlx::AnyPool; + +use crate::admin::settings::get_setting; +use crate::db::DatabaseBackend; + +pub struct FeatureFlag; + +impl FeatureFlag { + pub const SPACES_ENABLED: &str = "feature.spaces_enabled"; +} + +pub async fn is_enabled(pool: &AnyPool, key: &str, backend: DatabaseBackend) -> bool { + get_setting(pool, key, backend) + .await + .map(|v| v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + +#[derive(serde::Serialize)] +pub struct FeatureFlagStatus { + pub key: String, + pub name: String, + pub description: String, + pub enabled: bool, +} + +pub async fn list_flags(pool: &AnyPool, backend: DatabaseBackend) -> Vec { + let all_flags = [( + FeatureFlag::SPACES_ENABLED, + "Permissioned Spaces", + "Collaborative data spaces with granular permissions, membership, and invites.", + )]; + + let mut result = Vec::new(); + for (key, name, description) in all_flags { + let enabled = is_enabled(pool, key, backend).await; + result.push(FeatureFlagStatus { + key: key.to_string(), + name: name.to_string(), + description: description.to_string(), + enabled, + }); + } + result +} diff --git a/src/feature_middleware.rs b/src/feature_middleware.rs new file mode 100644 index 0000000..58a9c92 --- /dev/null +++ b/src/feature_middleware.rs @@ -0,0 +1,35 @@ +use axum::extract::{Request, State}; +use axum::middleware::Next; +use axum::response::Response; + +use crate::AppState; +use crate::error::AppError; + +async fn require_feature( + flag_key: &'static str, + State(state): State, + req: Request, + next: Next, +) -> Result { + if !crate::feature_flags::is_enabled(&state.db, flag_key, state.db_backend).await { + return Err(AppError::FeatureDisabled(format!( + "The feature '{}' is not currently enabled on this instance", + flag_key + ))); + } + Ok(next.run(req).await) +} + +pub async fn require_spaces( + state: State, + req: Request, + next: Next, +) -> Result { + require_feature( + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + state, + req, + next, + ) + .await +} diff --git a/src/lib.rs b/src/lib.rs index daf60e9..d467b65 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,8 @@ pub mod domain_middleware; pub mod error; pub mod event_log; pub mod external_auth; +pub mod feature_flags; +pub mod feature_middleware; pub mod jetstream; pub mod labeler; pub mod lexicon; diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs index 58e64d6..bc1c614 100644 --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -266,6 +266,15 @@ pub fn register_atproto_api( lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { let state = state_clone.clone(); async move { + if !crate::feature_flags::is_enabled( + &state.db, + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + state.db_backend, + ) + .await + { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } let uri = crate::spaces::SpaceUri::parse(&space_uri) .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; let space = crate::spaces::db::get_space_by_address( @@ -298,6 +307,15 @@ pub fn register_atproto_api( lua.create_async_function(move |_lua, (space_uri, did): (String, String)| { let state = state_clone.clone(); async move { + if !crate::feature_flags::is_enabled( + &state.db, + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + state.db_backend, + ) + .await + { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } let uri = crate::spaces::SpaceUri::parse(&space_uri) .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; let space = crate::spaces::db::get_space_by_address( @@ -329,6 +347,15 @@ pub fn register_atproto_api( let list_members_fn = lua.create_async_function(move |lua, space_uri: String| { let state = state_clone.clone(); async move { + if !crate::feature_flags::is_enabled( + &state.db, + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + state.db_backend, + ) + .await + { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } let uri = crate::spaces::SpaceUri::parse(&space_uri) .map_err(|e| mlua::Error::runtime(format!("invalid space URI: {e}")))?; let space = crate::spaces::db::get_space_by_address( @@ -368,6 +395,15 @@ pub fn register_atproto_api( let query_fn = lua.create_async_function(move |lua, opts: mlua::Table| { let state = state_clone.clone(); async move { + if !crate::feature_flags::is_enabled( + &state.db, + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + state.db_backend, + ) + .await + { + return Err(mlua::Error::runtime("spaces feature is not enabled")); + } let space_uri: String = opts .get("space_uri") .map_err(|_| mlua::Error::runtime("space_uri is required"))?; diff --git a/src/lua/context.rs b/src/lua/context.rs index b62a882..eab578e 100644 --- a/src/lua/context.rs +++ b/src/lua/context.rs @@ -32,6 +32,7 @@ fn set_space_context(lua: &Lua, space: Option<&SpaceContext>) -> LuaResult<()> { } /// Set global context variables for a procedure script. +#[allow(clippy::too_many_arguments)] pub fn set_procedure_context( lua: &Lua, method: &str, diff --git a/src/server.rs b/src/server.rs index 72cae9d..11e470b 100644 --- a/src/server.rs +++ b/src/server.rs @@ -62,7 +62,12 @@ pub fn router(state: AppState) -> Router { let serve_dir = ServeDir::new(&static_dir).not_found_service(spa_fallback); let domain_routes = Router::new() - .merge(crate::spaces::routes::space_routes()) + .merge( + crate::spaces::routes::space_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()) @@ -190,6 +195,13 @@ async fn config_endpoint( _ => env!("CARGO_PKG_VERSION"), }; + let spaces_enabled = crate::feature_flags::is_enabled( + pool, + crate::feature_flags::FeatureFlag::SPACES_ENABLED, + backend, + ) + .await; + Json(serde_json::json!({ "public_url": domain_url, "version": version, @@ -201,6 +213,9 @@ async fn config_endpoint( "default_rate_limit_refill_rate": state.config.default_rate_limit_refill_rate, "app_name": app_name, "logo_url": logo_url, + "features": { + "spaces": spaces_enabled, + }, })) } diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs index 13850d0..ab1d618 100644 --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -346,6 +346,7 @@ async fn handle_delete_record( } } +#[allow(clippy::too_many_arguments)] async fn handle_dpop_procedure( state: &AppState, claims: &Claims, diff --git a/tests/e2e_feature_flags.rs b/tests/e2e_feature_flags.rs new file mode 100644 index 0000000..617d7c9 --- /dev/null +++ b/tests/e2e_feature_flags.rs @@ -0,0 +1,302 @@ +mod common; + +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +fn admin_put( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method(Method::PUT) + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +fn admin_delete( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .method(Method::DELETE) + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +#[tokio::test] +#[serial] +#[ignore] +async fn space_routes_blocked_when_flag_disabled() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/dev.happyview.space.list") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body = json_body(resp).await; + assert_eq!(body["error"], "FeatureDisabled"); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn space_routes_allowed_after_enabling_flag() { + let app = TestApp::new().await; + + // Enable the feature flag + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + &json!({ "value": "true" }), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + // Space routes should now pass through (will get auth error, not FeatureDisabled) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/dev.happyview.space.list") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = json_body(resp).await; + assert_ne!( + body["error"].as_str().unwrap_or(""), + "FeatureDisabled", + "expected request to pass through feature gate" + ); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn space_routes_blocked_again_after_disabling_flag() { + let app = TestApp::new().await; + + // Enable + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + &json!({ "value": "true" }), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + // Disable + let resp = app + .router + .clone() + .oneshot(admin_delete( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + // Space routes should be blocked again + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/dev.happyview.space.list") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + let body = json_body(resp).await; + assert_eq!(body["error"], "FeatureDisabled"); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn admin_feature_flags_lists_flags() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/feature-flags", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let flags = body.as_array().expect("expected array"); + assert!(!flags.is_empty()); + + let spaces_flag = flags + .iter() + .find(|f| f["key"] == "feature.spaces_enabled") + .expect("spaces flag not found"); + assert_eq!(spaces_flag["enabled"], false); + assert!(spaces_flag["name"].as_str().is_some()); + assert!(spaces_flag["description"].as_str().is_some()); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn admin_feature_flags_reflects_enabled_state() { + let app = TestApp::new().await; + + // Enable the flag + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + &json!({ "value": "true" }), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/feature-flags", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let flags = body.as_array().unwrap(); + let spaces_flag = flags + .iter() + .find(|f| f["key"] == "feature.spaces_enabled") + .unwrap(); + assert_eq!(spaces_flag["enabled"], true); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn config_endpoint_includes_features() { + let app = TestApp::new().await; + + // Default: spaces disabled + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["features"]["spaces"], false); + + // Enable the flag + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/feature.spaces_enabled", + app.admin_cookie(), + &json!({ "value": "true" }), + )) + .await + .unwrap(); + assert!(resp.status().is_success()); + + // Now config should reflect enabled + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["features"]["spaces"], true); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn admin_feature_flags_requires_auth() { + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/feature-flags") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} -- 2.51.2 From a8e40f7a8f46f204806655cd8ca3c306a9458494 Mon Sep 17 00:00:00 2001 From: Trezy Date: Thu, 30 Apr 2026 14:34:14 -0500 Subject: [PATCH 12/17] docs: add missing docs for Lua APIs --- .../docs/getting-started/configuration.md | 3 + .../guides/features/attestation-signing.md | 109 ++++++++++++++++++ .../docs/docs/guides/indexing/index-hooks.md | 3 + packages/docs/docs/guides/scripting.md | 48 +++++--- .../guides/scripting/signed-record-verify.md | 54 +++++++++ .../docs/guides/scripting/signed-record.md | 56 +++++++++ .../docs/docs/reference/lua/atproto-api.md | 92 ++++++++++++--- .../docs/docs/reference/lua/database-api.md | 20 +++- .../docs/docs/reference/lua/xrpc-lua-api.md | 76 ++++++++++++ packages/docs/sidebars.ts | 20 ++++ 10 files changed, 450 insertions(+), 31 deletions(-) create mode 100644 packages/docs/docs/guides/features/attestation-signing.md create mode 100644 packages/docs/docs/guides/scripting/signed-record-verify.md create mode 100644 packages/docs/docs/guides/scripting/signed-record.md create mode 100644 packages/docs/docs/reference/lua/xrpc-lua-api.md diff --git a/packages/docs/docs/getting-started/configuration.md b/packages/docs/docs/getting-started/configuration.md index 1df104d..34a809f 100644 --- a/packages/docs/docs/getting-started/configuration.md +++ b/packages/docs/docs/getting-started/configuration.md @@ -20,6 +20,9 @@ HappyView is configured via environment variables. A `.env` file in the project | `TOKEN_ENCRYPTION_KEY` | no | --- | Base64-encoded 32-byte key for encrypting stored OAuth tokens. **Strongly recommended in production** | | `DEFAULT_RATE_LIMIT_CAPACITY` | no | `100` | Default token bucket capacity used when registering a new API client | | `DEFAULT_RATE_LIMIT_REFILL_RATE` | no | `2.0` | Default token bucket refill rate (tokens/second) for new API clients | +| `ATTESTATION_PRIVATE_KEY` | no | auto-generated | Hex-encoded 32-byte secp256k1 private key for [attestation signing](../guides/features/attestation-signing.md). Auto-generated and persisted to database on first run | +| `ATTESTATION_KEY_ID` | no | `did:web:{host}#attestation` | Key identifier included in attestation signatures. Derived from `PUBLIC_URL` by default | +| `ATTESTATION_SIG_TYPE` | no | app-specific NSID | `$type` value used in attestation signature objects | | `RUST_LOG` | no | `happyview=debug,tower_http=debug` | Log filter (uses `tracing_subscriber::EnvFilter`) | | `APP_NAME` | no | --- | Application name shown on OAuth authorization screens. Overridden by database setting if set via admin API | | `LOGO_URI` | no | --- | URL to application logo for OAuth screens. Overridden by database setting or logo upload | diff --git a/packages/docs/docs/guides/features/attestation-signing.md b/packages/docs/docs/guides/features/attestation-signing.md new file mode 100644 index 0000000..3348c2f --- /dev/null +++ b/packages/docs/docs/guides/features/attestation-signing.md @@ -0,0 +1,109 @@ +# Attestation Signing + +HappyView can sign records with an ECDSA (secp256k1) keypair so their origin can be verified later. Lua scripts call `atproto.sign()` to attach an inline signature to a record and `atproto.verify_signature()` to check one. HappyView's implementation follows the [atproto attestation spec](https://tangled.org/strings/did:plc:cbkjy5n7bk3ax2wplmtjofq2/3m3fy2xuahc22). + +## How it works + +1. HappyView loads or generates a secp256k1 keypair on startup +2. `atproto.sign(record)` encodes the record to DAG-CBOR, computes its CID, and signs the CID with the private key +3. The signature is added to the record's `signatures` array as an inline object +4. `atproto.verify_signature(record, sig, repo_did)` recomputes the CID and verifies the signature + +The repo DID is included in the signed data — a signature for one user's record can't be replayed against another's. Any modification to the record invalidates the signature. + +## Setup + +Attestation signing is enabled by default — HappyView generates a keypair on first startup and persists it to the `instance_settings` database table. No configuration is required. + +To use an explicit key instead, set the `ATTESTATION_PRIVATE_KEY` environment variable: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `ATTESTATION_PRIVATE_KEY` | no | auto-generated | Hex-encoded 32-byte secp256k1 private key | +| `ATTESTATION_KEY_ID` | no | `did:web:{host}#attestation` | Key identifier included in signatures. Derived from `PUBLIC_URL` by default | +| `ATTESTATION_SIG_TYPE` | no | app-specific NSID | The `$type` value used in signature objects | + +The key ID defaults to a `did:web` derived from your `PUBLIC_URL`. For example, `PUBLIC_URL=https://happyview.example.com` produces a key ID of `did:web:happyview.example.com#attestation`. + +### Priority order + +HappyView checks for signing configuration in this order: + +1. **Environment variables** — if `ATTESTATION_PRIVATE_KEY` is set, it's used +2. **Database** — if previously generated keys exist in `instance_settings`, they're loaded +3. **Auto-generation** — a new key is generated and persisted to the database + +If key loading fails for any reason, signing is disabled and `atproto.sign` / `atproto.verify_signature` will be `nil` in Lua scripts. + +## Using in Lua scripts + +Available in queries, procedures, and index hooks via the [atproto API](../../reference/lua/atproto-api.md). + +### Signing a record + +```lua +function handle() + local r = Record(collection, input) + r:save() + + local sig = atproto.sign({ text = input.text, createdAt = input.createdAt }) + return { uri = r._uri, cid = r._cid, signature = sig } +end +``` + +The returned signature object: + +```json +{ + "$type": "your.app.attestation", + "key": "did:web:happyview.example.com#attestation", + "signature": { + "$bytes": "base64-encoded-signature" + } +} +``` + +### Verifying a signature + +```lua +function handle() + local record = db.get(params.uri) + if not record then + return { error = "not found" } + end + + local sig = record.signatures and record.signatures[1] + if not sig then + return { record = record, verified = false } + end + + local valid = atproto.verify_signature(record, sig, record.did) + return { record = record, verified = valid } +end +``` + +### Checking availability + +Both functions are `nil` when no signer is configured: + +```lua +if atproto.sign then + record.signature = atproto.sign(record) +end +``` + +## Signature format + +Signatures are stored as objects in the record's `signatures` array: + +| Field | Type | Description | +| ----------- | ------ | ------------------------------------ | +| `$type` | string | Signature type NSID | +| `key` | string | Key identifier (DID with fragment) | +| `signature` | table | Contains `$bytes` (base64-encoded) | + +## Next steps + +- [atproto API reference](../../reference/lua/atproto-api.md#atprotosign) — `atproto.sign` and `atproto.verify_signature` parameter docs +- [Signed Record](../scripting/signed-record.md) — save a record with an attestation signature +- [Verify Signed Record](../scripting/signed-record-verify.md) — fetch a record and verify its signature diff --git a/packages/docs/docs/guides/indexing/index-hooks.md b/packages/docs/docs/guides/indexing/index-hooks.md index c00e3b9..a7f37d1 100644 --- a/packages/docs/docs/guides/indexing/index-hooks.md +++ b/packages/docs/docs/guides/indexing/index-hooks.md @@ -59,8 +59,11 @@ Index hooks have access to: - **[Database API](../../reference/lua/database-api.md)** — `db.query`, `db.get`, `db.search`, `db.backlinks`, `db.count`, `db.raw` - **[HTTP API](../../reference/lua/http-api.md)** — `http.get`, `http.post`, `http.put`, `http.patch`, `http.delete`, `http.head` +- **[XRPC Lua API](../../reference/lua/xrpc-lua-api.md)** — `xrpc.query`, `xrpc.procedure` +- **[atproto API](../../reference/lua/atproto-api.md)** — `atproto.resolve_service_endpoint`, `atproto.get_labels`, `atproto.get_labels_batch` - **[JSON API](../../reference/lua/json-api.md)** — `json.encode`, `json.decode` - **[Utility globals](../scripting.md#utility-globals)** — `log()`, `now()`, `TID()`, `toarray()` +- **[Script variables](../../reference/admin/script-variables.md)** — `env` table with key-value pairs configured in the dashboard ## Error handling and retries diff --git a/packages/docs/docs/guides/scripting.md b/packages/docs/docs/guides/scripting.md index 2ea70db..a8dc6fe 100644 --- a/packages/docs/docs/guides/scripting.md +++ b/packages/docs/docs/guides/scripting.md @@ -43,22 +43,25 @@ These globals are set automatically before `handle()` is called. ### Procedure globals -| Global | Type | Description | -| ------------ | ------ | ------------------------------------------------------- | -| `method` | string | The XRPC method name (e.g. `xyz.statusphere.setStatus`) | -| `input` | table | Parsed JSON request body | -| `caller_did` | string | DID of the authenticated user | -| `collection` | string | Target collection NSID | +| Global | Type | Description | +| -------------- | ------- | ------------------------------------------------------- | +| `method` | string | The XRPC method name (e.g. `xyz.statusphere.setStatus`) | +| `input` | table | Parsed JSON request body | +| `params` | table | Query string parameters | +| `caller_did` | string | DID of the authenticated user | +| `collection` | string | Target collection NSID | +| `delegate_did` | string? | DID of the delegated account, if using write delegation | +| `env` | table | Script variables configured in the dashboard | ### Query globals -| Global | Type | Description | -| ------------ | ------ | ------------------------------------------------ | -| `method` | string | The XRPC method name | -| `params` | table | Query string parameters (all values are strings) | -| `collection` | string | Target collection NSID | - -Queries are unauthenticated: there is no `caller_did` or `input`. +| Global | Type | Description | +| ------------ | ------- | ------------------------------------------------ | +| `method` | string | The XRPC method name | +| `params` | table | Query string parameters (all values are strings) | +| `collection` | string | Target collection NSID | +| `caller_did` | string? | DID of the authenticated user (nil if unauthenticated) | +| `env` | table | Script variables configured in the dashboard | ## Utility globals @@ -127,11 +130,24 @@ local resp = http.get("https://api.example.com/data") local data = json.decode(resp.body) ``` +## XRPC Lua API + +The `xrpc` table lets scripts call other XRPC endpoints — both local and proxied. Available in both queries and procedures. + +See the full [XRPC Lua API reference](../reference/lua/xrpc-lua-api.md) for `xrpc.query` and `xrpc.procedure`. + +Quick example: + +```lua +local resp = xrpc.query("xyz.statusphere.listStatuses", { limit = 5 }) +local data = json.decode(resp.body) +``` + ## atproto API -The `atproto` table provides atproto utility functions like DID resolution and label queries. +The `atproto` table provides atproto utility functions like DID resolution, label queries, and record signing. -See the full [atproto API reference](../reference/lua/atproto-api.md) for `atproto.resolve_service_endpoint`, `atproto.get_labels`, and `atproto.get_labels_batch`. +See the full [atproto API reference](../reference/lua/atproto-api.md) for `atproto.resolve_service_endpoint`, `atproto.get_labels`, `atproto.get_labels_batch`, `atproto.sign`, and `atproto.verify_signature`. ## JSON API @@ -181,6 +197,7 @@ See the example script references for complete, ready-to-use scripts: - [Paginated list](scripting/paginated-list.md) — list records with cursor-based pagination and DID filtering - [List or fetch](scripting/list-or-fetch.md) — combined single-record lookup and paginated listing - [Expanded query](scripting/expanded-query.md) — list statuses with user profiles in a single response +- [Verify signed record](scripting/signed-record-verify.md) — fetch a record and verify its attestation signature **Procedures:** - [Create a record](scripting/create-record.md) — simple write that saves input as a record @@ -190,6 +207,7 @@ See the example script references for complete, ready-to-use scripts: - [Sidecar records](scripting/sidecar-records.md) — create linked records across collections with a shared rkey - [Cascading delete](scripting/cascading-delete.md) — delete a record and all related records - [Complex mutations](scripting/complex-mutations.md) — load, transform, and save a record with multiple field changes +- [Signed record](scripting/signed-record.md) — save a record with an attestation signature **Index Hooks:** - [Algolia sync](scripting/algolia-sync.md) — push records to an Algolia search index on create/update/delete diff --git a/packages/docs/docs/guides/scripting/signed-record-verify.md b/packages/docs/docs/guides/scripting/signed-record-verify.md new file mode 100644 index 0000000..dc0dcd5 --- /dev/null +++ b/packages/docs/docs/guides/scripting/signed-record-verify.md @@ -0,0 +1,54 @@ +# Query: Verify Signed Record + +Fetch a record and verify its attestation signature. + +**Lexicon type:** query + +```lua +function handle() + local record = db.get(params.uri) + if not record then + return { error = "not found" } + end + + local verified = false + if atproto.verify_signature and record.signature then + verified = atproto.verify_signature( + { text = record.text, createdAt = record.createdAt }, + record.signature, + params.did + ) + end + + return { record = record, verified = verified } +end +``` + +## How it works + +1. Fetch the record by AT URI. +2. If a signature is present, rebuild the same field table that was signed and verify it with [`atproto.verify_signature()`](../../reference/lua/atproto-api.md#atprotoverify_signature). +3. Return `verified = true` if the signature is valid, `false` if it's missing, invalid, or the signer isn't configured. + +## Usage + +```sh +curl "http://127.0.0.1:3000/xrpc/xyz.example.getPost?uri=at://did:plc:abc/xyz.example.post/3abc123&did=did:plc:abc" +``` + +```json +{ + "record": { + "uri": "at://did:plc:abc/xyz.example.post/3abc123", + "text": "Hello world", + "createdAt": "2026-04-30T12:00:00Z" + }, + "verified": true +} +``` + +## Use case + +Pair this with the [Signed Record](signed-record.md) procedure to create a write-then-verify flow. The query re-derives the CID from the same fields that were originally signed, so any tampering between write and read is caught. + +See [Attestation Signing](../features/attestation-signing.md) for setup and configuration. diff --git a/packages/docs/docs/guides/scripting/signed-record.md b/packages/docs/docs/guides/scripting/signed-record.md new file mode 100644 index 0000000..448bc55 --- /dev/null +++ b/packages/docs/docs/guides/scripting/signed-record.md @@ -0,0 +1,56 @@ +# Procedure: Signed Record + +Save a record with an attestation signature attached. + +**Lexicon type:** procedure + +```lua +function handle() + local r = Record(collection, { + text = input.text, + createdAt = now(), + }) + r:save() + + local sig = nil + if atproto.sign then + sig = atproto.sign({ text = input.text, createdAt = r.createdAt }) + end + + return { uri = r._uri, cid = r._cid, signature = sig } +end +``` + +## How it works + +1. Create and save the record. +2. Sign the record fields with [`atproto.sign()`](../../reference/lua/atproto-api.md#atprotosign). The `nil` guard lets the script work without a signer configured. +3. Return the signature alongside the URI. + +## Usage + +```sh +curl -X POST http://127.0.0.1:3000/xrpc/xyz.example.createPost \ + -H "X-Client-Key: $CLIENT_KEY" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ "text": "Hello world" }' +``` + +```json +{ + "uri": "at://did:plc:abc/xyz.example.post/3abc123", + "cid": "bafyrei...", + "signature": { + "$type": "your.app.attestation", + "key": "did:web:happyview.example.com#attestation", + "signature": { "$bytes": "..." } + } +} +``` + +## Use case + +Attestation signatures let clients verify that a record was processed by your HappyView instance — useful for contributions, moderation decisions, or cross-instance data where provenance matters. The signature covers both the record content and the author's DID, so it can't be replayed across users or tampered with. + +See [Attestation Signing](../features/attestation-signing.md) for setup and configuration, or [Verify Signed Record](signed-record-verify.md) for the read-side counterpart. diff --git a/packages/docs/docs/reference/lua/atproto-api.md b/packages/docs/docs/reference/lua/atproto-api.md index 271e965..9286b7f 100644 --- a/packages/docs/docs/reference/lua/atproto-api.md +++ b/packages/docs/docs/reference/lua/atproto-api.md @@ -10,9 +10,9 @@ local endpoint = atproto.resolve_service_endpoint(did) Resolves a DID to its atproto service endpoint URL by fetching the DID document. Supports both `did:plc:*` (via the PLC directory) and `did:web:*` (via `.well-known/did.json`). -| Parameter | Type | Description | -| --------- | ------ | ------------------------ | -| `did` | string | The DID to resolve | +| Parameter | Type | Description | +| --------- | ------ | ------------------ | +| `did` | string | The DID to resolve | **Returns:** The service endpoint URL as a string, or `nil` if resolution fails (DID not found, no PDS service in document, network error). @@ -49,18 +49,18 @@ local labels = atproto.get_labels(uri) Returns an array of labels for a single AT URI. Merges external labels (from subscribed labelers) with self-labels (from the record's `labels.values[]` field). -| Parameter | Type | Description | -| --------- | ------ | ------------------------------ | -| `uri` | string | AT URI of the record to query | +| Parameter | Type | Description | +| --------- | ------ | ----------------------------- | +| `uri` | string | AT URI of the record to query | Each label in the array is a table with: -| Field | Type | Description | -| ----- | ------ | ---------------------------------------- | -| `src` | string | DID of the labeler (or record author) | -| `uri` | string | AT URI this label applies to | -| `val` | string | Label value (e.g. "nsfw", "!hide") | -| `cts` | string | Timestamp when the label was created | +| Field | Type | Description | +| ----- | ------ | ------------------------------------- | +| `src` | string | DID of the labeler (or record author) | +| `uri` | string | AT URI this label applies to | +| `val` | string | Label value (e.g. "nsfw", "!hide") | +| `cts` | string | Timestamp when the label was created | Expired labels are automatically filtered out. Returns an empty array if no labels exist. @@ -72,9 +72,9 @@ local labels_by_uri = atproto.get_labels_batch(uris) Batch version of `get_labels`. Takes an array of AT URIs and returns a table keyed by URI, where each value is an array of labels. -| Parameter | Type | Description | -| --------- | ----- | ------------------------ | -| `uris` | table | Array of AT URI strings | +| Parameter | Type | Description | +| --------- | ----- | ----------------------- | +| `uris` | table | Array of AT URI strings | **Returns:** A table keyed by URI. Each value is an array of label tables (same shape as `get_labels`). URIs with no labels have an empty array. @@ -105,3 +105,65 @@ for _, uri in ipairs(uris) do end end ``` + +## atproto.sign + +```lua +local sig = atproto.sign(record) +``` + +Signs a record and returns the inline signature object. Only available when an attestation signer is configured — if no signer is configured, `atproto.sign` is `nil`. + +| Parameter | Type | Description | +| --------- | ----- | ----------------------- | +| `record` | table | The record data to sign | + +**Returns:** A signature table with: + +| Field | Type | Description | +| ----------- | ------ | --------------------------------------------------- | +| `key` | string | The signing key ID (e.g. `did:web:example#signing`) | +| `signature` | table | Contains `$bytes` with the signature | + +### Examples + +```lua +-- Sign a record before returning it +local record = { contributionType = "correction", changes = { name = "Test" } } +local sig = atproto.sign(record) +record.signature = sig +return record + +-- Check if signing is available +if atproto.sign then + local sig = atproto.sign(record) +end +``` + +## atproto.verify_signature + +```lua +local valid = atproto.verify_signature(record, signature, repo_did) +``` + +Verifies that an inline signature was produced by this HappyView instance. Only available when an attestation signer is configured — if no signer is configured, `atproto.verify_signature` is `nil`. + +| Parameter | Type | Description | +| ----------- | ------ | ------------------------------------------ | +| `record` | table | The record data | +| `signature` | table | The signature object from `atproto.sign()` | +| `repo_did` | string | The repo DID | + +**Returns:** `true` if the signature is valid, `false` otherwise. Returns `false` on failure rather than raising an error. + +### Examples + +```lua +-- Verify a signature roundtrip +local record = { contributionType = "correction", changes = { name = "Test" } } +local sig = atproto.sign(record) +local valid = atproto.verify_signature(record, sig, caller_did) +if not valid then + return { error = "signature verification failed" } +end +``` diff --git a/packages/docs/docs/reference/lua/database-api.md b/packages/docs/docs/reference/lua/database-api.md index f04667f..c6797c7 100644 --- a/packages/docs/docs/reference/lua/database-api.md +++ b/packages/docs/docs/reference/lua/database-api.md @@ -95,7 +95,7 @@ Parameters are passed as an array and bound to `$1`, `$2`, etc. Supported parame ### SQL dialect -Write SQL in **SQLite syntax** — HappyView translates it to Postgres at runtime if you're using Postgres. See [Database Setup](../../guides/database/database-setup.md) for details on what gets translated. If you need database-specific SQL that can't be translated, check `db.is_postgres()` at runtime. +Write SQL in **SQLite syntax** — HappyView translates it to Postgres at runtime if you're using Postgres. See [Database Setup](../../guides/database/database-setup.md) for details on what gets translated. If you need database-specific SQL that can't be translated, check `db.backend()` at runtime. ### Column type mapping @@ -108,3 +108,21 @@ Write SQL in **SQLite syntax** — HappyView translates it to Postgres at runtim | `TEXT` (JSON) | `JSON`, `JSONB` | table | | `TEXT` (ISO 8601) | `TIMESTAMPTZ` | string (ISO 8601) | | Other | Other | string (fallback) | + +## db.backend + +```lua +local backend = db.backend() +-- "sqlite" or "postgres" +``` + +Returns `"sqlite"` or `"postgres"`. Useful when you need database-specific SQL that can't be automatically translated. + +```lua +if db.backend() == "postgres" then + db.raw("SELECT * FROM records WHERE record @> $1::jsonb", { json.encode({ status = "active" }) }) +else + -- SQLite fallback + db.raw("SELECT * FROM records WHERE json_extract(record, '$.status') = $1", { "active" }) +end +``` diff --git a/packages/docs/docs/reference/lua/xrpc-lua-api.md b/packages/docs/docs/reference/lua/xrpc-lua-api.md new file mode 100644 index 0000000..f0a9a85 --- /dev/null +++ b/packages/docs/docs/reference/lua/xrpc-lua-api.md @@ -0,0 +1,76 @@ +# XRPC Lua API + +The `xrpc` table provides cross-endpoint XRPC calls. Available in queries, procedures, and [index hooks](../../guides/indexing/index-hooks.md). + +## xrpc.query + +```lua +local resp = xrpc.query("xyz.statusphere.listStatuses", { -- required: XRPC method name + limit = 10, -- optional: query parameters +}) +``` + +Calls an XRPC query. If the method matches a locally registered query lexicon, it runs locally. Otherwise, the request is proxied to the NSID's authority. + +**Returns:** A table with: + +| Field | Type | Description | +| -------- | ------- | -------------------- | +| `status` | integer | HTTP status code | +| `body` | string | Response body (JSON) | + +The body is a raw JSON string — use `json.decode(resp.body)` to parse it. + +### Examples + +```lua +-- Call a local query endpoint +local resp = xrpc.query("xyz.statusphere.listStatuses", { limit = 5 }) +local data = json.decode(resp.body) +for _, record in ipairs(data.records) do + log(record.uri) +end + +-- Call without parameters +local resp = xrpc.query("com.example.getConfig") + +-- Proxy to a remote XRPC endpoint +local resp = xrpc.query("app.bsky.feed.getAuthorFeed", { + actor = "did:plc:abc123", + limit = 10, +}) +``` + +## xrpc.procedure + +```lua +local resp = xrpc.procedure( + "xyz.statusphere.setStatus", -- required: XRPC method name + { status = "hello" }, -- required: request body + { someParam = "value" } -- optional: query parameters +) +``` + +Calls an XRPC procedure using the current request's `caller_did` for authentication. If the method matches a locally registered procedure lexicon, it runs locally. Otherwise, the request is proxied. + +Requires a `caller_did` — raises an error without one. + +**Returns:** A table with the same shape as `xrpc.query` responses (`status` and `body`). + +### Examples + +```lua +-- Call a local procedure +local resp = xrpc.procedure("xyz.statusphere.setStatus", { + status = "hello", + createdAt = now(), +}) + +if resp.status ~= 200 then + return { error = "failed: " .. resp.body } +end + +-- Parse the response +local result = json.decode(resp.body) +return { uri = result.uri } +``` diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index 60dd611..cdcfed2 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -88,6 +88,11 @@ const sidebars: SidebarsConfig = { id: "guides/features/api-clients", label: "API Clients", }, + { + type: "doc", + id: "guides/features/attestation-signing", + label: "Attestation Signing", + }, { type: "doc", id: "guides/features/labelers", @@ -194,6 +199,16 @@ const sidebars: SidebarsConfig = { id: "guides/scripting/complex-mutations", label: "Complex Mutations", }, + { + type: "doc", + id: "guides/scripting/signed-record", + label: "Signed Record", + }, + { + type: "doc", + id: "guides/scripting/signed-record-verify", + label: "Verify Signed Record", + }, { type: "doc", id: "guides/scripting/algolia-sync", @@ -393,6 +408,11 @@ const sidebars: SidebarsConfig = { id: "reference/lua/http-api", label: "HTTP API", }, + { + type: "doc", + id: "reference/lua/xrpc-lua-api", + label: "XRPC Lua API", + }, { type: "doc", id: "reference/lua/atproto-api", -- 2.51.2 From 83df252a3d1d57e67c58b6e4e099642d40790c28 Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 1 May 2026 11:35:57 -0500 Subject: [PATCH 13/17] feat: add control for experiments to the dashboard --- .../dashboard/settings/experiments/page.tsx | 96 +++++++++++++++++++ web/src/components/app-sidebar.tsx | 7 ++ web/src/lib/api.ts | 19 ++++ 3 files changed, 122 insertions(+) create mode 100644 web/src/app/dashboard/settings/experiments/page.tsx diff --git a/web/src/app/dashboard/settings/experiments/page.tsx b/web/src/app/dashboard/settings/experiments/page.tsx new file mode 100644 index 0000000..74d3e23 --- /dev/null +++ b/web/src/app/dashboard/settings/experiments/page.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { IconAlertTriangle } from "@tabler/icons-react" + +import { useCurrentUser } from "@/hooks/use-current-user" +import { getFeatureFlags, setFeatureFlag, type FeatureFlag } from "@/lib/api" +import { SiteHeader } from "@/components/site-header" +import { Switch } from "@/components/ui/switch" +import { Label } from "@/components/ui/label" + +export default function ExperimentsPage() { + const { hasPermission } = useCurrentUser() + const canManage = hasPermission("settings:manage") + + const [flags, setFlags] = useState([]) + const [error, setError] = useState(null) + const [toggling, setToggling] = useState(null) + + const load = useCallback(async () => { + try { + const data = await getFeatureFlags() + setFlags(data) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + async function handleToggle(flag: FeatureFlag) { + setError(null) + setToggling(flag.key) + try { + await setFeatureFlag(flag.key, !flag.enabled) + await load() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setToggling(null) + } + } + + return ( + <> + +
+
+ +
+

+ Experimental features +

+

+ These features are under active development. They may be + incomplete, change without notice, or be removed entirely. + Disabling a feature hides its routes and UI — no data is deleted. +

+
+
+ + {error &&

{error}

} + + {flags.map((flag) => ( +
+
+ +

+ {flag.description} +

+
+ handleToggle(flag)} + disabled={!canManage || toggling === flag.key} + /> +
+ ))} + + {flags.length === 0 && !error && ( +

+ No experimental features available. +

+ )} +
+ + ) +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index b7d55a0..cc54c77 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -19,6 +19,7 @@ import { IconArrowUpCircle, IconArrowsShuffle, IconSkull, + IconFlask, } from "@tabler/icons-react"; import Image from "next/image"; import Link from "next/link"; @@ -123,6 +124,12 @@ const systemItems: NavItem[] = [ icon: IconClipboardList, requiredPermissions: ["events:read"], }, + { + title: "Experiments", + url: "/dashboard/settings/experiments", + icon: IconFlask, + requiredPermissions: ["settings:manage"], + }, ]; export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b04b1b8..a6ce249 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -338,6 +338,25 @@ export function deleteLogo() { return apiFetch("/admin/settings/logo", { method: "DELETE" }) } +// Feature Flags +export type FeatureFlag = { + key: string + name: string + description: string + enabled: boolean +} + +export function getFeatureFlags() { + return apiFetch("/admin/feature-flags") +} + +export function setFeatureFlag(key: string, enabled: boolean) { + if (enabled) { + return upsertSetting(key, "true") + } + return deleteSetting(key) +} + // Proxy config export type ProxyConfig = { mode: "disabled" | "open" | "allowlist" | "blocklist" -- 2.51.2 From 9ee009fa3fe2dbcbb86ad741eb96172c00ddc25f Mon Sep 17 00:00:00 2001 From: Trezy Date: Fri, 1 May 2026 11:37:42 -0500 Subject: [PATCH 14/17] fix: remove public record aggregation from permissioned spaces --- src/spaces/db.rs | 116 -------------- src/spaces/mod.rs | 2 - src/spaces/notifications.rs | 86 ----------- src/spaces/routes.rs | 47 ------ src/spaces/sync.rs | 293 ------------------------------------ src/spaces/types.rs | 41 ----- 6 files changed, 585 deletions(-) delete mode 100644 src/spaces/notifications.rs delete mode 100644 src/spaces/sync.rs diff --git a/src/spaces/db.rs b/src/spaces/db.rs index 173a053..5577a50 100644 --- a/src/spaces/db.rs +++ b/src/spaces/db.rs @@ -662,119 +662,3 @@ fn parse_invite_row(r: InviteRow) -> Result { created_at: r.9, }) } - -// --------------------------------------------------------------------------- -// Space Sync State -// --------------------------------------------------------------------------- - -pub async fn get_sync_state( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - space_id: &str, - member_did: &str, -) -> Result, AppError> { - let sql = adapt_sql( - "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE space_id = ? AND member_did = ?", - backend, - ); - - let row: Option = sqlx::query_as(&sql) - .bind(space_id) - .bind(member_did) - .fetch_optional(pool) - .await - .map_err(|e| AppError::Internal(format!("failed to get sync state: {e}")))?; - - row.map(parse_sync_state_row).transpose() -} - -pub async fn upsert_sync_state( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - state: &SpaceSyncState, -) -> Result<(), AppError> { - let sql = match backend { - DatabaseBackend::Sqlite => { - "INSERT OR REPLACE INTO space_sync_state (id, space_id, member_did, cursor, last_synced_at, status, error) VALUES (?, ?, ?, ?, ?, ?, ?)".to_string() - } - DatabaseBackend::Postgres => adapt_sql( - "INSERT INTO space_sync_state (id, space_id, member_did, cursor, last_synced_at, status, error) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (space_id, member_did) DO UPDATE SET cursor = EXCLUDED.cursor, last_synced_at = EXCLUDED.last_synced_at, status = EXCLUDED.status, error = EXCLUDED.error", - backend, - ), - }; - - sqlx::query(&sql) - .bind(&state.id) - .bind(&state.space_id) - .bind(&state.member_did) - .bind(&state.cursor) - .bind(&state.last_synced_at) - .bind(state.status.as_str()) - .bind(&state.error) - .execute(pool) - .await - .map_err(|e| AppError::Internal(format!("failed to upsert sync state: {e}")))?; - - Ok(()) -} - -pub async fn list_sync_states_for_space( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - space_id: &str, -) -> Result, AppError> { - let sql = adapt_sql( - "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE space_id = ? ORDER BY member_did ASC", - backend, - ); - - let rows: Vec = sqlx::query_as(&sql) - .bind(space_id) - .fetch_all(pool) - .await - .map_err(|e| AppError::Internal(format!("failed to list sync states: {e}")))?; - - rows.into_iter().map(parse_sync_state_row).collect() -} - -pub async fn list_pending_syncs( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, -) -> Result, AppError> { - let sql = adapt_sql( - "SELECT id, space_id, member_did, cursor, last_synced_at, status, error FROM space_sync_state WHERE status = 'pending' OR status = 'error' ORDER BY last_synced_at ASC NULLS FIRST LIMIT 50", - backend, - ); - - let rows: Vec = sqlx::query_as(&sql) - .fetch_all(pool) - .await - .map_err(|e| AppError::Internal(format!("failed to list pending syncs: {e}")))?; - - rows.into_iter().map(parse_sync_state_row).collect() -} - -type SyncStateRow = ( - String, - String, - String, - Option, - Option, - String, - Option, -); - -fn parse_sync_state_row(r: SyncStateRow) -> Result { - let status = SyncStatus::parse(&r.5) - .ok_or_else(|| AppError::Internal(format!("invalid sync status: {}", r.5)))?; - - Ok(SpaceSyncState { - id: r.0, - space_id: r.1, - member_did: r.2, - cursor: r.3, - last_synced_at: r.4, - status, - error: r.6, - }) -} diff --git a/src/spaces/mod.rs b/src/spaces/mod.rs index a59c5d0..385f183 100644 --- a/src/spaces/mod.rs +++ b/src/spaces/mod.rs @@ -2,9 +2,7 @@ pub mod auth; pub mod credential; pub mod db; pub mod members; -pub mod notifications; pub mod routes; -pub mod sync; pub mod types; use crate::error::AppError; diff --git a/src/spaces/notifications.rs b/src/spaces/notifications.rs deleted file mode 100644 index 3d17e12..0000000 --- a/src/spaces/notifications.rs +++ /dev/null @@ -1,86 +0,0 @@ -use serde::Deserialize; -use uuid::Uuid; - -use crate::db::DatabaseBackend; -use crate::error::AppError; -use crate::spaces::db; -use crate::spaces::types::*; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WriteNotification { - pub space_uri: String, - pub author_did: String, - pub collection: String, - pub rkey: String, - pub action: WriteAction, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum WriteAction { - Create, - Update, - Delete, -} - -/// Process a write notification by queuing a sync pull for the affected member. -/// -/// This marks the member's sync state as pending so the next sync pass picks it up. -pub async fn handle_write_notification( - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - space_id: &str, - notification: &WriteNotification, -) -> Result<(), AppError> { - let existing = db::get_sync_state(pool, backend, space_id, ¬ification.author_did).await?; - - let state = SpaceSyncState { - id: existing - .map(|s| s.id) - .unwrap_or_else(|| Uuid::new_v4().to_string()), - space_id: space_id.to_string(), - member_did: notification.author_did.clone(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Pending, - error: None, - }; - - db::upsert_sync_state(pool, backend, &state).await?; - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn write_action_deserializes() { - let action: WriteAction = serde_json::from_str("\"create\"").unwrap(); - assert!(matches!(action, WriteAction::Create)); - - let action: WriteAction = serde_json::from_str("\"update\"").unwrap(); - assert!(matches!(action, WriteAction::Update)); - - let action: WriteAction = serde_json::from_str("\"delete\"").unwrap(); - assert!(matches!(action, WriteAction::Delete)); - } - - #[test] - fn write_notification_deserializes() { - let json = r#"{ - "spaceUri": "ats://did:plc:owner/com.example.forum/main", - "authorDid": "did:plc:alice", - "collection": "com.example.forum.post", - "rkey": "3k2abc", - "action": "create" - }"#; - - let notif: WriteNotification = serde_json::from_str(json).unwrap(); - assert_eq!(notif.author_did, "did:plc:alice"); - assert_eq!(notif.collection, "com.example.forum.post"); - assert!(matches!(notif.action, WriteAction::Create)); - } -} diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs index 235e2bb..543707c 100644 --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -146,16 +146,6 @@ struct RefreshCredentialInput { credential: String, } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct WriteNotificationInput { - space_uri: String, - author_did: String, - collection: String, - rkey: String, - action: crate::spaces::notifications::WriteAction, -} - // --------------------------------------------------------------------------- // Route registration // --------------------------------------------------------------------------- @@ -208,11 +198,6 @@ pub fn space_routes() -> Router { &format!("/xrpc/{NS}.space.refreshCredential"), post(refresh_credential), ) - // Notifications - .route( - &format!("/xrpc/{NS}.space.writeNotification"), - post(write_notification), - ) } // --------------------------------------------------------------------------- @@ -948,35 +933,3 @@ async fn refresh_credential( "expiresAt": issued.expires_at, }))) } - -// --------------------------------------------------------------------------- -// Notification handlers -// --------------------------------------------------------------------------- - -async fn write_notification( - State(state): State, - xrpc_claims: XrpcClaims, - Json(input): Json, -) -> Result, AppError> { - let claims = require_auth(&xrpc_claims)?; - let space = resolve_space(&state, &input.space_uri).await?; - require_space_admin(&state, &space, claims.did()).await?; - - let notification = crate::spaces::notifications::WriteNotification { - space_uri: input.space_uri, - author_did: input.author_did, - collection: input.collection, - rkey: input.rkey, - action: input.action, - }; - - crate::spaces::notifications::handle_write_notification( - &state.db, - state.db_backend, - &space.id, - ¬ification, - ) - .await?; - - Ok(Json(serde_json::json!({ "success": true }))) -} diff --git a/src/spaces/sync.rs b/src/spaces/sync.rs deleted file mode 100644 index 40d5f72..0000000 --- a/src/spaces/sync.rs +++ /dev/null @@ -1,293 +0,0 @@ -use uuid::Uuid; - -use crate::db::DatabaseBackend; -use crate::db::now_rfc3339; -use crate::error::AppError; -use crate::profile::resolve_pds_endpoint; -use crate::spaces::types::*; -use crate::spaces::{db, members}; - -/// Sync all members of a space by pulling records from their PDSes. -pub async fn sync_space( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - collections: &[String], -) -> Result { - let resolved = members::resolve_members(pool, backend, space_id).await?; - let mut results = Vec::new(); - - for member in &resolved { - let result = sync_member( - http, - pool, - backend, - plc_url, - space_id, - &member.did, - collections, - ) - .await; - - results.push(MemberSyncResult { - did: member.did.clone(), - records_synced: result.as_ref().map(|r| r.records_synced).unwrap_or(0), - error: result.err().map(|e| e.to_string()), - }); - } - - let total = results.iter().map(|r| r.records_synced).sum(); - - Ok(SyncSpaceResult { - members_processed: results.len(), - total_records_synced: total, - member_results: results, - }) -} - -/// Sync records from a single member's PDS for a given space. -pub async fn sync_member( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - member_did: &str, - collections: &[String], -) -> Result { - let state_id = match db::get_sync_state(pool, backend, space_id, member_did).await? { - Some(s) => s.id, - None => { - let id = Uuid::new_v4().to_string(); - let initial = SpaceSyncState { - id: id.clone(), - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Pending, - error: None, - }; - db::upsert_sync_state(pool, backend, &initial).await?; - id - } - }; - - // Mark as syncing - let syncing_state = SpaceSyncState { - id: state_id.clone(), - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: None, - status: SyncStatus::Syncing, - error: None, - }; - db::upsert_sync_state(pool, backend, &syncing_state).await?; - - let result = pull_member_records( - http, - pool, - backend, - plc_url, - space_id, - member_did, - collections, - ) - .await; - - match result { - Ok(summary) => { - let done = SpaceSyncState { - id: state_id, - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: summary.cursor.clone(), - last_synced_at: Some(now_rfc3339()), - status: SyncStatus::Synced, - error: None, - }; - db::upsert_sync_state(pool, backend, &done).await?; - Ok(summary) - } - Err(e) => { - let err_state = SpaceSyncState { - id: state_id, - space_id: space_id.to_string(), - member_did: member_did.to_string(), - cursor: None, - last_synced_at: Some(now_rfc3339()), - status: SyncStatus::Error, - error: Some(e.to_string()), - }; - db::upsert_sync_state(pool, backend, &err_state).await?; - Err(e) - } - } -} - -async fn pull_member_records( - http: &reqwest::Client, - pool: &sqlx::AnyPool, - backend: DatabaseBackend, - plc_url: &str, - space_id: &str, - member_did: &str, - collections: &[String], -) -> Result { - let pds_url = resolve_pds_endpoint(http, plc_url, member_did).await?; - let mut total_records = 0usize; - let mut last_cursor = None; - - for collection in collections { - let mut cursor: Option = None; - loop { - let (records, next_cursor) = fetch_records_page( - http, - &pds_url, - member_did, - collection, - cursor.as_deref(), - 100, - ) - .await?; - - if records.is_empty() { - break; - } - - for record in &records { - let uri = record["uri"].as_str().unwrap_or(""); - let rkey = extract_rkey(uri); - let cid = record["cid"].as_str().unwrap_or("").to_string(); - let value = record - .get("value") - .cloned() - .unwrap_or(serde_json::Value::Null); - - let space_record_uri = format!("ats://{space_id}/{member_did}/{collection}/{rkey}"); - - let space_record = SpaceRecord { - uri: space_record_uri, - space_id: space_id.to_string(), - author_did: member_did.to_string(), - collection: collection.clone(), - rkey: rkey.to_string(), - record: value, - cid, - indexed_at: now_rfc3339(), - }; - - db::upsert_space_record(pool, backend, &space_record).await?; - total_records += 1; - } - - last_cursor = next_cursor.clone(); - cursor = next_cursor; - - if cursor.is_none() { - break; - } - } - } - - Ok(MemberSyncSummary { - records_synced: total_records, - cursor: last_cursor, - }) -} - -async fn fetch_records_page( - http: &reqwest::Client, - pds_url: &str, - repo: &str, - collection: &str, - cursor: Option<&str>, - limit: u32, -) -> Result<(Vec, Option), AppError> { - let mut url = format!( - "{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit={}", - pds_url.trim_end_matches('/'), - repo, - collection, - limit, - ); - - if let Some(c) = cursor { - url.push_str(&format!("&cursor={c}")); - } - - let resp = http - .get(&url) - .send() - .await - .map_err(|e| AppError::Internal(format!("PDS request failed: {e}")))?; - - if !resp.status().is_success() { - let status = resp.status(); - return Err(AppError::Internal(format!( - "PDS listRecords failed with {status} for {repo}/{collection}" - ))); - } - - let body: serde_json::Value = resp - .json() - .await - .map_err(|e| AppError::Internal(format!("invalid PDS response: {e}")))?; - - let records = body["records"].as_array().cloned().unwrap_or_default(); - - let next_cursor = body["cursor"].as_str().map(|s| s.to_string()); - - Ok((records, next_cursor)) -} - -fn extract_rkey(uri: &str) -> &str { - uri.rsplit('/').next().unwrap_or("") -} - -// --------------------------------------------------------------------------- -// Result types -// --------------------------------------------------------------------------- - -pub struct SyncSpaceResult { - pub members_processed: usize, - pub total_records_synced: usize, - pub member_results: Vec, -} - -pub struct MemberSyncResult { - pub did: String, - pub records_synced: usize, - pub error: Option, -} - -pub struct MemberSyncSummary { - pub records_synced: usize, - pub cursor: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_rkey_from_at_uri() { - assert_eq!( - extract_rkey("at://did:plc:abc/app.bsky.feed.post/3k2abc"), - "3k2abc" - ); - } - - #[test] - fn extract_rkey_from_empty() { - assert_eq!(extract_rkey(""), ""); - } - - #[test] - fn extract_rkey_no_slash() { - assert_eq!(extract_rkey("singlevalue"), "singlevalue"); - } -} diff --git a/src/spaces/types.rs b/src/spaces/types.rs index 3d55ebb..c62c116 100644 --- a/src/spaces/types.rs +++ b/src/spaces/types.rs @@ -139,47 +139,6 @@ pub struct SpaceInvite { pub created_at: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SyncStatus { - Pending, - Syncing, - Synced, - Error, -} - -impl SyncStatus { - pub fn as_str(&self) -> &'static str { - match self { - SyncStatus::Pending => "pending", - SyncStatus::Syncing => "syncing", - SyncStatus::Synced => "synced", - SyncStatus::Error => "error", - } - } - - pub fn parse(s: &str) -> Option { - match s { - "pending" => Some(SyncStatus::Pending), - "syncing" => Some(SyncStatus::Syncing), - "synced" => Some(SyncStatus::Synced), - "error" => Some(SyncStatus::Error), - _ => None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SpaceSyncState { - pub id: String, - pub space_id: String, - pub member_did: String, - pub cursor: Option, - pub last_synced_at: Option, - pub status: SyncStatus, - pub error: Option, -} - #[cfg(test)] mod tests { use super::*; -- 2.51.2 From 7b87c36ffd9458942c321009b4a39013abb9cff7 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 4 May 2026 23:30:14 -0500 Subject: [PATCH 15/17] feat: update JS SDKs to more closely match their @atproto cousins --- .../docs/getting-started/authentication.md | 9 +- .../docs/docs/guides/features/api-clients.md | 30 +- packages/docs/docs/sdk/lex-agent.md | 5 +- .../docs/docs/sdk/oauth-client-browser.md | 99 ++++-- packages/docs/docs/sdk/overview.md | 9 +- packages/lex-agent/README.md | 3 +- packages/oauth-client-browser/README.md | 34 +- .../src/__tests__/browser-client.test.ts | 306 +++++++++++++++++- .../src/browser-client.ts | 244 +++++++++++++- packages/oauth-client-browser/src/index.ts | 8 +- packages/oauth-client/src/client.ts | 2 +- packages/oauth-client/src/index.ts | 2 +- 12 files changed, 669 insertions(+), 82 deletions(-) diff --git a/packages/docs/docs/getting-started/authentication.md b/packages/docs/docs/getting-started/authentication.md index edc2416..0ae4a2a 100644 --- a/packages/docs/docs/getting-started/authentication.md +++ b/packages/docs/docs/getting-started/authentication.md @@ -67,11 +67,12 @@ const oauthClient = new HappyViewBrowserClient({ clientKey: "hvc_your_client_key", }); -// Login — redirects to the user's PDS for authorization -await oauthClient.login("alice.bsky.social"); +// Sign in — redirects to the user's PDS for authorization +await oauthClient.signIn("alice.bsky.social"); -// On /oauth/callback — complete the token exchange -const session = await oauthClient.callback(); +// On page load — restore session or process OAuth callback +const result = await oauthClient.init(); +const session = result?.session; // Create a type-safe Lex client const agent = createAgent(session); diff --git a/packages/docs/docs/guides/features/api-clients.md b/packages/docs/docs/guides/features/api-clients.md index cfdfbe9..274c226 100644 --- a/packages/docs/docs/guides/features/api-clients.md +++ b/packages/docs/docs/guides/features/api-clients.md @@ -116,28 +116,22 @@ const client = new HappyViewBrowserClient({ clientKey: "hvc_your_client_key", }); -// Login — redirects to the user's PDS -await client.login("alice.bsky.social"); +// Sign in — redirects to the user's PDS +await client.signIn("alice.bsky.social"); ``` -On your callback page: +On page load, restore a session or process the OAuth callback: ```typescript -const session = await client.callback(); - -// Make authenticated requests -const response = await session.fetchHandler( - "/xrpc/com.example.getStuff?limit=10", - { method: "GET" }, -); -``` - -On subsequent page loads, restore the session from localStorage: - -```typescript -const session = await client.restore(); -if (session) { - // User is still logged in +const result = await client.init(); +if (result) { + const { session } = result; + + // Make authenticated requests + const response = await session.fetchHandler( + "/xrpc/com.example.getStuff?limit=10", + { method: "GET" }, + ); } ``` diff --git a/packages/docs/docs/sdk/lex-agent.md b/packages/docs/docs/sdk/lex-agent.md index cd6ad43..0119846 100644 --- a/packages/docs/docs/sdk/lex-agent.md +++ b/packages/docs/docs/sdk/lex-agent.md @@ -25,8 +25,9 @@ const client = new HappyViewBrowserClient({ clientKey: "hvc_your_client_key", }); -// Authenticate (or restore a session) -const session = await client.restore(); +// Restore an existing session +const result = await client.init(); +const session = result?.session; // Create a Lex agent from the session const agent = createAgent(session); diff --git a/packages/docs/docs/sdk/oauth-client-browser.md b/packages/docs/docs/sdk/oauth-client-browser.md index 59a6fa6..c21b21e 100644 --- a/packages/docs/docs/sdk/oauth-client-browser.md +++ b/packages/docs/docs/sdk/oauth-client-browser.md @@ -47,26 +47,45 @@ const client = new HappyViewBrowserClient({ The API client must be registered as a **public** client (no secret) with your app's origin in `allowed_origins`. See [Authentication — API clients](../getting-started/authentication.md#api-clients-confidential-vs-public). ::: -## Login +## Sign in -`login()` resolves the user's handle, discovers their PDS, provisions a DPoP key, and redirects the browser to the PDS authorization server: +`signIn()` resolves the user's handle, discovers their PDS, provisions a DPoP key, and redirects the browser to the PDS authorization server: ```typescript -await client.login("alice.bsky.social"); +await client.signIn("alice.bsky.social"); // Browser redirects — code stops here ``` -If you need the authorization URL without redirecting (e.g., for a popup or custom UI), use `prepareLogin()`: +To sign in via a popup window instead: + +```typescript +const session = await client.signIn("alice.bsky.social", { + display: "popup", +}); +``` + +Or use the explicit methods: + +```typescript +// Full-page redirect (equivalent to signIn without display option) +await client.signInRedirect("alice.bsky.social"); + +// Popup window +const session = await client.signInPopup("alice.bsky.social"); +``` + +If you need the authorization URL without redirecting (e.g., for a custom UI), use `prepareLogin()`: ```typescript const { authorizationUrl, did, state } = await client.prepareLogin("alice.bsky.social"); - -// Open in a popup, new tab, etc. -window.open(authorizationUrl); ``` -### What happens during login +:::note +`login()` still works as an alias for `signInRedirect()`. +::: + +### What happens during sign in 1. The handle is resolved to a DID via `resolveHandleToDid`. 2. The DID document is fetched to find the PDS URL. @@ -74,32 +93,60 @@ window.open(authorizationUrl); 4. A DPoP key is provisioned from HappyView. 5. PKCE challenge/verifier pairs are generated (one for HappyView's DPoP provisioning, one for the PDS authorization server). 6. The pending auth state is stored in localStorage. -7. The browser is redirected to the PDS authorization endpoint. +7. The browser is redirected to the PDS authorization endpoint (or a popup is opened). -## OAuth callback +## Initialization -Your app needs an `/oauth/callback` route. On that page, call `callback()` to complete the token exchange: +On page load, call `init()` to automatically handle both session restoration and OAuth callbacks: ```typescript -// On /oauth/callback -const session = await client.callback(); -// Session is now stored in localStorage and ready to use +const result = await client.init(); +if (result) { + const { session, state } = result; + // session is ready to use +} ``` -`callback()` reads the `code` and `state` from the URL query string, exchanges the code for tokens at the PDS token endpoint, and registers the session with HappyView. The pending auth state is cleaned up automatically. +`init()` checks the URL for OAuth callback parameters. If found, it processes the callback and returns `{ session, state }`. Otherwise, it tries to restore the last active session from localStorage. -## Restore session - -On subsequent page loads, restore the session from localStorage instead of re-authenticating: +For more control, use the specific methods: ```typescript -const session = await client.restore(); -if (session) { - // User is still logged in +// Restore only — ignores callback params in the URL +const result = await client.initRestore(); +if (result) { + const { session } = result; } + +// Callback only — throws if no callback params are present +const { session, state } = await client.initCallback(); ``` -Returns `null` if no stored session is found. +### Restoring a specific session + +To restore a specific user's session by DID: + +```typescript +const session = await client.restore("did:plc:abc123"); +``` + +Calling `restore()` with no arguments returns the last active session, or `null` if none is found. + +:::note +`callback()` still works as a standalone method that processes the OAuth callback and returns a session directly. +::: + +## Detecting callback params + +`readCallbackParams()` checks the current URL for OAuth callback parameters without processing them. This is useful when your app uses client-side routing and needs to detect callbacks before the router changes the URL: + +```typescript +const params = client.readCallbackParams(); +if (params) { + // URL contains OAuth callback params — process them + const { session } = await client.initCallback(); +} +``` ## Authenticated requests @@ -116,12 +163,16 @@ const data = await response.json(); Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). -## Logout +## Revoke session ```typescript -await client.logout(session.did); +await client.revoke(session.did); ``` +:::note +`logout()` still works as an alias for `revoke()`. +::: + ## Resolution utilities The browser client exports the resolution functions it uses internally. These are useful if you need to resolve handles or discover PDS URLs outside of the login flow: diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md index 39f53e1..d2efce2 100644 --- a/packages/docs/docs/sdk/overview.md +++ b/packages/docs/docs/sdk/overview.md @@ -43,11 +43,12 @@ const oauthClient = new HappyViewBrowserClient({ clientKey: "hvc_your_client_key", }); -// Login — redirects to the user's PDS -await oauthClient.login("alice.bsky.social"); +// Sign in — redirects to the user's PDS +await oauthClient.signIn("alice.bsky.social"); -// On /oauth/callback — complete the flow -const session = await oauthClient.callback(); +// On page load — restore session or process callback +const result = await oauthClient.init(); +const session = result?.session; // Create a type-safe Lex client const agent = createAgent(session); diff --git a/packages/lex-agent/README.md b/packages/lex-agent/README.md index c545a02..9c9e5c0 100644 --- a/packages/lex-agent/README.md +++ b/packages/lex-agent/README.md @@ -24,7 +24,8 @@ const client = new HappyViewBrowserClient({ instanceUrl: "https://happyview.example.com", clientKey: "hvc_your_client_key", }); -const session = await client.restore(); +const result = await client.init(); +const session = result?.session; // Create a Lex agent from the session const agent = createAgent(session); diff --git a/packages/oauth-client-browser/README.md b/packages/oauth-client-browser/README.md index b9e7a6c..74be821 100644 --- a/packages/oauth-client-browser/README.md +++ b/packages/oauth-client-browser/README.md @@ -23,39 +23,39 @@ const client = new HappyViewBrowserClient({ }); ``` -### Login +### Sign In Redirects the user to their PDS authorization server: ```typescript -await client.login("alice.bsky.social"); +await client.signIn("alice.bsky.social"); // User is redirected to their PDS for authorization ``` -If you need the authorization URL without an immediate redirect (e.g., to open in a popup), use `prepareLogin`: +Or sign in via a popup: ```typescript -const { authorizationUrl, did, state } = - await client.prepareLogin("alice.bsky.social"); +const session = await client.signIn("alice.bsky.social", { + display: "popup", +}); ``` -### OAuth Callback - -On the `/oauth/callback` route, call `callback()` to complete the token exchange: +If you need the authorization URL without an immediate redirect, use `prepareLogin`: ```typescript -const session = await client.callback(); -// Session is now stored in localStorage +const { authorizationUrl, did, state } = + await client.prepareLogin("alice.bsky.social"); ``` -### Restore Session +### Initialization -On subsequent page loads, restore the session from localStorage: +On page load, call `init()` to restore a session or process an OAuth callback: ```typescript -const session = await client.restore(); -if (session) { - // User is still logged in +const result = await client.init(); +if (result) { + const { session } = result; + // User is logged in } ``` @@ -70,10 +70,10 @@ const response = await session.fetchHandler( ); ``` -### Logout +### Revoke Session ```typescript -await client.logout("did:plc:abc123"); +await client.revoke("did:plc:abc123"); ``` ## Exports diff --git a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts index 3f9cf3e..d6e26c1 100644 --- a/packages/oauth-client-browser/src/__tests__/browser-client.test.ts +++ b/packages/oauth-client-browser/src/__tests__/browser-client.test.ts @@ -4,7 +4,10 @@ import { TokenExchangeError, type StorageAdapter, } from "@happyview/oauth-client"; -import { HappyViewBrowserClient } from "../browser-client"; +import { + HappyViewBrowserClient, + LoginContinuedInParentWindowError, +} from "../browser-client"; import { LocalStorageAdapter } from "../local-storage-adapter"; // Generate a real ES256 JWK once for all tests that need importJwk to succeed @@ -349,6 +352,56 @@ describe("HappyViewBrowserClient", () => { expect(payload.htu).toBe("https://pds.example.com/oauth/token"); }); + test("prepareLogin uses constructor scopes by default", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + scopes: "atproto transition:generic", + storage: new LocalStorageAdapter(), + fetch: fetchFn, + }); + + await client.prepareLogin("user.bsky.social"); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe("atproto transition:generic"); + }); + + test("prepareLogin accepts per-call scope override", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + scopes: "atproto", + storage: new LocalStorageAdapter(), + fetch: fetchFn, + }); + + await client.prepareLogin("user.bsky.social", { + scopes: "atproto transition:generic repo:app.example.post", + }); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + const body = new URLSearchParams( + (parCall![1] as RequestInit).body as string, + ); + expect(body.get("scope")).toBe( + "atproto transition:generic repo:app.example.post", + ); + }); + test("callback throws InvalidStateError when code or state is missing", async () => { const client = createClient(); try { @@ -477,4 +530,255 @@ describe("HappyViewBrowserClient", () => { localStorage.getItem("@happyview/oauth(happyview:last-active-did)"), ).toBeNull(); }); + + test("revoke is an alias for logout", async () => { + const deleteFn = mock( + async (input: RequestInfo | URL, init?: RequestInit) => { + return new Response(null, { status: 204 }); + }, + ); + const client = createClient(deleteFn); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + + await client.revoke("did:plc:abcdefghijklmnopqrstuvwx"); + + expect( + localStorage.getItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + ), + ).toBeNull(); + }); + + test("restore with no args returns last active session", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore(); + expect(session).not.toBeNull(); + expect(session!.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("restore with DID arg returns that specific session", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:specific)", + JSON.stringify({ + did: "did:plc:specific", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const session = await client.restore("did:plc:specific"); + expect(session).not.toBeNull(); + expect(session!.did).toBe("did:plc:specific"); + }); + + test("restore with DID arg updates last active DID", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:specific)", + JSON.stringify({ + did: "did:plc:specific", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + await client.restore("did:plc:specific"); + + expect( + localStorage.getItem("@happyview/oauth(happyview:last-active-did)"), + ).toBe("did:plc:specific"); + }); + + test("restore with DID arg does not update last active when session missing", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:original", + ); + + await client.restore("did:plc:nonexistent"); + + expect( + localStorage.getItem("@happyview/oauth(happyview:last-active-did)"), + ).toBe("did:plc:original"); + }); + + test("restore with DID arg returns null when session does not exist", async () => { + const client = createClient(); + const session = await client.restore("did:plc:nonexistent"); + expect(session).toBeNull(); + }); + + test("initRestore returns session wrapper when last active exists", async () => { + const client = createClient(); + + localStorage.setItem( + "@happyview/oauth(happyview:last-active-did)", + "did:plc:abcdefghijklmnopqrstuvwx", + ); + localStorage.setItem( + "@happyview/oauth(happyview:session:did:plc:abcdefghijklmnopqrstuvwx)", + JSON.stringify({ + did: "did:plc:abcdefghijklmnopqrstuvwx", + dpopKey: testJwk, + accessToken: "at_stored", + clientKey: "hvc_test", + instanceUrl: "https://happyview.example.com", + }), + ); + + const result = await client.initRestore(); + expect(result).toBeDefined(); + expect(result!.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + }); + + test("initRestore returns undefined when no session exists", async () => { + const client = createClient(); + const result = await client.initRestore(); + expect(result).toBeUndefined(); + }); + + test("initCallback processes callback and returns session with state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const pendingState = { + did: "did:plc:abcdefghijklmnopqrstuvwx", + provisionId: "hvp_test123", + rawJwk: testJwk, + provisionPkceVerifier: "provision-verifier", + authPkceVerifier: "auth-verifier", + pdsUrl: "https://pds.example.com", + tokenEndpoint: "https://pds.example.com/oauth/token", + state: "initcb_state", + issuer: "https://pds.example.com", + }; + localStorage.setItem( + "@happyview/oauth(pending-auth:initcb_state)", + JSON.stringify(pendingState), + ); + + const result = await client.initCallback( + "?code=auth-code&state=initcb_state", + ); + expect(result.session.did).toBe("did:plc:abcdefghijklmnopqrstuvwx"); + expect(result.state).toBe("initcb_state"); + }); + + test("readCallbackParams returns null when no OAuth params in URL", () => { + const client = createClient(); + const params = client.readCallbackParams(); + expect(params).toBeNull(); + }); + + test("findRedirectUrl returns configured redirectUri", () => { + const client = new HappyViewBrowserClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_test", + redirectUri: "https://myapp.com/callback", + }); + expect(client.findRedirectUrl()).toBe("https://myapp.com/callback"); + }); + + test("findRedirectUrl returns default when no redirectUri configured", () => { + const client = createClient(); + expect(client.findRedirectUrl()).toBe( + `${window.location.origin}/oauth/callback`, + ); + }); + + test("signInRedirect delegates to login", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + // signInRedirect calls login which calls prepareLogin then sets window.location.href + // We can verify it hits the same fetch endpoints as prepareLogin + // Since window.location.href assignment doesn't work in tests, we just verify + // the PAR request was made (proving prepareLogin was called) + await client.signInRedirect("user.bsky.social"); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + }); + + test("signIn defaults to signInRedirect", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + await client.signIn("user.bsky.social"); + + const parCall = fetchFn.mock.calls.find((call: any[]) => + String(call[0]).includes("/oauth/par"), + ); + expect(parCall).toBeDefined(); + }); + + test("prepareLogin accepts custom state", async () => { + const fetchFn = mockFetchForFullFlow(); + const client = createClient(fetchFn); + + const result = await client.prepareLogin("user.bsky.social", { + state: "custom-state-123", + }); + + expect(result.state).toBe("custom-state-123"); + + const stored = localStorage.getItem( + "@happyview/oauth(pending-auth:custom-state-123)", + ); + expect(stored).not.toBeNull(); + }); + + test("dispose does not throw", () => { + const client = createClient(); + expect(() => client.dispose()).not.toThrow(); + }); + + test("LoginContinuedInParentWindowError has correct name and message", () => { + const err = new LoginContinuedInParentWindowError(); + expect(err.name).toBe("LoginContinuedInParentWindowError"); + expect(err.message).toBe("Login continued in parent window"); + expect(err).toBeInstanceOf(Error); + }); }); diff --git a/packages/oauth-client-browser/src/browser-client.ts b/packages/oauth-client-browser/src/browser-client.ts index 86b47db..740cbc8 100644 --- a/packages/oauth-client-browser/src/browser-client.ts +++ b/packages/oauth-client-browser/src/browser-client.ts @@ -4,6 +4,7 @@ import type { DidDocument } from "@atproto/did"; import { HappyViewOAuthClient, HappyViewSession, + LAST_ACTIVE_KEY, importJwk, InvalidStateError, ResolutionError, @@ -12,6 +13,17 @@ import { } from "@happyview/oauth-client"; import { LocalStorageAdapter } from "./local-storage-adapter"; +const NAMESPACE = "@happyview/oauth-client-browser"; +const POPUP_CHANNEL_NAME = `${NAMESPACE}(popup-channel)`; +const POPUP_STATE_PREFIX = `${NAMESPACE}(popup-state):`; + +export class LoginContinuedInParentWindowError extends Error { + constructor() { + super("Login continued in parent window"); + this.name = "LoginContinuedInParentWindowError"; + } +} + export interface HappyViewBrowserClientOptions { instanceUrl: string; clientId: string; @@ -34,6 +46,22 @@ interface PendingAuthState { issuer: string; } +export interface LoginOptions { + scopes?: string; + state?: string; +} + +export interface PopupLoginOptions extends LoginOptions { + popupName?: string; + popupFeatures?: string; +} + +export interface SignInOptions extends LoginOptions { + display?: "popup" | "page"; + popupName?: string; + popupFeatures?: string; +} + export interface PrepareLoginResult { authorizationUrl: string; did: string; @@ -74,7 +102,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { this.didResolver = new DidResolverCommon({ fetch: fetchFn }); } - async prepareLogin(handle: string): Promise { + async prepareLogin(handle: string, options?: LoginOptions): Promise { // Resolve handle → DID → DID document → PDS URL → auth server metadata const resolvedDid = await this.handleResolver.resolve(handle); if (!resolvedDid) { @@ -86,6 +114,8 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { const pdsUrl = extractPdsUrl(didDoc); const authMeta = await this.fetchAuthServerMetadata(pdsUrl); + const scopes = options?.scopes ?? this.scopes; + // Provision DPoP key from HappyView const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = await this.provisionDpopKey(); @@ -94,10 +124,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { const authPkceVerifier = generatePkceVerifier(); const authPkceChallenge = await computePkceChallenge(authPkceVerifier); - const stateBytes = crypto.getRandomValues(new Uint8Array(16)); - const state = Array.from(stateBytes, (b) => - b.toString(16).padStart(2, "0"), - ).join(""); + const state = options?.state ?? randomHex(16); const pendingState: PendingAuthState = { did, @@ -122,7 +149,7 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { client_id: clientId, redirect_uri: redirectUri, state, - scope: this.scopes, + scope: scopes, code_challenge: authPkceChallenge, code_challenge_method: "S256", login_hint: handle, @@ -163,8 +190,8 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { return { authorizationUrl, did, state }; } - async login(handle: string): Promise { - const { authorizationUrl } = await this.prepareLogin(handle); + async login(handle: string, options?: LoginOptions): Promise { + const { authorizationUrl } = await this.prepareLogin(handle, options); window.location.href = authorizationUrl; } @@ -294,6 +321,182 @@ export class HappyViewBrowserClient extends HappyViewOAuthClient { await this.deleteSession(did); } + async revoke(did: string): Promise { + return this.logout(did); + } + + override async restore(did?: string): Promise { + if (did) { + const session = await this.restoreSession(did); + if (session) { + await this.storage.set(LAST_ACTIVE_KEY, did); + } + return session; + } + return super.restore(); + } + + async init(): Promise< + | { session: HappyViewSession; state?: string | null } + | undefined + > { + const params = this.readCallbackParams(); + if (params) { + return this.initCallback(`?${params.toString()}`); + } + return this.initRestore(); + } + + async initRestore(): Promise<{ session: HappyViewSession } | undefined> { + const session = await this.restore(); + if (session) return { session }; + return undefined; + } + + async initCallback( + search?: string, + ): Promise<{ session: HappyViewSession; state: string | null }> { + const searchStr = search ?? window.location.search; + const params = new URLSearchParams(searchStr); + const state = params.get("state"); + + history.replaceState(null, "", window.location.pathname); + + const session = await this.callback(searchStr); + + if (state?.startsWith(POPUP_STATE_PREFIX)) { + const stateKey = state.slice(POPUP_STATE_PREFIX.length); + const received = await sendPopupResult(stateKey, { + status: "fulfilled", + value: session.did, + }); + if (!received) { + await this.logout(session.did); + } + window.close(); + throw new LoginContinuedInParentWindowError(); + } + + return { session, state }; + } + + async signIn( + handle: string, + options?: SignInOptions, + ): Promise { + if (options?.display === "popup") { + return this.signInPopup(handle, options); + } + return this.signInRedirect(handle, options); + } + + async signInRedirect( + handle: string, + options?: LoginOptions, + ): Promise { + return this.login(handle, options); + } + + async signInPopup( + handle: string, + options?: PopupLoginOptions, + ): Promise { + const popupTarget = options?.popupName ?? "_blank"; + const popupFeatures = + options?.popupFeatures ?? + "width=600,height=600,menubar=no,toolbar=no"; + + let popup = window.open("about:blank", popupTarget, popupFeatures); + + const stateKey = Math.random().toString(36).slice(2); + const result = await this.prepareLogin(handle, { + ...options, + state: `${POPUP_STATE_PREFIX}${stateKey}`, + }); + + if (popup) { + popup.location.href = result.authorizationUrl; + } else { + popup = window.open( + result.authorizationUrl, + popupTarget, + popupFeatures, + ); + } + popup?.focus(); + + return new Promise((resolve, reject) => { + const channel = new BroadcastChannel(POPUP_CHANNEL_NAME); + const cleanup = () => { + clearTimeout(timeout); + channel.removeEventListener("message", onMessage); + channel.close(); + popup?.close(); + }; + + const timeout = setTimeout(() => { + reject(new Error("Popup login timed out")); + cleanup(); + }, 5 * 60e3); + + const onMessage = async ({ data }: MessageEvent) => { + if (data.key !== stateKey) return; + if (!("result" in data)) return; + + channel.postMessage({ key: stateKey, ack: true }); + cleanup(); + + if (data.result.status === "fulfilled") { + const did = data.result.value as string; + try { + const session = await this.restoreSession(did); + if (session) { + resolve(session); + } else { + reject( + new Error( + "Failed to restore session after popup login", + ), + ); + } + } catch (err) { + reject(err); + await this.logout(did); + } + } else { + reject( + new Error( + data.result.reason?.message ?? "Popup login failed", + ), + ); + } + }; + + channel.addEventListener("message", onMessage); + }); + } + + readCallbackParams(): URLSearchParams | null { + const params = new URLSearchParams(window.location.search); + if ( + !params.has("state") || + !(params.has("code") || params.has("error")) + ) { + return null; + } + return params; + } + + findRedirectUrl(): string { + return ( + this.redirectUri ?? `${window.location.origin}/oauth/callback` + ); + } + + dispose(): void { + // No persistent resources to clean up + } + private resolveOAuthEndpoints(): { clientId: string; redirectUri: string } { return { clientId: this.clientId, @@ -373,6 +576,31 @@ function generatePkceVerifier(): string { .replace(/=+$/, ""); } +function sendPopupResult( + key: string, + result: { + status: "fulfilled" | "rejected"; + value?: string; + reason?: { message: string }; + }, +): Promise { + const channel = new BroadcastChannel(POPUP_CHANNEL_NAME); + return new Promise((resolve) => { + const cleanup = (received: boolean) => { + clearTimeout(timer); + channel.removeEventListener("message", onMessage); + channel.close(); + resolve(received); + }; + const onMessage = ({ data }: MessageEvent) => { + if ("ack" in data && data.key === key) cleanup(true); + }; + channel.addEventListener("message", onMessage); + channel.postMessage({ key, result }); + const timer = setTimeout(() => cleanup(false), 500); + }); +} + async function computePkceChallenge(verifier: string): Promise { const hash = await crypto.subtle.digest( "SHA-256", diff --git a/packages/oauth-client-browser/src/index.ts b/packages/oauth-client-browser/src/index.ts index a0fc391..b581a9f 100644 --- a/packages/oauth-client-browser/src/index.ts +++ b/packages/oauth-client-browser/src/index.ts @@ -15,9 +15,15 @@ export { type StoredSession, } from "@happyview/oauth-client"; -export { HappyViewBrowserClient } from "./browser-client"; +export { + HappyViewBrowserClient, + LoginContinuedInParentWindowError, +} from "./browser-client"; export type { HappyViewBrowserClientOptions, + LoginOptions, + PopupLoginOptions, PrepareLoginResult, + SignInOptions, } from "./browser-client"; export { LocalStorageAdapter } from "./local-storage-adapter"; diff --git a/packages/oauth-client/src/client.ts b/packages/oauth-client/src/client.ts index c74c17d..ba753bb 100644 --- a/packages/oauth-client/src/client.ts +++ b/packages/oauth-client/src/client.ts @@ -13,7 +13,7 @@ import type { } from "./types"; const STORAGE_PREFIX = "happyview:session:"; -const LAST_ACTIVE_KEY = "happyview:last-active-did"; +export const LAST_ACTIVE_KEY = "happyview:last-active-did"; export class HappyViewOAuthClient { protected readonly instanceUrl: string; diff --git a/packages/oauth-client/src/index.ts b/packages/oauth-client/src/index.ts index bf772a0..1275cba 100644 --- a/packages/oauth-client/src/index.ts +++ b/packages/oauth-client/src/index.ts @@ -1,4 +1,4 @@ -export { HappyViewOAuthClient } from "./client"; +export { HappyViewOAuthClient, LAST_ACTIVE_KEY } from "./client"; export { importJwk } from "./import-jwk"; export { ApiError, -- 2.51.2 From 63f36127c851b4b206476b316dba5ca8b9cc9ca1 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 4 May 2026 23:31:32 -0500 Subject: [PATCH 16/17] docs: bring Statusphere tutorial up-to-date --- packages/docs/docs/tutorials/statusphere.md | 121 +++++++++++++------- 1 file changed, 77 insertions(+), 44 deletions(-) diff --git a/packages/docs/docs/tutorials/statusphere.md b/packages/docs/docs/tutorials/statusphere.md index 675ccb9..5b9983d 100644 --- a/packages/docs/docs/tutorials/statusphere.md +++ b/packages/docs/docs/tutorials/statusphere.md @@ -71,57 +71,49 @@ Once the backfill starts, you should see records appearing in the dashboard: 2. Go to **Records** to browse individual indexed statuses 3. Go to **Backfill** to watch the backfill job progress — you'll see the number of repos processed and records fetched -## Step 3: Add a query lexicon for listing statuses +## Step 3: Create an API client -Now add a query endpoint to read the indexed data: +Before you can call any XRPC endpoint, you need an [API client](../guides/features/api-clients.md). The client key identifies your application to HappyView and is required on every request. -1. Go to **Lexicons > Add Lexicon > Local** -2. Set the NSID to `xyz.statusphere.listStatuses` -3. Set the type to **Query** -4. Set `target_collection` to `xyz.statusphere.status` — this tells the query which record collection it operates on (`target_collection` is a HappyView-specific field, not part of the lexicon spec) -5. Click **Add** +1. Go to **Settings > API Clients > New client** +2. Set the **Name** to something like "Statusphere Dev" +3. Set the **Client ID URL** and **Client URI** to your app's URL (for local testing, `http://localhost:3000` works) +4. Add a **Redirect URI** (e.g. `http://localhost:3000/oauth/callback`) +5. Click **Create** -This creates a `GET /xrpc/xyz.statusphere.listStatuses` endpoint. Without a Lua script, it uses HappyView's built-in default behavior: listing records with `limit`, `cursor`, and `did` parameters, or fetching a single record by `uri`. Try it: +Copy the `hvc_`-prefixed **client key** — you'll use it in every request. If you created a confidential client, also save the `hvs_`-prefixed **client secret** immediately; it's only shown once. -```sh -curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?limit=5" \ - -H "X-Client-Key: $CLIENT_KEY" -``` +For the rest of this tutorial, we'll use `$CLIENT_KEY` to refer to your client key. + +## Step 4: Add a query endpoint for listing statuses + +Now add a query endpoint to read the indexed data: + +1. Go to **Lexicons > Add Lexicon > Local** +2. In the JSON editor, set the `id` to `xyz.statusphere.listStatuses` and change the type to `query`: ```json { - "records": [ - { - "uri": "at://did:plc:abc/xyz.statusphere.status/3abc123", - "status": "\ud83d\ude0a", - "createdAt": "2025-01-01T12:00:00Z" - }, - { - "uri": "at://did:plc:def/xyz.statusphere.status/3def456", - "status": "\ud83c\udf1f", - "createdAt": "2025-01-01T11:30:00Z" + "lexicon": 1, + "id": "xyz.statusphere.listStatuses", + "defs": { + "main": { + "type": "query" } - ], - "cursor": "MjAyNS0wMS0wMVQxMjowMDowMFp8YXQ6Ly9kaWQ6..." + } } ``` -See [XRPC API](../reference/xrpc-api.md) for the full default query behavior. - -## Step 4: Customize the query with a Lua script - -The default query behavior works, but let's customize it with a [Lua script](../guides/scripting.md). The script will handle single-record lookups by URI and paginated listing with an optional DID filter. - -1. Click on **xyz.statusphere.listStatuses** in the lexicon list to open its detail page -2. The Lua script editor is at the bottom of the page -3. Paste in the following script: +3. A [Lua script](../guides/scripting.md) editor appears automatically. Replace the default script with: ```lua +collection = "xyz.statusphere.status" + function handle() if params.uri then local record = db.get(params.uri) if not record then - return { error = "not found" } + error("record not found") end return { record = record } end @@ -135,9 +127,36 @@ function handle() end ``` -4. Click **Save** +The `collection` variable at the top tells the script which record collection to query. The `handle()` function supports single-record lookups by URI and paginated listing with an optional DID filter. -The endpoint now uses your custom logic. Filter by a specific user: +4. Click **Upload** + +Try it out: + +```sh +curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?limit=5" \ + -H "X-Client-Key: $CLIENT_KEY" +``` + +```json +{ + "records": [ + { + "uri": "at://did:plc:abc/xyz.statusphere.status/3abc123", + "status": "😊", + "createdAt": "2025-01-01T12:00:00Z" + }, + { + "uri": "at://did:plc:def/xyz.statusphere.status/3def456", + "status": "🌟", + "createdAt": "2025-01-01T11:30:00Z" + } + ], + "cursor": "MjAyNS0wMS0wMVQxMjowMDowMFp8YXQ6Ly9kaWQ6..." +} +``` + +Filter by a specific user: ```sh curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?did=did:plc:abc&limit=1" \ @@ -151,17 +170,30 @@ curl "http://127.0.0.1:3000/xrpc/xyz.statusphere.listStatuses?uri=at://did:plc:a -H "X-Client-Key: $CLIENT_KEY" ``` -## Step 5: Add a procedure lexicon for setting status +## Step 5: Add a procedure endpoint for setting status Add a write endpoint so users can set their status through your AppView: 1. Go to **Lexicons > Add Lexicon > Local** -2. Set the NSID to `xyz.statusphere.setStatus` -3. Set the type to **Procedure** -4. Set `target_collection` to `xyz.statusphere.status` -5. A default Lua script is generated — replace it with: +2. In the JSON editor, set the `id` to `xyz.statusphere.setStatus` and change the type to `procedure`: + +```json +{ + "lexicon": 1, + "id": "xyz.statusphere.setStatus", + "defs": { + "main": { + "type": "procedure" + } + } +} +``` + +3. A default Lua script is generated — replace it with: ```lua +collection = "xyz.statusphere.status" + function handle() local r = Record(collection, { status = input.status, @@ -172,7 +204,7 @@ function handle() end ``` -6. Click **Add** +4. Click **Upload** This creates a `POST /xrpc/xyz.statusphere.setStatus` endpoint that creates records on the user's PDS and indexes them locally. @@ -186,7 +218,7 @@ curl -X POST http://127.0.0.1:3000/xrpc/xyz.statusphere.setStatus \ -H "Authorization: DPoP $TOKEN" \ -H "DPoP: $DPOP_PROOF" \ -H "Content-Type: application/json" \ - -d '{ "status": "\ud83d\ude80" }' + -d '{ "status": "🚀" }' ``` ```json @@ -211,8 +243,9 @@ Everything was done through the dashboard — no server restarts, no config file ## Next steps +- [API Clients](../guides/features/api-clients.md): Public vs. confidential clients, DPoP authentication, and rate limiting - [Lua Scripting](../guides/scripting.md): Explore the full Record and database APIs to build more complex queries -- [Lexicons](../guides/indexing/lexicons.md): Learn about network lexicons, the backfill flag, and target collections +- [Lexicons](../guides/indexing/lexicons.md): Learn about network lexicons, the backfill flag, and record collections - [XRPC API](../reference/xrpc-api.md): Understand how the generated endpoints behave - [Admin API](../reference/admin/admin-api.md): Automate lexicon management via the API - [Statusphere example app](https://github.com/bluesky-social/statusphere-example-app): See the full Statusphere frontend -- 2.51.2 From 6b5e9c16af7ca967562247c0f404ab8ad2d72fed Mon Sep 17 00:00:00 2001 From: Trezy Date: Wed, 6 May 2026 13:40:27 -0500 Subject: [PATCH 17/17] docs: add info for use with @atproto/api --- packages/docs/docs/sdk/overview.md | 2 ++ packages/oauth-client-browser/README.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md index d2efce2..884b27f 100644 --- a/packages/docs/docs/sdk/overview.md +++ b/packages/docs/docs/sdk/overview.md @@ -12,6 +12,8 @@ HappyView provides JavaScript packages for building third-party apps that authen **Starting a new app?** Use `@happyview/lex-agent` with `@atproto/lex`. It gives you type-safe XRPC calls through a `Client` that routes requests to your HappyView instance with DPoP authentication. This is the recommended way to interact with HappyView from JavaScript. +**Already using `@atproto/api`?** `HappyViewSession` works directly as a session manager for `@atproto/api`'s `Agent` — just pass it to `new Agent(session)`. See [Using with @atproto/api](./oauth-client-browser.md#using-with-atprotoapi). + **Already using `@atproto/oauth-client-browser`?** Add `@happyview/oauth-client-browser` to get a `HappyViewBrowserClient` that handles the HappyView-specific DPoP key provisioning and session registration on top of the standard atproto OAuth flow. **Building a server-side app or something more custom?** Use `@happyview/oauth-client` directly and provide your own `CryptoAdapter` and `StorageAdapter`. diff --git a/packages/oauth-client-browser/README.md b/packages/oauth-client-browser/README.md index 74be821..1db1709 100644 --- a/packages/oauth-client-browser/README.md +++ b/packages/oauth-client-browser/README.md @@ -70,6 +70,20 @@ const response = await session.fetchHandler( ); ``` +### Using with @atproto/api + +`HappyViewSession` works directly with `@atproto/api`'s `Agent`: + +```typescript +import { Agent } from "@atproto/api"; + +const result = await client.init(); +if (result) { + const agent = new Agent(result.session); + const profile = await agent.getProfile({ actor: agent.did }); +} +``` + ### Revoke Session ```typescript -- 2.51.2