From 3f0f12b111d70d33af113942d7f00aa6ee27d950 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 24 Jul 2026 14:00:11 +0100 Subject: [PATCH] refactor(sitesvc): rename Org to Instance, refuse an unmigrated database The signup form's JSON field becomes instance_name, and the pending-signup document field with it. That collection is sitesvc-private and expires after 24 hours, so no migration is needed, but in-flight signups written before the deploy will fail verification. --- sitesvc/cmd/main.go | 7 +++ sitesvc/internal/api/ratelimit.go | 6 --- sitesvc/internal/api/signup.go | 30 ++++++------ sitesvc/internal/api/validate.go | 8 ---- sitesvc/internal/mail/mail.go | 4 +- sitesvc/internal/models/models.go | 2 +- sitesvc/internal/store/store.go | 76 ++++++++++++++++++------------- 7 files changed, 70 insertions(+), 63 deletions(-) diff --git a/sitesvc/cmd/main.go b/sitesvc/cmd/main.go index 309db86..1dbd2d5 100644 --- a/sitesvc/cmd/main.go +++ b/sitesvc/cmd/main.go @@ -28,6 +28,13 @@ func main() { } log.Printf("connected to MongoDB (database %q)", store.DatabaseName()) + guardCtx, guardCancel := context.WithTimeout(context.Background(), 10*time.Second) + guardErr := store.RequireMigratedDatabase(guardCtx) + guardCancel() + if guardErr != nil { + log.Fatalf("database check failed: %v", guardErr) + } + if err := store.EnsureIndexes(); err != nil { log.Fatalf("failed to ensure indexes: %v", err) } diff --git a/sitesvc/internal/api/ratelimit.go b/sitesvc/internal/api/ratelimit.go index fc3539c..5bd2258 100644 --- a/sitesvc/internal/api/ratelimit.go +++ b/sitesvc/internal/api/ratelimit.go @@ -5,10 +5,6 @@ import ( "time" ) - - - - type limiter struct { mu sync.Mutex hits map[string]*window @@ -51,8 +47,6 @@ func (l *limiter) allow(key string) bool { return true } - - func (l *limiter) gc(now time.Time) { if now.Sub(l.lastGC) < l.window { return diff --git a/sitesvc/internal/api/signup.go b/sitesvc/internal/api/signup.go index 670848a..930eac8 100644 --- a/sitesvc/internal/api/signup.go +++ b/sitesvc/internal/api/signup.go @@ -22,10 +22,10 @@ const ( ) type signupBody struct { - OrgName string `json:"org_name"` - Email string `json:"email"` - Password string `json:"password"` - Website string `json:"website"` + InstanceName string `json:"instance_name"` + Email string `json:"email"` + Password string `json:"password"` + Website string `json:"website"` } func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { @@ -41,7 +41,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { var problems []fieldError - orgName, nameErr := text("org_name", body.OrgName, true, maxShort) + instanceName, nameErr := text("instance_name", body.InstanceName, true, maxShort) if nameErr != nil { problems = append(problems, *nameErr) } @@ -82,7 +82,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - token, err := store.CreatePending(ctx, orgName, addr, body.Password) + token, err := store.CreatePending(ctx, instanceName, addr, body.Password) switch { case errors.Is(err, store.ErrEmailTaken): writeJSON(w, http.StatusConflict, map[string]any{ @@ -95,7 +95,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { return case errors.Is(err, store.ErrNameRejected): writeFieldErrors(w, []fieldError{{ - Field: "org_name", + Field: "instance_name", Message: strings.TrimPrefix(err.Error(), store.ErrNameRejected.Error()+": "), }}) return @@ -108,7 +108,7 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { } link := s.verifyURL(token) - if err := s.mail.SendVerification(addr, orgName, link, store.PendingTTL); err != nil { + if err := s.mail.SendVerification(addr, instanceName, link, store.PendingTTL); err != nil { log.Printf("signup: send verification to %s: %v", addr, err) writeJSON(w, http.StatusBadGateway, map[string]string{ @@ -136,7 +136,7 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() - org, err := store.Verify(ctx, token) + inst, err := store.Verify(ctx, token) switch { case errors.Is(err, store.ErrBadToken): s.verifyPage(w, http.StatusGone, "Link expired", @@ -157,23 +157,23 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) { return } - log.Printf("verify: provisioned org %s (%s)", org.Slug, org.OrgID) + log.Printf("verify: provisioned instance %s (%s)", inst.Slug, inst.InstanceID) - if login := s.loginURL(org.Slug); login != "" { + if login := s.loginURL(inst.Slug); login != "" { http.Redirect(w, r, login, http.StatusSeeOther) return } s.verifyPage(w, http.StatusOK, "Organisation ready", - fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name)) + fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", inst.Name)) } -// loginURL is the org-specific sign-in URL a verified owner is sent to. Each -// org lives on its own subdomain (.vantage.hostxtra.co.uk), so the slug +// loginURL is the instance-specific sign-in URL a verified owner is sent to. Each +// instance lives on its own subdomain (.vantage.hostxtra.co.uk), so the slug // must be substituted per signup rather than pointing at one shared address. // // APP_LOGIN_URL is a template. A "{slug}" placeholder is replaced with the -// org's slug; a value without one is treated as a literal (a single shared +// instance's slug; a value without one is treated as a literal (a single shared // login page) so a plain URL still works. An empty value falls back to the // confirmation page. func (s *Server) loginURL(slug string) string { diff --git a/sitesvc/internal/api/validate.go b/sitesvc/internal/api/validate.go index ebfa6e3..ecd459d 100644 --- a/sitesvc/internal/api/validate.go +++ b/sitesvc/internal/api/validate.go @@ -7,8 +7,6 @@ import ( "unicode/utf8" ) - - var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`) const ( @@ -23,9 +21,6 @@ type fieldError struct { func (e fieldError) Error() string { return e.Field + ": " + e.Message } - - - func text(name, value string, required bool, max int) (string, *fieldError) { v := strings.TrimSpace(value) if v == "" { @@ -55,9 +50,6 @@ func email(name, value string) (string, *fieldError) { return v, nil } - - - func oneOf(name, value string, allowed []string) (string, *fieldError) { v := strings.TrimSpace(value) for _, a := range allowed { diff --git a/sitesvc/internal/mail/mail.go b/sitesvc/internal/mail/mail.go index 9ca27d3..87ce0a1 100644 --- a/sitesvc/internal/mail/mail.go +++ b/sitesvc/internal/mail/mail.go @@ -150,7 +150,7 @@ func sanitizeHeader(v string) string { return strings.NewReplacer("\r", " ", "\n", " ").Replace(v) } -func (c Config) SendVerification(to, orgName, link string, ttl time.Duration) error { +func (c Config) SendVerification(to, instanceName, link string, ttl time.Duration) error { body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage. Open this link: @@ -161,7 +161,7 @@ The link works once and expires in %d hours. Until you use it, no account exists nothing has been created and the address is not registered. If you did not request this, ignore this email and nothing will happen. -`, orgName, link, int(ttl.Hours())) +`, instanceName, link, int(ttl.Hours())) return c.sendTo(to, "Confirm your Vantage organisation", body, "") } diff --git a/sitesvc/internal/models/models.go b/sitesvc/internal/models/models.go index 4f3bd5b..57341be 100644 --- a/sitesvc/internal/models/models.go +++ b/sitesvc/internal/models/models.go @@ -15,7 +15,7 @@ import ( type PendingSignup struct { ID bson.ObjectID `bson:"_id,omitempty"` PendingID string `bson:"pending_id"` - OrgName string `bson:"org_name"` + InstanceName string `bson:"instance_name"` Email string `bson:"email"` PasswordHash string `bson:"password_hash"` TokenHash string `bson:"token_hash"` diff --git a/sitesvc/internal/store/store.go b/sitesvc/internal/store/store.go index 2e8492b..e3b90b8 100644 --- a/sitesvc/internal/store/store.go +++ b/sitesvc/internal/store/store.go @@ -23,7 +23,6 @@ import ( "golang.org/x/crypto/bcrypt" ) - const PendingTTL = 24 * time.Hour // ErrEmailTaken and ErrNameRejected are aliases of the shared errors so @@ -36,15 +35,6 @@ var ( var database *mongo.Database - - - - - - - - - func Connect(uri string) error { cs, err := connstring.ParseAndValidate(uri) if err != nil { @@ -68,7 +58,6 @@ func Connect(uri string) error { return nil } - func DatabaseName() string { if database == nil { return "" @@ -82,7 +71,7 @@ func EnsureIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - // users.email and orgs.slug are declared in the shared module so both + // users.email and instances.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 { @@ -95,8 +84,7 @@ func EnsureIndexes() error { Options: options.Index().SetUnique(true), }, {Keys: bson.D{{Key: "email", Value: 1}}}, - - + { Keys: bson.D{{Key: "expires_at", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(0), @@ -108,13 +96,46 @@ func EnsureIndexes() error { return nil } +// RequireMigratedDatabase refuses to start against a control-plane database +// that has not run migration 0004. +// +// Provisioning into `orgs` while the control plane reads `instances` would +// create tenants nobody can see — the exact skew failure the shared module was +// built to prevent. Failing to start is strictly better. +func RequireMigratedDatabase(ctx context.Context) error { + names, err := database.ListCollectionNames(ctx, bson.M{}) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + + var hasInstances, hasOrgs bool + for _, n := range names { + switch n { + case "instances": + hasInstances = true + case "orgs": + hasOrgs = true + } + } + + // A brand-new database has neither. That is fine — whichever service starts + // first creates `instances`. + if !hasInstances && !hasOrgs { + return nil + } + if !hasInstances { + return errors.New("instances collection not found; deploy the control plane first") + } + return nil +} + func EmailTaken(ctx context.Context, email string) (bool, error) { n, err := col("users").CountDocuments(ctx, bson.M{"email": email}) return n > 0, err } -func CreatePending(ctx context.Context, orgName, email, password string) (string, error) { - if _, err := provision.BaseSlug(orgName); err != nil { +func CreatePending(ctx context.Context, instanceName, email, password string) (string, error) { + if _, err := provision.BaseSlug(instanceName); err != nil { return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) } @@ -136,8 +157,6 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string return "", err } - - if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil { return "", err } @@ -145,7 +164,7 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string now := time.Now().UTC() pending := models.PendingSignup{ PendingID: uuid.NewString(), - OrgName: strings.TrimSpace(orgName), + InstanceName: strings.TrimSpace(instanceName), Email: email, PasswordHash: string(hash), TokenHash: hashToken(raw), @@ -158,12 +177,7 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string return raw, nil } - - - - - -func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) { +func Verify(ctx context.Context, rawToken string) (*sharedmodels.Instance, error) { var pending models.PendingSignup err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{ "token_hash": hashToken(rawToken), @@ -176,24 +190,24 @@ func Verify(ctx context.Context, rawToken string) (*sharedmodels.Org, error) { return nil, err } - org, err := provision.CreateOrg(ctx, database, pending.OrgName) + inst, err := provision.CreateInstance(ctx, database, pending.InstanceName) if err != nil { return nil, err } // 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, + _, err = provision.CreateUserWithHash(ctx, database, inst.InstanceID, 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) + // Leaving an instance behind would permanently occupy a slug nobody owns. + if rbErr := provision.RollbackInstance(ctx, database, inst.InstanceID); rbErr != nil { + log.Printf("verify: failed to roll back instance %s: %v", inst.InstanceID, rbErr) } return nil, err } - return org, nil + return inst, nil } func randomToken() (string, error) {