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.
This commit is contained in:
2026-07-24 14:00:11 +01:00
parent 3891a2c239
commit 3f0f12b111
7 changed files with 70 additions and 63 deletions
+7
View File
@@ -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)
}
-6
View File
@@ -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
+15 -15
View File
@@ -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 (<slug>.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 (<slug>.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 {
-8
View File
@@ -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 {
+2 -2
View File
@@ -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, "")
}
+1 -1
View File
@@ -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"`
+45 -31
View File
@@ -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) {