1use chrono::{DateTime, Utc};
8use uuid::Uuid;
9
10use crate::error::SaasError;
11
12#[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 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#[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 #[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#[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
96pub 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 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 for label in lower.split('.') {
148 if label.is_empty() {
149 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 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 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#[cfg(test)]
185mod tests {
186 use super::*;
187
188 const BASE: &str = "allowthem.io";
189
190 #[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 #[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 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 #[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}