From d715d2150cc5e78459dc879f9584e38e980d64c5 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 24 Jul 2026 13:38:52 +0100 Subject: [PATCH] feat(shared): add slug rules and shared document models --- shared/models/org.go | 18 ++++++++++++ shared/models/settings.go | 40 ++++++++++++++++++++++++++ shared/models/user.go | 33 ++++++++++++++++++++++ shared/provision/slug.go | 59 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 shared/models/org.go create mode 100644 shared/models/settings.go create mode 100644 shared/models/user.go create mode 100644 shared/provision/slug.go diff --git a/shared/models/org.go b/shared/models/org.go new file mode 100644 index 0000000..b3d8d87 --- /dev/null +++ b/shared/models/org.go @@ -0,0 +1,18 @@ +// Package models holds the MongoDB documents written by more than one Vantage +// service. Documents only the control plane touches stay in +// server/internal/models. +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type Org struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + OrgID string `bson:"org_id" json:"org_id"` + Name string `bson:"name" json:"name"` + Slug string `bson:"slug" json:"slug"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` +} diff --git a/shared/models/settings.go b/shared/models/settings.go new file mode 100644 index 0000000..d260e39 --- /dev/null +++ b/shared/models/settings.go @@ -0,0 +1,40 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type AlertSettings struct { + Enabled bool `bson:"enabled" json:"enabled"` + WebhookURL string `bson:"webhook_url" json:"webhook_url"` + OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"` +} + +type EmailSettings struct { + Enabled bool `bson:"enabled" json:"enabled"` + SMTPHost string `bson:"smtp_host" json:"smtp_host"` + SMTPPort int `bson:"smtp_port" json:"smtp_port"` + Username string `bson:"username" json:"username"` + Password string `bson:"password" json:"password"` + FromAddr string `bson:"from_addr" json:"from_addr"` + ToAddrs []string `bson:"to_addrs" json:"to_addrs"` + UseTLS bool `bson:"use_tls" json:"use_tls"` +} + +type SecretsSettings struct { + ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"` + ReadTokenSet bool `bson:"-" json:"read_token_set"` + RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"` +} + +type Settings struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + OrgID string `bson:"org_id" json:"org_id"` + Alerts AlertSettings `bson:"alerts" json:"alerts"` + Email EmailSettings `bson:"email" json:"email"` + Secrets SecretsSettings `bson:"secrets" json:"secrets"` + + WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"` +} diff --git a/shared/models/user.go b/shared/models/user.go new file mode 100644 index 0000000..d1bc186 --- /dev/null +++ b/shared/models/user.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" +) + +func ValidRole(role string) bool { + switch role { + case RoleOwner, RoleAdmin, RoleMember: + return true + } + return false +} + +type User struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + UserID string `bson:"user_id" json:"user_id"` + OrgID string `bson:"org_id" json:"org_id"` + Email string `bson:"email" json:"email"` + PasswordHash string `bson:"password_hash,omitempty" json:"-"` + Role string `bson:"role" json:"role"` + AuthSource string `bson:"auth_source" json:"auth_source"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` + LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"` +} diff --git a/shared/provision/slug.go b/shared/provision/slug.go new file mode 100644 index 0000000..9656448 --- /dev/null +++ b/shared/provision/slug.go @@ -0,0 +1,59 @@ +// Package provision holds the tenant creation rules shared by the control +// plane and sitesvc. +// +// These rules used to be duplicated: the control plane owned one copy and +// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried +// on a lost slug race while the control plane returned an error. This package +// is the single definition; neither service may reimplement any of it. +package provision + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + MinSlugLength = 3 + MaxSlugLength = 40 +) + +var slugStrip = regexp.MustCompile(`[^a-z0-9]+`) + +// ReservedSlugs are subdomain labels the platform needs for itself. +var ReservedSlugs = map[string]bool{ + "www": true, "api": true, "app": true, "admin": true, "auth": true, + "install": true, "static": true, "_next": true, "default": true, +} + +// Slugify lowercases a name and collapses every run of non-alphanumeric +// characters into a single hyphen, trimming hyphens from both ends. +func Slugify(name string) string { + s := strings.ToLower(name) + s = slugStrip.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +// BaseSlug turns a name into a validated slug stem, or explains why it cannot. +func BaseSlug(name string) (string, error) { + base := Slugify(name) + if len(base) < MinSlugLength { + return "", fmt.Errorf("organisation name too short (slug must be at least %d characters)", MinSlugLength) + } + if len(base) > MaxSlugLength { + base = base[:MaxSlugLength] + } + if ReservedSlugs[base] { + return "", fmt.Errorf("that organisation name is reserved") + } + return base, nil +} + +// NextSlug returns the candidate slug for a given attempt. Attempt 1 is the +// base itself; later attempts append a counter. +func NextSlug(base string, attempt int) string { + if attempt < 2 { + return base + } + return fmt.Sprintf("%s-%d", base, attempt) +}