···11+CREATE TABLE IF NOT EXISTS domains (
22+ id TEXT PRIMARY KEY,
33+ url TEXT NOT NULL UNIQUE,
44+ is_primary INTEGER NOT NULL DEFAULT 0,
55+ created_at TEXT NOT NULL,
66+ updated_at TEXT NOT NULL
77+);
···11+CREATE TABLE IF NOT EXISTS domains (
22+ id TEXT PRIMARY KEY,
33+ url TEXT NOT NULL UNIQUE,
44+ is_primary INTEGER NOT NULL DEFAULT 0,
55+ created_at TEXT NOT NULL,
66+ updated_at TEXT NOT NULL
77+);
+8-8
src/admin/api_clients.rs
···9494 if let (Some(capacity), Some(refill_rate)) =
9595 (body.rate_limit_capacity, body.rate_limit_refill_rate)
9696 {
9797- let global = state.rate_limiter.global_config();
9797+ let defaults = state.rate_limiter.defaults();
9898 state.rate_limiter.register_client_config(
9999 client_key.clone(),
100100 crate::rate_limit::RateLimitConfig {
101101 capacity: capacity as u32,
102102 refill_rate,
103103- default_query_cost: global.default_query_cost,
104104- default_procedure_cost: global.default_procedure_cost,
105105- default_proxy_cost: global.default_proxy_cost,
103103+ default_query_cost: defaults.query_cost,
104104+ default_procedure_cost: defaults.procedure_cost,
105105+ default_proxy_cost: defaults.proxy_cost,
106106 },
107107 );
108108 }
···397397 },
398398 );
399399 if let (Some(cap), Some(refill)) = (capacity, refill_rate) {
400400- let global = state.rate_limiter.global_config();
400400+ let defaults = state.rate_limiter.defaults();
401401 state.rate_limiter.register_client_config(
402402 client_key,
403403 crate::rate_limit::RateLimitConfig {
404404 capacity: cap as u32,
405405 refill_rate: refill,
406406- default_query_cost: global.default_query_cost,
407407- default_procedure_cost: global.default_procedure_cost,
408408- default_proxy_cost: global.default_proxy_cost,
406406+ default_query_cost: defaults.query_cost,
407407+ default_procedure_cost: defaults.procedure_cost,
408408+ default_proxy_cost: defaults.proxy_cost,
409409 },
410410 );
411411 } else {
+306
src/admin/domains.rs
···11+use axum::Json;
22+use axum::extract::{Path, State};
33+use axum::http::StatusCode;
44+55+use crate::AppState;
66+use crate::db::{adapt_sql, now_rfc3339};
77+use crate::domain::Domain;
88+use crate::error::AppError;
99+use crate::event_log::{EventLog, Severity, log_event};
1010+1111+use super::auth::UserAuth;
1212+use super::permissions::Permission;
1313+use super::types::{CreateDomainBody, DomainResponse};
1414+1515+fn domain_to_response(d: &Domain) -> DomainResponse {
1616+ DomainResponse {
1717+ id: d.id.clone(),
1818+ url: d.url.clone(),
1919+ is_primary: d.is_primary,
2020+ created_at: d.created_at.clone(),
2121+ updated_at: d.updated_at.clone(),
2222+ }
2323+}
2424+2525+/// GET /admin/domains
2626+pub(super) async fn list(
2727+ State(state): State<AppState>,
2828+ auth: UserAuth,
2929+) -> Result<Json<Vec<DomainResponse>>, AppError> {
3030+ auth.require(Permission::SettingsManage).await?;
3131+3232+ let sql = adapt_sql(
3333+ "SELECT id, url, is_primary, created_at, updated_at FROM domains ORDER BY created_at",
3434+ state.db_backend,
3535+ );
3636+ let rows: Vec<(String, String, i32, String, String)> = sqlx::query_as(&sql)
3737+ .fetch_all(&state.db)
3838+ .await
3939+ .map_err(|e| AppError::Internal(format!("failed to list domains: {e}")))?;
4040+4141+ let domains: Vec<DomainResponse> = rows
4242+ .into_iter()
4343+ .map(
4444+ |(id, url, is_primary, created_at, updated_at)| DomainResponse {
4545+ id,
4646+ url,
4747+ is_primary: is_primary != 0,
4848+ created_at,
4949+ updated_at,
5050+ },
5151+ )
5252+ .collect();
5353+5454+ Ok(Json(domains))
5555+}
5656+5757+/// POST /admin/domains
5858+pub(super) async fn create(
5959+ State(state): State<AppState>,
6060+ auth: UserAuth,
6161+ Json(body): Json<CreateDomainBody>,
6262+) -> Result<(StatusCode, Json<DomainResponse>), AppError> {
6363+ auth.require(Permission::SettingsManage).await?;
6464+6565+ let url = body.url.trim_end_matches('/').to_string();
6666+6767+ let parsed =
6868+ reqwest::Url::parse(&url).map_err(|_| AppError::BadRequest("invalid URL".into()))?;
6969+7070+ if parsed.path() != "/" && !parsed.path().is_empty() {
7171+ return Err(AppError::BadRequest("URL must not contain a path".into()));
7272+ }
7373+7474+ if parsed.host_str().is_none() {
7575+ return Err(AppError::BadRequest("URL must contain a host".into()));
7676+ }
7777+7878+ let is_loopback = state.config.public_url.contains("127.0.0.1")
7979+ || state.config.public_url.contains("[::1]")
8080+ || state.config.public_url.contains("localhost");
8181+8282+ if parsed.scheme() != "https" && !is_loopback {
8383+ return Err(AppError::BadRequest("URL scheme must be https".into()));
8484+ }
8585+8686+ // Check for duplicates
8787+ let existing: Option<(String,)> = sqlx::query_as(&adapt_sql(
8888+ "SELECT id FROM domains WHERE url = ?",
8989+ state.db_backend,
9090+ ))
9191+ .bind(&url)
9292+ .fetch_optional(&state.db)
9393+ .await
9494+ .map_err(|e| AppError::Internal(format!("failed to check domain: {e}")))?;
9595+9696+ if existing.is_some() {
9797+ return Err(AppError::BadRequest(format!(
9898+ "domain '{url}' already exists"
9999+ )));
100100+ }
101101+102102+ let id = uuid::Uuid::new_v4().to_string();
103103+ let now = now_rfc3339();
104104+105105+ let sql = adapt_sql(
106106+ "INSERT INTO domains (id, url, is_primary, created_at, updated_at) VALUES (?, ?, 0, ?, ?)",
107107+ state.db_backend,
108108+ );
109109+ sqlx::query(&sql)
110110+ .bind(&id)
111111+ .bind(&url)
112112+ .bind(&now)
113113+ .bind(&now)
114114+ .execute(&state.db)
115115+ .await
116116+ .map_err(|e| AppError::Internal(format!("failed to create domain: {e}")))?;
117117+118118+ let domain = Domain {
119119+ id: id.clone(),
120120+ url: url.clone(),
121121+ is_primary: false,
122122+ created_at: now.clone(),
123123+ updated_at: now,
124124+ };
125125+126126+ // Register the OAuth client for this domain
127127+ state
128128+ .oauth
129129+ .register_domain_client(url.clone(), state.oauth.primary_client());
130130+131131+ // Build a proper OAuth client if not loopback
132132+ let domain_is_loopback =
133133+ url.contains("127.0.0.1") || url.contains("[::1]") || url.contains("localhost");
134134+ if !domain_is_loopback {
135135+ let client_id_url = format!("{}/oauth-client-metadata.json", url.trim_end_matches('/'));
136136+ let callback = format!("{}/auth/callback", url.trim_end_matches('/'));
137137+ if let Err(e) = state.oauth.register_api_client(
138138+ &client_id_url,
139139+ &url,
140140+ vec![callback],
141141+ "atproto",
142142+ &crate::auth::client_registry::ApiClientOAuthParams {
143143+ plc_url: state.config.plc_url.clone(),
144144+ state_store: state.oauth_state_store.clone(),
145145+ session_store_pool: state.db.clone(),
146146+ db_backend: state.db_backend,
147147+ },
148148+ ) {
149149+ tracing::error!(domain = %url, error = %e, "Failed to create OAuth client for domain");
150150+ } else {
151151+ // Move from `clients` (where register_api_client puts it) to domain_clients + clients
152152+ if let Some(client) = state.oauth.get(&client_id_url) {
153153+ state.oauth.remove(&client_id_url);
154154+ state.oauth.register_domain_client(url.clone(), client);
155155+ }
156156+ }
157157+ }
158158+159159+ // Update in-memory cache
160160+ state.domain_cache.insert(domain.clone()).await;
161161+162162+ log_event(
163163+ &state.db,
164164+ EventLog {
165165+ event_type: "domain.created".to_string(),
166166+ severity: Severity::Info,
167167+ actor_did: Some(auth.did.clone()),
168168+ subject: Some(url),
169169+ detail: serde_json::json!({ "id": id }),
170170+ },
171171+ state.db_backend,
172172+ )
173173+ .await;
174174+175175+ let response = domain_to_response(&domain);
176176+ Ok((StatusCode::CREATED, Json(response)))
177177+}
178178+179179+/// DELETE /admin/domains/{id}
180180+pub(super) async fn delete(
181181+ State(state): State<AppState>,
182182+ auth: UserAuth,
183183+ Path(id): Path<String>,
184184+) -> Result<StatusCode, AppError> {
185185+ auth.require(Permission::SettingsManage).await?;
186186+187187+ let sql = adapt_sql(
188188+ "SELECT id, url, is_primary, created_at, updated_at FROM domains WHERE id = ?",
189189+ state.db_backend,
190190+ );
191191+ let row: Option<(String, String, i32, String, String)> = sqlx::query_as(&sql)
192192+ .bind(&id)
193193+ .fetch_optional(&state.db)
194194+ .await
195195+ .map_err(|e| AppError::Internal(format!("failed to find domain: {e}")))?;
196196+197197+ let (_, url, is_primary, _, _) =
198198+ row.ok_or_else(|| AppError::NotFound("domain not found".into()))?;
199199+200200+ if is_primary != 0 {
201201+ return Err(AppError::BadRequest(
202202+ "cannot delete the primary domain — set a different domain as primary first".into(),
203203+ ));
204204+ }
205205+206206+ let delete_sql = adapt_sql("DELETE FROM domains WHERE id = ?", state.db_backend);
207207+ sqlx::query(&delete_sql)
208208+ .bind(&id)
209209+ .execute(&state.db)
210210+ .await
211211+ .map_err(|e| AppError::Internal(format!("failed to delete domain: {e}")))?;
212212+213213+ // Remove OAuth client and cache entry
214214+ state.oauth.remove_domain_client(&url);
215215+ let host = url
216216+ .strip_prefix("https://")
217217+ .or_else(|| url.strip_prefix("http://"))
218218+ .unwrap_or(&url);
219219+ state.domain_cache.remove(host).await;
220220+221221+ log_event(
222222+ &state.db,
223223+ EventLog {
224224+ event_type: "domain.deleted".to_string(),
225225+ severity: Severity::Info,
226226+ actor_did: Some(auth.did.clone()),
227227+ subject: Some(url),
228228+ detail: serde_json::json!({ "id": id }),
229229+ },
230230+ state.db_backend,
231231+ )
232232+ .await;
233233+234234+ Ok(StatusCode::NO_CONTENT)
235235+}
236236+237237+/// POST /admin/domains/{id}/primary
238238+pub(super) async fn set_primary(
239239+ State(state): State<AppState>,
240240+ auth: UserAuth,
241241+ Path(id): Path<String>,
242242+) -> Result<StatusCode, AppError> {
243243+ auth.require(Permission::SettingsManage).await?;
244244+245245+ let sql = adapt_sql(
246246+ "SELECT id, url, is_primary, created_at, updated_at FROM domains WHERE id = ?",
247247+ state.db_backend,
248248+ );
249249+ let row: Option<(String, String, i32, String, String)> = sqlx::query_as(&sql)
250250+ .bind(&id)
251251+ .fetch_optional(&state.db)
252252+ .await
253253+ .map_err(|e| AppError::Internal(format!("failed to find domain: {e}")))?;
254254+255255+ let (_, url, _, _, _) = row.ok_or_else(|| AppError::NotFound("domain not found".into()))?;
256256+257257+ let now = now_rfc3339();
258258+259259+ let unset_sql = adapt_sql(
260260+ "UPDATE domains SET is_primary = 0, updated_at = ? WHERE is_primary = 1",
261261+ state.db_backend,
262262+ );
263263+ sqlx::query(&unset_sql)
264264+ .bind(&now)
265265+ .execute(&state.db)
266266+ .await
267267+ .map_err(|e| AppError::Internal(format!("failed to unset primary: {e}")))?;
268268+269269+ let set_sql = adapt_sql(
270270+ "UPDATE domains SET is_primary = 1, updated_at = ? WHERE id = ?",
271271+ state.db_backend,
272272+ );
273273+ sqlx::query(&set_sql)
274274+ .bind(&now)
275275+ .bind(&id)
276276+ .execute(&state.db)
277277+ .await
278278+ .map_err(|e| AppError::Internal(format!("failed to set primary: {e}")))?;
279279+280280+ // Update cache
281281+ let host = url
282282+ .strip_prefix("https://")
283283+ .or_else(|| url.strip_prefix("http://"))
284284+ .unwrap_or(&url);
285285+ state.domain_cache.set_primary(host).await;
286286+287287+ // Update OAuth primary client
288288+ if let Some(client) = state.oauth.get_domain_client(&url) {
289289+ state.oauth.set_primary_client(client);
290290+ }
291291+292292+ log_event(
293293+ &state.db,
294294+ EventLog {
295295+ event_type: "domain.primary_changed".to_string(),
296296+ severity: Severity::Info,
297297+ actor_did: Some(auth.did.clone()),
298298+ subject: Some(url),
299299+ detail: serde_json::json!({ "id": id }),
300300+ },
301301+ state.db_backend,
302302+ )
303303+ .await;
304304+305305+ Ok(StatusCode::NO_CONTENT)
306306+}
···11+use arc_swap::ArcSwap;
12use dashmap::DashMap;
23use std::sync::Arc;
34···2728/// shows the correct domain. The default client is HappyView's own identity,
2829/// used for dashboard auth.
2930pub struct OAuthClientRegistry {
3030- default_client: Arc<HappyViewOAuthClient>,
3131+ primary_client: ArcSwap<HappyViewOAuthClient>,
3232+ domain_clients: DashMap<String, Arc<HappyViewOAuthClient>>,
3133 clients: DashMap<String, Arc<HappyViewOAuthClient>>,
3234}
33353436impl OAuthClientRegistry {
3535- pub fn new(default_client: Arc<HappyViewOAuthClient>) -> Self {
3737+ pub fn new(primary_client: Arc<HappyViewOAuthClient>) -> Self {
3638 Self {
3737- default_client,
3939+ primary_client: ArcSwap::new(primary_client),
4040+ domain_clients: DashMap::new(),
3841 clients: DashMap::new(),
3942 }
4043 }
···5457 self.clients.get(client_id_url).map(|r| r.value().clone())
5558 }
56595757- /// Look up a client by `client_id_url`, falling back to the default.
6060+ /// Look up a client by `client_id_url`, falling back to the primary client.
5861 pub fn get_or_default(&self, client_id_url: Option<&str>) -> Arc<HappyViewOAuthClient> {
5962 if let Some(url) = client_id_url {
6063 self.clients
6164 .get(url)
6265 .map(|r| r.value().clone())
6363- .unwrap_or_else(|| self.default_client.clone())
6666+ .unwrap_or_else(|| self.primary_client.load_full())
6467 } else {
6565- self.default_client.clone()
6868+ self.primary_client.load_full()
6669 }
6770 }
68716969- /// Get the default (HappyView dashboard) client.
7070- pub fn default_client(&self) -> &Arc<HappyViewOAuthClient> {
7171- &self.default_client
7272+ /// Get the primary (HappyView dashboard) client.
7373+ pub fn primary_client(&self) -> Arc<HappyViewOAuthClient> {
7474+ self.primary_client.load_full()
7575+ }
7676+7777+ /// Register a domain-specific OAuth client.
7878+ /// Inserts into both `domain_clients` (keyed by domain URL, for `get_for_domain`)
7979+ /// and `clients` (keyed by client_id_url, for `get_or_default`).
8080+ pub fn register_domain_client(&self, domain_url: String, client: Arc<HappyViewOAuthClient>) {
8181+ let client_id_url = format!(
8282+ "{}/oauth-client-metadata.json",
8383+ domain_url.trim_end_matches('/')
8484+ );
8585+ self.domain_clients.insert(domain_url, Arc::clone(&client));
8686+ self.clients.insert(client_id_url, client);
8787+ }
8888+8989+ /// Remove a domain-specific OAuth client from both maps.
9090+ pub fn remove_domain_client(&self, domain_url: &str) {
9191+ self.domain_clients.remove(domain_url);
9292+ let client_id_url = format!(
9393+ "{}/oauth-client-metadata.json",
9494+ domain_url.trim_end_matches('/')
9595+ );
9696+ self.clients.remove(&client_id_url);
9797+ }
9898+9999+ /// Look up a domain-specific OAuth client.
100100+ pub fn get_domain_client(&self, domain_url: &str) -> Option<Arc<HappyViewOAuthClient>> {
101101+ self.domain_clients
102102+ .get(domain_url)
103103+ .map(|r| r.value().clone())
104104+ }
105105+106106+ /// Get the OAuth client for a domain, falling back to the primary client.
107107+ pub fn get_for_domain(&self, domain_url: &str) -> Arc<HappyViewOAuthClient> {
108108+ self.domain_clients
109109+ .get(domain_url)
110110+ .map(|r| r.value().clone())
111111+ .unwrap_or_else(|| self.primary_client.load_full())
112112+ }
113113+114114+ /// Replace the primary OAuth client (e.g. when admin changes the primary domain).
115115+ pub fn set_primary_client(&self, client: Arc<HappyViewOAuthClient>) {
116116+ self.primary_client.store(client);
72117 }
7311874119 /// Build and register a single OAuth client from API client metadata.
+16-5
src/auth/routes.rs
···5757async fn login(
5858 State(state): State<AppState>,
5959 jar: SignedCookieJar<Key>,
6060+ domain: Option<axum::extract::Extension<std::sync::Arc<crate::domain::Domain>>>,
6061 Query(query): Query<LoginQuery>,
6162) -> Result<(SignedCookieJar<Key>, Json<serde_json::Value>), AppError> {
6263 tracing::debug!(handle = %query.handle, redirect_uri = ?query.redirect_uri, scope = ?query.scope, "login request");
···77787879 tracing::debug!(scopes = ?scopes, client_id = ?query.client_id, "resolved oauth scopes");
79808181+ // For dashboard logins (no explicit client_id), use the domain's OAuth client
8282+ let domain_url = domain.map(|d| d.0.url.clone());
8383+ let effective_client_id = if query.client_id.is_some() {
8484+ query.client_id.clone()
8585+ } else {
8686+ domain_url
8787+ .as_ref()
8888+ .map(|du| format!("{}/oauth-client-metadata.json", du.trim_end_matches('/')))
8989+ };
9090+8091 // Select the appropriate OAuth client based on client_id
8181- let oauth_client = state.oauth.get_or_default(query.client_id.as_deref());
9292+ let oauth_client = state.oauth.get_or_default(effective_client_id.as_deref());
82938394 // Hold the authorize lock so that authorize() + take_last_state_key() are atomic.
8495 // This prevents concurrent logins from swapping each other's state keys.
···106117 // Store the redirect URI in the database, keyed by the OAuth state parameter.
107118 // This avoids third-party cookie issues when Pentaract (cross-origin) calls this endpoint.
108119 // Store redirect URI and client_id for the callback to use
109109- if query.redirect_uri.is_some() || query.client_id.is_some() {
120120+ if query.redirect_uri.is_some() || effective_client_id.is_some() {
110121 let redirect_uri = query.redirect_uri.as_deref().unwrap_or("");
111111- tracing::debug!(oauth_state = ?oauth_state, redirect_uri = %redirect_uri, client_id = ?query.client_id, "storing redirect for state");
122122+ tracing::debug!(oauth_state = ?oauth_state, redirect_uri = %redirect_uri, client_id = ?effective_client_id, "storing redirect for state");
112123113124 if let Some(oauth_state) = oauth_state {
114125 let now = now_rfc3339();
···120131 let _ = sqlx::query(&sql)
121132 .bind(&oauth_state)
122133 .bind(redirect_uri)
123123- .bind(query.client_id.as_deref())
134134+ .bind(effective_client_id.as_deref())
124135 .bind(&now)
125136 .bind(&expires_at)
126137 .execute(&state.db)
···257268 let raw = cookie.value().to_string();
258269 let did_str = raw.split('\n').next().unwrap_or(&raw).to_string();
259270 if let Ok(did) = atrium_api::types::string::Did::new(did_str) {
260260- let _ = state.oauth.default_client().revoke(&did).await;
271271+ let _ = state.oauth.primary_client().revoke(&did).await;
261272 }
262273 }
263274