Skip to main content

allowthem_saas/
domains.rs

1//! Custom domain types and validation.
2//!
3//! `DomainId`, `DomainStatus`, and `TenantDomain` mirror the
4//! `tenant_domains` control-plane table. `normalize_domain` encapsulates
5//! the validation rules from plan §3.4.
6
7use chrono::{DateTime, Utc};
8use uuid::Uuid;
9
10use crate::error::SaasError;
11
12// ── Id newtype ────────────────────────────────────────────────────────────────
13
14/// UUIDv7-backed identifier for a `tenant_domains` row.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct DomainId(Uuid);
17
18impl DomainId {
19    pub fn new() -> Self {
20        Self(Uuid::now_v7())
21    }
22
23    pub fn as_uuid(&self) -> &Uuid {
24        &self.0
25    }
26
27    /// Raw bytes suitable for binding to a SQLite BLOB column.
28    pub fn as_bytes(&self) -> &[u8] {
29        self.0.as_bytes()
30    }
31}
32
33impl Default for DomainId {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl From<Uuid> for DomainId {
40    fn from(u: Uuid) -> Self {
41        Self(u)
42    }
43}
44
45// ── Status enum ───────────────────────────────────────────────────────────────
46
47/// Lifecycle state of a custom domain registration.
48///
49/// `Active` is reserved for 38y.3 (Caddy on-demand TLS): this task only
50/// ever writes `PendingVerification`, `Verified`, or `Failed`. The variant
51/// exists so the schema and enum are complete from day one.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, sqlx::Type)]
53#[sqlx(type_name = "TEXT")]
54#[serde(rename_all = "snake_case")]
55pub enum DomainStatus {
56    #[sqlx(rename = "pending_verification")]
57    PendingVerification,
58    #[sqlx(rename = "verified")]
59    Verified,
60    /// Set by 38y.3 (Caddy cert callback). Never written in 38y.1.
61    #[sqlx(rename = "active")]
62    Active,
63    #[sqlx(rename = "failed")]
64    Failed,
65}
66
67impl DomainStatus {
68    pub fn as_str(&self) -> &'static str {
69        match self {
70            DomainStatus::PendingVerification => "pending_verification",
71            DomainStatus::Verified => "verified",
72            DomainStatus::Active => "active",
73            DomainStatus::Failed => "failed",
74        }
75    }
76}
77
78// ── Row struct ────────────────────────────────────────────────────────────────
79
80/// One row of `tenant_domains`. BLOB ID columns are kept as `Vec<u8>` to
81/// match the `Tenant` / `TenantMember` convention.
82#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize)]
83pub struct TenantDomain {
84    pub id: Vec<u8>,
85    pub tenant_id: Vec<u8>,
86    pub domain: String,
87    pub status: DomainStatus,
88    pub dns_target: String,
89    pub verified_at: Option<DateTime<Utc>>,
90    pub cert_expires_at: Option<DateTime<Utc>>,
91    pub last_error: Option<String>,
92    pub created_at: DateTime<Utc>,
93    pub updated_at: DateTime<Utc>,
94}
95
96// ── Domain validation ─────────────────────────────────────────────────────────
97
98/// Validate and normalise a raw domain input per plan §3.4.
99///
100/// Rules applied in order:
101/// 1. Non-empty, ≤ 253 chars, only `[a-z0-9.-]` (after lowercasing).
102/// 2. No leading/trailing `.` or `-`; no label > 63 chars; no `..`.
103/// 3. Must not equal `base_domain` or end with `.<base_domain>` (case-insensitive).
104/// 4. Must contain at least one `.` (single-label names are not routable).
105///
106/// Returns the lowercased canonical form on success, or
107/// [`SaasError::DomainInvalid`] with a human-readable reason on failure.
108pub fn normalize_domain(input: &str, base_domain: &str) -> Result<String, SaasError> {
109    let lower = input.to_lowercase();
110
111    if lower.is_empty() {
112        return Err(SaasError::DomainInvalid("domain must not be empty"));
113    }
114    if lower.len() > 253 {
115        return Err(SaasError::DomainInvalid(
116            "domain must be 253 characters or fewer",
117        ));
118    }
119
120    // Only LDH (letters, digits, hyphens) plus dots are valid in DNS names.
121    if !lower
122        .bytes()
123        .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
124    {
125        return Err(SaasError::DomainInvalid(
126            "domain may only contain letters, digits, hyphens, and dots",
127        ));
128    }
129
130    if lower.starts_with('.') || lower.ends_with('.') {
131        return Err(SaasError::DomainInvalid(
132            "domain must not start or end with a dot",
133        ));
134    }
135    if lower.starts_with('-') || lower.ends_with('-') {
136        return Err(SaasError::DomainInvalid(
137            "domain must not start or end with a hyphen",
138        ));
139    }
140    if lower.contains("..") {
141        return Err(SaasError::DomainInvalid(
142            "domain must not contain consecutive dots",
143        ));
144    }
145
146    // Every label must be ≤ 63 characters and must not start or end with a hyphen.
147    for label in lower.split('.') {
148        if label.is_empty() {
149            // Covered by the leading/trailing/double-dot checks above; be explicit.
150            return Err(SaasError::DomainInvalid("domain labels must not be empty"));
151        }
152        if label.len() > 63 {
153            return Err(SaasError::DomainInvalid(
154                "each label must be 63 characters or fewer",
155            ));
156        }
157        if label.starts_with('-') || label.ends_with('-') {
158            return Err(SaasError::DomainInvalid(
159                "domain labels must not start or end with a hyphen",
160            ));
161        }
162    }
163
164    // Require at least one dot (single-label names like "localhost" are not valid FQDNs).
165    if !lower.contains('.') {
166        return Err(SaasError::DomainInvalid(
167            "domain must be a fully-qualified name with at least one dot",
168        ));
169    }
170
171    // Reject the base domain itself and any subdomain of it.
172    let base = base_domain.to_lowercase();
173    if lower == base || lower.ends_with(&format!(".{base}")) {
174        return Err(SaasError::DomainInvalid(
175            "domain must not be the platform base domain or a subdomain of it",
176        ));
177    }
178
179    Ok(lower)
180}
181
182// ── Tests ─────────────────────────────────────────────────────────────────────
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    const BASE: &str = "allowthem.io";
189
190    // ── Acceptance ────────────────────────────────────────────────────────────
191
192    #[test]
193    fn accepts_valid_domain() {
194        assert_eq!(
195            normalize_domain("auth.theirapp.com", BASE).unwrap(),
196            "auth.theirapp.com"
197        );
198    }
199
200    #[test]
201    fn lowercases_mixed_case() {
202        assert_eq!(
203            normalize_domain("Auth.TheirApp.COM", BASE).unwrap(),
204            "auth.theirapp.com"
205        );
206    }
207
208    #[test]
209    fn accepts_domain_with_hyphens() {
210        assert_eq!(
211            normalize_domain("my-app.example.com", BASE).unwrap(),
212            "my-app.example.com"
213        );
214    }
215
216    // ── Rejections — structural ───────────────────────────────────────────────
217
218    #[test]
219    fn rejects_empty() {
220        let err = normalize_domain("", BASE).unwrap_err();
221        assert!(matches!(err, SaasError::DomainInvalid(_)));
222    }
223
224    #[test]
225    fn rejects_too_long() {
226        // 254-char domain: 63 + '.' + 63 + '.' + 63 + '.' + 63 = 255 — trim to 254
227        let long: String = format!("{}.{}.com", "a".repeat(120), "b".repeat(120));
228        let err = normalize_domain(&long, BASE).unwrap_err();
229        assert!(matches!(err, SaasError::DomainInvalid(_)));
230    }
231
232    #[test]
233    fn rejects_label_too_long() {
234        let long_label = format!("{}.example.com", "a".repeat(64));
235        let err = normalize_domain(&long_label, BASE).unwrap_err();
236        assert!(matches!(err, SaasError::DomainInvalid(_)));
237    }
238
239    #[test]
240    fn rejects_leading_dot() {
241        let err = normalize_domain(".auth.example.com", BASE).unwrap_err();
242        assert!(matches!(err, SaasError::DomainInvalid(_)));
243    }
244
245    #[test]
246    fn rejects_trailing_dot() {
247        let err = normalize_domain("auth.example.com.", BASE).unwrap_err();
248        assert!(matches!(err, SaasError::DomainInvalid(_)));
249    }
250
251    #[test]
252    fn rejects_leading_hyphen() {
253        let err = normalize_domain("-auth.example.com", BASE).unwrap_err();
254        assert!(matches!(err, SaasError::DomainInvalid(_)));
255    }
256
257    #[test]
258    fn rejects_trailing_hyphen() {
259        let err = normalize_domain("auth-.example.com", BASE).unwrap_err();
260        assert!(matches!(err, SaasError::DomainInvalid(_)));
261    }
262
263    #[test]
264    fn rejects_double_dot() {
265        let err = normalize_domain("auth..example.com", BASE).unwrap_err();
266        assert!(matches!(err, SaasError::DomainInvalid(_)));
267    }
268
269    #[test]
270    fn rejects_non_ldh_chars() {
271        let err = normalize_domain("auth_app.example.com", BASE).unwrap_err();
272        assert!(matches!(err, SaasError::DomainInvalid(_)));
273    }
274
275    #[test]
276    fn rejects_single_label() {
277        let err = normalize_domain("localhost", BASE).unwrap_err();
278        assert!(matches!(err, SaasError::DomainInvalid(_)));
279    }
280
281    // ── Rejections — base domain rules ───────────────────────────────────────
282
283    #[test]
284    fn rejects_base_domain_itself() {
285        let err = normalize_domain("allowthem.io", BASE).unwrap_err();
286        assert!(matches!(err, SaasError::DomainInvalid(_)));
287    }
288
289    #[test]
290    fn rejects_subdomain_of_base() {
291        let err = normalize_domain("auth.allowthem.io", BASE).unwrap_err();
292        assert!(matches!(err, SaasError::DomainInvalid(_)));
293    }
294
295    #[test]
296    fn rejects_base_domain_case_insensitive() {
297        let err = normalize_domain("ALLOWTHEM.IO", BASE).unwrap_err();
298        assert!(matches!(err, SaasError::DomainInvalid(_)));
299    }
300
301    #[test]
302    fn rejects_subdomain_of_base_mixed_case() {
303        let err = normalize_domain("Auth.AllowThem.IO", BASE).unwrap_err();
304        assert!(matches!(err, SaasError::DomainInvalid(_)));
305    }
306}