AkurAI Build
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"));
    }
}