rdev/internal/domain/project_domain.go
jordan c86516c53a feat: Add multi-domain support with auto-generated slugs for landing page cookbook
Landing page cookbook implementation (Weeks 1-4):

Domain Infrastructure:
- Add project_domains table with migration (013_project_domains.sql)
- Add ProjectDomain model with domain types (primary_auto, primary_custom, alias)
- Add SlugGenerator and ProjectDomainRepository interfaces
- Implement postgres adapters for domain and slug management

Service Layer:
- Add domain CRUD methods to ProjectInfraService
- Generate 8-char random slugs for auto-domains
- Support custom subdomains during project creation
- Add site_live health check to project status
- Trigger CI build after template seeding

Handler Updates:
- Add DomainService interface and adapter pattern
- Rewrite domain handlers to use database-backed service
- Add proper error handling for duplicate/missing domains

CI Integration:
- Add TriggerBuild to CIProvider interface
- Implement TriggerBuild in Woodpecker adapter
- Manually trigger initial build after template seed

Cookbook & Scripts:
- Add landing-test.sh script for E2E testing
- Add release.sh for version releases
- Add logs.sh for quick log access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:55:59 -07:00

153 lines
4.5 KiB
Go

// Package domain contains pure domain models with no external dependencies.
package domain
import (
"crypto/rand"
"fmt"
"regexp"
"strings"
"time"
)
// DomainType represents the type of domain association with a project.
type DomainType string
const (
// DomainTypePrimaryAuto is a system-generated random subdomain (e.g., k7m2x9p4.threesix.ai).
DomainTypePrimaryAuto DomainType = "primary_auto"
// DomainTypePrimaryCustom is a user-chosen subdomain (e.g., my-app.threesix.ai).
DomainTypePrimaryCustom DomainType = "primary_custom"
// DomainTypeAlias is an additional domain pointing to the project (e.g., www.myapp.com).
DomainTypeAlias DomainType = "alias"
)
// Valid returns true if the domain type is recognized.
func (t DomainType) Valid() bool {
switch t {
case DomainTypePrimaryAuto, DomainTypePrimaryCustom, DomainTypeAlias:
return true
}
return false
}
// IsPrimary returns true if this is a primary domain (auto or custom).
func (t DomainType) IsPrimary() bool {
return t == DomainTypePrimaryAuto || t == DomainTypePrimaryCustom
}
// ProjectDomain represents a domain associated with a project.
type ProjectDomain struct {
ID int64
ProjectID string
Domain string
Type DomainType
DNSRecordID string // Cloudflare record ID for cleanup
DNSRecordType string // A, CNAME, etc.
Verified bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Slug generation constants.
const (
// SlugLength is the length of generated slugs.
SlugLength = 8
// slugChars are the allowed characters for slugs.
// Excludes ambiguous chars: 0/o, 1/l/i to prevent confusion.
slugChars = "23456789abcdefghjkmnpqrstuvwxyz"
)
// slugCharRegex validates that a slug contains only allowed characters.
var slugCharRegex = regexp.MustCompile(`^[23456789abcdefghjkmnpqrstuvwxyz]+$`)
// GenerateSlug creates a random 8-character slug for project identification.
// Uses a restricted character set that excludes ambiguous characters.
func GenerateSlug() (string, error) {
bytes := make([]byte, SlugLength)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
result := make([]byte, SlugLength)
for i := range SlugLength {
result[i] = slugChars[int(bytes[i])%len(slugChars)]
}
return string(result), nil
}
// ValidateSlug checks if a slug is valid (correct length and characters).
func ValidateSlug(slug string) error {
if len(slug) != SlugLength {
return fmt.Errorf("slug must be exactly %d characters", SlugLength)
}
if !slugCharRegex.MatchString(slug) {
return fmt.Errorf("slug contains invalid characters")
}
return nil
}
// Domain validation patterns.
var (
// subdomainRegex validates subdomains under a base domain.
// Lowercase letters, digits, and hyphens. Must start with a letter.
subdomainRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
// fqdnRegex validates fully qualified domain names.
fqdnRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`)
)
// ValidateSubdomain validates a subdomain name (without the base domain).
func ValidateSubdomain(subdomain string) error {
if subdomain == "" {
return fmt.Errorf("subdomain cannot be empty")
}
if len(subdomain) > 63 {
return fmt.Errorf("subdomain cannot exceed 63 characters")
}
if !subdomainRegex.MatchString(subdomain) {
return fmt.Errorf("subdomain must be lowercase alphanumeric with hyphens, starting with a letter")
}
if reservedProjectNames[subdomain] {
return fmt.Errorf("subdomain %q is reserved", subdomain)
}
return nil
}
// ValidateFQDN validates a fully qualified domain name.
func ValidateFQDN(domain string) error {
if domain == "" {
return fmt.Errorf("domain cannot be empty")
}
if len(domain) > 253 {
return fmt.Errorf("domain cannot exceed 253 characters")
}
domain = strings.ToLower(domain)
if !fqdnRegex.MatchString(domain) {
return fmt.Errorf("invalid domain format")
}
return nil
}
// IsSubdomainOf checks if domain is a subdomain of baseDomain.
func IsSubdomainOf(domain, baseDomain string) bool {
domain = strings.ToLower(domain)
baseDomain = strings.ToLower(baseDomain)
suffix := "." + baseDomain
return strings.HasSuffix(domain, suffix)
}
// ExtractSubdomain extracts the subdomain portion from a full domain.
// Returns empty string if domain is not a subdomain of baseDomain.
func ExtractSubdomain(domain, baseDomain string) string {
domain = strings.ToLower(domain)
baseDomain = strings.ToLower(baseDomain)
suffix := "." + baseDomain
if subdomain, found := strings.CutSuffix(domain, suffix); found {
return subdomain
}
return ""
}