Menu
Public repository / 3503b684768a57d3fa15dd6fd3c2af86320c5b56
AkurAI-DNS / src/routes.rs
Latest change 3503b684768a57d3fa15dd6fd3c2af86320c5b56 - feat: add domain_ownerships table and CRUD API with tenant isolation (AKURAI-DNS-5) by agent
use std::sync::Arc;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use rusqlite::params;
use serde_json::json;
use tokio::sync::Mutex;
use tracing::warn;
use crate::models::{CreateDomainRequest, DomainOwnership, DomainResponse, DomainState};
use crate::schema;
pub type Db = Arc<Mutex<rusqlite::Connection>>;
/// Shared application state injected into every handler.
#[derive(Clone)]
pub struct AppState {
pub db: Db,
}
// ── Tenant extraction ──────────────────────────────────────────────────
#[derive(Debug)]
struct Tenant {
org_id: String,
workspace_id: String,
}
/// Extract tenant identity from request headers.
/// Returns 401 when either header is missing or empty.
fn require_tenant(
headers: &axum::http::HeaderMap,
) -> Result<Tenant, (StatusCode, Json<serde_json::Value>)> {
let missing = |name: &str| {
(
StatusCode::UNAUTHORIZED,
Json(json!({"error": format!("missing required header: {name}")})),
)
};
let org_id = headers
.get("x-org-id")
.and_then(|v| v.to_str().ok())
.filter(|v| !v.is_empty())
.ok_or_else(|| missing("x-org-id"))?
.to_string();
let workspace_id = headers
.get("x-workspace-id")
.and_then(|v| v.to_str().ok())
.filter(|v| !v.is_empty())
.ok_or_else(|| missing("x-workspace-id"))?
.to_string();
Ok(Tenant {
org_id,
workspace_id,
})
}
// ── Domain format validation ───────────────────────────────────────────
fn is_valid_domain(domain: &str) -> bool {
if domain.is_empty() || domain.len() > 253 {
return false;
}
// Each label: 1–63 chars, alphanumeric + hyphens, no leading/trailing hyphen.
for label in domain.split('.') {
if label.is_empty() || label.len() > 63 {
return false;
}
if label.starts_with('-') || label.ends_with('-') {
return false;
}
if !label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
return false;
}
}
true
}
// ── Handlers ───────────────────────────────────────────────────────────
/// POST /api/domains — register a new domain ownership.
pub async fn create_domain(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Json(body): Json<CreateDomainRequest>,
) -> impl IntoResponse {
let tenant = match require_tenant(&headers) {
Ok(t) => t,
Err(resp) => return resp,
};
let domain = body.domain.trim().to_lowercase();
if !is_valid_domain(&domain) {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "invalid domain format"})),
);
}
let token = uuid::Uuid::new_v4().to_string();
let db = state.db.lock().await;
let result = db.execute(
"INSERT INTO domain_ownerships (org_id, workspace_id, domain, state, verification_token)
VALUES (?1, ?2, ?3, 'requested', ?4)",
params![tenant.org_id, tenant.workspace_id, domain, token],
);
match result {
Ok(_) => {
let id = db.last_insert_rowid();
let row = db
.query_row(
"SELECT id, org_id, workspace_id, domain, state, verification_token,
verified_at, provisioned_at, expires_at, created_at, updated_at
FROM domain_ownerships WHERE id = ?1",
params![id],
|row| {
Ok(DomainOwnership {
id: row.get(0)?,
org_id: row.get(1)?,
workspace_id: row.get(2)?,
domain: row.get(3)?,
state: DomainState::from_str(&row.get::<_, String>(4)?)
.unwrap_or(DomainState::Requested),
verification_token: row.get(5)?,
verified_at: row.get(6)?,
provisioned_at: row.get(7)?,
expires_at: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
},
)
.ok();
let response = row.map(DomainResponse::from);
(StatusCode::CREATED, Json(json!(response)))
}
Err(e) => {
if e.to_string().contains("UNIQUE constraint") {
return (
StatusCode::CONFLICT,
Json(json!({"error": "domain already registered"})),
);
}
warn!(error = %e, "failed to insert domain ownership");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to create domain ownership"})),
)
}
}
}
/// GET /api/domains — list domains belonging to the caller's workspace.
pub async fn list_domains(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> impl IntoResponse {
let tenant = match require_tenant(&headers) {
Ok(t) => t,
Err(resp) => return resp,
};
let db = state.db.lock().await;
let mut stmt = match db.prepare(
"SELECT id, org_id, workspace_id, domain, state, verification_token,
verified_at, provisioned_at, expires_at, created_at, updated_at
FROM domain_ownerships
WHERE org_id = ?1 AND workspace_id = ?2
ORDER BY created_at DESC",
) {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "failed to prepare list query");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "internal server error"})),
);
}
};
let rows: Vec<DomainOwnership> =
match stmt.query_map(params![tenant.org_id, tenant.workspace_id], |row| {
Ok(DomainOwnership {
id: row.get(0)?,
org_id: row.get(1)?,
workspace_id: row.get(2)?,
domain: row.get(3)?,
state: DomainState::from_str(&row.get::<_, String>(4)?)
.unwrap_or(DomainState::Requested),
verification_token: row.get(5)?,
verified_at: row.get(6)?,
provisioned_at: row.get(7)?,
expires_at: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
}) {
Ok(mapped) => mapped.filter_map(|r| r.ok()).collect(),
Err(_) => Vec::new(),
};
let response: Vec<DomainResponse> = rows.into_iter().map(DomainResponse::from).collect();
(StatusCode::OK, Json(json!(response)))
}
/// DELETE /api/domains/{id} — remove a domain ownership (tenant-scoped).
pub async fn delete_domain(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(id): Path<i64>,
) -> impl IntoResponse {
let tenant = match require_tenant(&headers) {
Ok(t) => t,
Err(resp) => return resp,
};
let db = state.db.lock().await;
// Verify the row belongs to this tenant before deleting.
let exists: bool = db
.query_row(
"SELECT 1 FROM domain_ownerships WHERE id = ?1 AND org_id = ?2 AND workspace_id = ?3",
params![id, tenant.org_id, tenant.workspace_id],
|_| Ok(()),
)
.is_ok();
if !exists {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "domain ownership not found"})),
);
}
match db.execute("DELETE FROM domain_ownerships WHERE id = ?1", params![id]) {
Ok(1) => (StatusCode::NO_CONTENT, Json(json!(null))),
Ok(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "unexpected delete result"})),
),
Err(e) => {
warn!(error = %e, "failed to delete domain ownership");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "failed to delete domain ownership"})),
)
}
}
}
/// Initialize the database and run migrations.
pub fn init_db(path: &str) -> Result<Db, Box<dyn std::error::Error>> {
let conn = rusqlite::Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
schema::migrate(&conn)?;
Ok(Arc::new(Mutex::new(conn)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_domains_pass() {
assert!(is_valid_domain("example.com"));
assert!(is_valid_domain("sub.example.com"));
assert!(is_valid_domain("a.io"));
}
#[test]
fn invalid_domains_rejected() {
assert!(!is_valid_domain(""));
assert!(!is_valid_domain("-example.com"));
assert!(!is_valid_domain("example-.com"));
}
}