refactor(sitesvc): use shared models and provision

Deletes internal/provision and the hand-mirrored Org and User structs. The
control plane and sitesvc now share one definition of both.
This commit is contained in:
2026-07-24 13:43:58 +01:00
parent 76d3111a72
commit 51622a922b
5 changed files with 33 additions and 191 deletions
+4 -3
View File
@@ -1,16 +1,15 @@
module github.com/mrhid6/vantage/sitesvc
go 1.26
go 1.26.4
require (
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
go.mongodb.org/mongo-driver/v2 v2.2.2
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
)
require (
github.com/golang/snappy v1.0.0 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
@@ -19,3 +18,5 @@ require (
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
+2 -4
View File
@@ -1,7 +1,5 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -19,8 +17,8 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+6 -36
View File
@@ -6,42 +6,12 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
/*
Org and User mirror server/internal/models field for field, because sitesvc
writes into the same collections the control plane reads.
These two structs and the rules in internal/provision are the only places
sitesvc duplicates control-plane logic. If the control plane's shape changes,
these must change with it.
*/
type Org struct {
ID bson.ObjectID `bson:"_id,omitempty"`
OrgID string `bson:"org_id"`
Name string `bson:"name"`
Slug string `bson:"slug"`
CreatedAt time.Time `bson:"created_at"`
}
type User struct {
ID bson.ObjectID `bson:"_id,omitempty"`
UserID string `bson:"user_id"`
OrgID string `bson:"org_id"`
Email string `bson:"email"`
PasswordHash string `bson:"password_hash,omitempty"`
Role string `bson:"role"`
AuthSource string `bson:"auth_source"`
CreatedAt time.Time `bson:"created_at"`
LastLogin *time.Time `bson:"last_login,omitempty"`
}
// PendingSignup lives here rather than in the shared module because only
// sitesvc writes site_pending_signups. The control plane does not know the
// collection exists.
//
// Org and User used to be mirrored here by hand. They now come from
// github.com/mrhid6/vantage/shared/models, which is the only copy.
type PendingSignup struct {
ID bson.ObjectID `bson:"_id,omitempty"`
PendingID string `bson:"pending_id"`
-61
View File
@@ -1,61 +0,0 @@
package provision
import (
"fmt"
"regexp"
"strings"
)
/*
Slug rules mirrored from the control plane (server/internal/services: Slugify in
stepscan.go, reservedSlugs and CreateOrg in orgs.go).
They live here rather than being imported because sitesvc is a separate module
with no dependency on the server. That is a deliberate trade: sitesvc stays
small and independent, at the cost of this one duplicated rule set.
Keep the two in step. If the control plane's slug handling, reserved names or
bcrypt cost change, change them here in the same commit nothing enforces the
match automatically, and a divergence would create tenants under rules the app
does not agree with.
*/
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12
)
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
var ReservedSlugs = map[string]bool{
"www": true, "api": true, "app": true, "admin": true, "auth": true,
"install": true, "static": true, "_next": true, "default": true,
}
func Slugify(name string) string {
s := strings.ToLower(name)
s = slugStrip.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
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
}
func NextSlug(base string, attempt int) string {
if attempt < 2 {
return base
}
return fmt.Sprintf("%s-%d", base, attempt)
}
+21 -87
View File
@@ -12,8 +12,10 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/shared/indexes"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"github.com/mrhid6/vantage/sitesvc/internal/models"
"github.com/mrhid6/vantage/sitesvc/internal/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -24,10 +26,12 @@ import (
const PendingTTL = 24 * time.Hour
// ErrEmailTaken and ErrNameRejected are aliases of the shared errors so
// errors.Is keeps working for existing callers in internal/api.
var (
ErrEmailTaken = errors.New("email already registered")
ErrEmailTaken = provision.ErrEmailTaken
ErrBadToken = errors.New("verification link is invalid or has expired")
ErrNameRejected = errors.New("organisation name rejected")
ErrNameRejected = provision.ErrNameRejected
)
var database *mongo.Database
@@ -78,18 +82,11 @@ func EnsureIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := col("users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("users.email index: %w", err)
}
if _, err := col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("orgs.slug index: %w", err)
// users.email and orgs.slug are declared in the shared module so both
// services agree. Re-declaring at boot means sitesvc does not depend on the
// control plane having started first.
if err := indexes.EnsureCoreIndexes(ctx, database); err != nil {
return err
}
_, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{
@@ -166,7 +163,7 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) {
var pending models.PendingSignup
err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
"token_hash": hashToken(rawToken),
@@ -179,89 +176,26 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
return nil, err
}
org, err := createOrg(ctx, pending.OrgName)
org, err := provision.CreateOrg(ctx, database, pending.OrgName)
if err != nil {
return nil, err
}
user := models.User{
UserID: uuid.NewString(),
OrgID: org.OrgID,
Email: pending.Email,
PasswordHash: pending.PasswordHash,
Role: "owner",
AuthSource: "local",
CreatedAt: time.Now().UTC(),
}
if _, err := col("users").InsertOne(ctx, user); err != nil {
if rbErr := rollbackOrg(ctx, org.OrgID); rbErr != nil {
// The password was hashed when the signup was recorded; only the hash
// survives to this point.
_, err = provision.CreateUserWithHash(ctx, database, org.OrgID, pending.Email,
pending.PasswordHash, sharedmodels.RoleOwner, "local")
if err != nil {
// Leaving an org behind would permanently occupy a slug nobody owns.
if rbErr := provision.RollbackOrg(ctx, database, org.OrgID); rbErr != nil {
log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr)
}
if mongo.IsDuplicateKeyError(err) {
return nil, ErrEmailTaken
}
return nil, err
}
return org, nil
}
func createOrg(ctx context.Context, name string) (*models.Org, error) {
base, err := provision.BaseSlug(name)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
}
for attempt := 1; attempt <= 50; attempt++ {
slug := provision.NextSlug(base, attempt)
n, err := col("orgs").CountDocuments(ctx, bson.M{"slug": slug})
if err != nil {
return nil, err
}
if n > 0 {
continue
}
org := models.Org{
OrgID: uuid.NewString(),
Name: name,
Slug: slug,
CreatedAt: time.Now().UTC(),
}
if _, err := col("orgs").InsertOne(ctx, org); err != nil {
if mongo.IsDuplicateKeyError(err) {
continue
}
return nil, err
}
return &org, nil
}
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
}
func rollbackOrg(ctx context.Context, orgID string) error {
n, err := col("users").CountDocuments(ctx, bson.M{"org_id": orgID})
if err != nil {
return err
}
if n > 0 {
return fmt.Errorf("refusing to roll back org %s: it has %d user(s)", orgID, n)
}
_, err = col("orgs").DeleteOne(ctx, bson.M{"org_id": orgID})
return err
}
func randomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {