# Shared Module Extraction Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extract a `shared` Go module holding the document shapes and provisioning rules that `server` and `sitesvc` both write, deleting the hand-copied duplicates in sitesvc.

**Architecture:** A new Go module `gitea.hostxtra.co.uk/mrhid6/vantage/shared` containing `models`, `provision` and `indexes` packages. `server` and `sitesvc` consume it via `replace` directives plus a root `go.work`. Functions take a `*mongo.Database` handle from the caller so `shared` never owns a connection. Docker build contexts move to the repo root so the `replace` paths resolve.

**Tech Stack:** Go 1.26, MongoDB driver v2, `golang.org/x/crypto/bcrypt`, `github.com/google/uuid`, Docker, Gitea Actions.

**No automated tests.** Verification is by compiler, `grep`, and running the two services end to end against a scratch database. Every task ends with checks that produce observable output.

## Global Constraints

- Go version in every `go.mod`: `go 1.26`
- Module path: `gitea.hostxtra.co.uk/mrhid6/vantage/shared`
- `shared/go.mod` may require **only** these three: `go.mongodb.org/mongo-driver/v2`, `golang.org/x/crypto`, `github.com/google/uuid`. Any addition needs review — this constraint is what keeps sitesvc small.
- `MinSlugLength = 3`, `MaxSlugLength = 40`, `BcryptCost = 12` — exact values, no literals elsewhere.
- Reserved slugs, exactly: `www`, `api`, `app`, `admin`, `auth`, `install`, `static`, `_next`, `default`
- User-facing error text uses **"organisation"** (British spelling) everywhere. The control plane currently says "organization"; that changes in this plan.
- **No renaming.** `Org`, `OrgID`, `org_id`, `orgs` stay exactly as they are. Renaming is plan 0b.
- **No behaviour changes** other than the two explicitly listed in Task 3.
- The `agent` module is not touched by any task in this plan.
- A local MongoDB is needed for the end-to-end checks: `docker run -d -p 27017:27017 --name vantage-dev-mongo mongo:7`

---

## File Structure

**Created:**

| Path                             | Responsibility                                                       |
| -------------------------------- | -------------------------------------------------------------------- |
| `go.work`                        | Workspace over shared, server, sitesvc                               |
| `shared/go.mod`, `shared/go.sum` | Module definition                                                    |
| `shared/models/org.go`           | `Org` document                                                       |
| `shared/models/user.go`          | `User` document, role constants, `ValidRole`                         |
| `shared/models/settings.go`      | `Settings` and sub-structs                                           |
| `shared/provision/slug.go`       | `Slugify`, `BaseSlug`, `NextSlug`, `ReservedSlugs`, length constants |
| `shared/provision/org.go`        | `CreateOrg`, `RollbackOrg`                                           |
| `shared/provision/user.go`       | `CreateUser`, `CreateUserWithHash`, `BcryptCost`                     |
| `shared/indexes/indexes.go`      | `EnsureCoreIndexes`                                                  |

**Modified:**

| Path                                                      | Change                                  |
| --------------------------------------------------------- | --------------------------------------- |
| `server/go.mod`                                           | require + replace shared                |
| `server/internal/models/org.go`, `user.go`, `settings.go` | Replaced by type aliases to shared      |
| `server/internal/services/orgs.go`                        | `CreateOrg` delegates to shared         |
| `server/internal/services/users.go`                       | `CreateUser` delegates to shared        |
| `server/internal/services/migrate.go`                     | `EnsureAuthIndexes` delegates to shared |
| `server/Dockerfile`                                       | Build from repo root                    |
| `sitesvc/go.mod`                                          | require + replace shared                |
| `sitesvc/internal/models/models.go`                       | Reduced to `PendingSignup`              |
| `sitesvc/internal/store/store.go`                         | Uses shared provision                   |
| `sitesvc/Dockerfile`                                      | Build from repo root                    |
| `.gitea/workflows/server-deploy.yml`                      | Root context for the two Go images      |

**Deleted:**

| Path                                      |
| ----------------------------------------- |
| `sitesvc/internal/provision/provision.go` |

---

### Task 1: Scaffold the shared module

**Files:**

- Create: `shared/go.mod`
- Create: `go.work`

**Interfaces:**

- Consumes: nothing
- Produces: an importable but empty module

- [ ] **Step 1: Create the module**

```bash
cd c:/Work/Repos/vantage
mkdir -p shared
cd shared
go mod init gitea.hostxtra.co.uk/mrhid6/vantage/shared
go get go.mongodb.org/mongo-driver/v2@latest
go get golang.org/x/crypto@latest
go get github.com/google/uuid@latest
```

- [ ] **Step 2: Create the workspace**

Create `go.work` at the repo root:

```
go 1.26

use (
	./shared
	./server
	./sitesvc
)
```

`./agent` is deliberately absent. The agent stays standalone with its own release pipeline.

- [ ] **Step 3: Verify the workspace resolves**

Run: `cd c:/Work/Repos/vantage && go work sync && go list -m all | head -5`
Expected: output includes `gitea.hostxtra.co.uk/mrhid6/vantage/shared`, `gitea.hostxtra.co.uk/mrhid6/vantage/server` and `gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc`. No error.

- [ ] **Step 4: Commit**

```bash
git add go.work shared/go.mod shared/go.sum
git commit -m "chore: scaffold shared module"
```

---

### Task 2: Slug rules and models

Pure definitions with no database access. Grouped into one task because neither is independently reviewable — a struct with no consumer and a regex with no caller are the same review.

**Files:**

- Create: `shared/provision/slug.go`
- Create: `shared/models/org.go`
- Create: `shared/models/user.go`
- Create: `shared/models/settings.go`

**Interfaces:**

- Consumes: nothing
- Produces:
    - `const MinSlugLength = 3`, `MaxSlugLength = 40`
    - `var ReservedSlugs map[string]bool`
    - `func Slugify(name string) string`
    - `func BaseSlug(name string) (string, error)`
    - `func NextSlug(base string, attempt int) string` — attempt 1 returns base, attempt 2 returns `base-2`
    - `models.Org`, `models.User`, `models.Settings`, `models.AlertSettings`, `models.EmailSettings`, `models.SecretsSettings`
    - `models.RoleOwner`/`RoleAdmin`/`RoleMember`, `models.ValidRole(string) bool`

- [ ] **Step 1: Write the slug rules**

Create `shared/provision/slug.go`:

```go
// 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)
}
```

- [ ] **Step 2: Create the Org model**

Create `shared/models/org.go`:

```go
// 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"`
}
```

- [ ] **Step 3: Create the User model**

Create `shared/models/user.go`:

```go
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"`
}
```

- [ ] **Step 4: Create the Settings model**

Create `shared/models/settings.go`:

```go
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"`
}
```

- [ ] **Step 5: Verify the bson tags match production exactly**

Without tests, this diff is the only thing standing between you and orphaned production data. A changed bson tag means every existing document silently stops matching.

```bash
cd c:/Work/Repos/vantage
diff <(grep -o 'bson:"[^"]*"' server/internal/models/org.go) \
     <(grep -o 'bson:"[^"]*"' shared/models/org.go)
diff <(grep -o 'bson:"[^"]*"' server/internal/models/user.go) \
     <(grep -o 'bson:"[^"]*"' shared/models/user.go)
diff <(grep -o 'bson:"[^"]*"' server/internal/models/settings.go) \
     <(grep -o 'bson:"[^"]*"' shared/models/settings.go)
```

Expected: **no output from any of the three.** Any output is a defect — stop and fix it before continuing.

- [ ] **Step 6: Sanity-check the slug rules by hand**

Create a throwaway `shared/cmd/slugcheck/main.go`:

```go
package main

import (
	"fmt"

	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
)

func main() {
	for _, n := range []string{"Acme", "Acme Corp", "ACME  CORP", "Acme & Co.", "  Acme  ", "---Acme---", "!!!", "ab", "admin"} {
		base, err := provision.BaseSlug(n)
		fmt.Printf("%-14q slugify=%-12q base=%-12q err=%v\n", n, provision.Slugify(n), base, err)
	}
	fmt.Println(provision.NextSlug("acme", 1), provision.NextSlug("acme", 2), provision.NextSlug("acme", 3))
}
```

Run: `cd shared && go run ./cmd/slugcheck`

Expected output:

```
"Acme"         slugify="acme"        base="acme"        err=<nil>
"Acme Corp"    slugify="acme-corp"   base="acme-corp"   err=<nil>
"ACME  CORP"   slugify="acme-corp"   base="acme-corp"   err=<nil>
"Acme & Co."   slugify="acme-co"     base="acme-co"     err=<nil>
"  Acme  "     slugify="acme"        base="acme"        err=<nil>
"---Acme---"   slugify="acme"        base="acme"        err=<nil>
"!!!"          slugify=""            base=""            err=organisation name too short (slug must be at least 3 characters)
"ab"           slugify="ab"          base=""            err=organisation name too short (slug must be at least 3 characters)
"admin"        slugify="admin"       base=""            err=that organisation name is reserved
acme acme-2 acme-3
```

Then delete it: `rm -rf shared/cmd/slugcheck`

- [ ] **Step 7: Build and commit**

```bash
cd c:/Work/Repos/vantage/shared && go build ./... && go vet ./...
cd ..
git add shared/provision/slug.go shared/models
git commit -m "feat(shared): add slug rules and shared document models"
```

---

### Task 3: Provisioning

The single `CreateOrg`, `RollbackOrg` and `CreateUser`. **Two deliberate behaviour changes**, both adopting sitesvc's version because it is the correct one:

1. On a duplicate-key race the slug loop **retries the next slug** instead of returning an error. The control plane previously failed the request.
2. Error text uses "organisation", not "organization".

**Files:**

- Create: `shared/provision/org.go`
- Create: `shared/provision/user.go`

**Interfaces:**

- Consumes: `models.Org`, `models.User`, `models.ValidRole`, `provision.BaseSlug`, `provision.NextSlug`
- Produces:
    - `var ErrNameRejected error`, `var ErrEmailTaken error`
    - `const BcryptCost = 12`
    - `func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)`
    - `func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error`
    - `func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error)`
    - `func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error)`

`CreateUserWithHash` exists because sitesvc hashes the password at signup time and stores the hash in the pending record; by verification time it holds a hash, not a password. Without it sitesvc would have to insert the document by hand, which is the duplication this plan removes.

- [ ] **Step 1: Write org provisioning**

Create `shared/provision/org.go`:

```go
package provision

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/google/uuid"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

// ErrNameRejected wraps every reason a name cannot become an organisation.
var ErrNameRejected = errors.New("organisation name rejected")

const maxSlugAttempts = 50

// CreateOrg inserts an organisation under the first free slug derived from name.
//
// The count-then-insert loop is racy on its own. It is safe only because
// orgs.slug carries a unique index: a lost race surfaces as a duplicate-key
// error, which we treat as "that slug is taken" and retry. Do not remove the
// duplicate-key branch, and do not remove the index.
func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error) {
	base, err := BaseSlug(name)
	if err != nil {
		return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
	}

	for attempt := 1; attempt <= maxSlugAttempts; attempt++ {
		slug := NextSlug(base, attempt)

		n, err := db.Collection("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 := db.Collection("orgs").InsertOne(ctx, org); err != nil {
			if mongo.IsDuplicateKeyError(err) {
				continue // lost the race; try the next slug
			}
			return nil, err
		}
		return &org, nil
	}
	return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
}

// RollbackOrg deletes an organisation that has no users.
//
// It refuses an organisation that has users. Rollback exists to clean up a
// half-finished signup, and an organisation with users is not half-finished.
func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error {
	n, err := db.Collection("users").CountDocuments(ctx, bson.M{"org_id": orgID})
	if err != nil {
		return err
	}
	if n > 0 {
		return fmt.Errorf("refusing to roll back organisation %s: it has %d user(s)", orgID, n)
	}
	_, err = db.Collection("orgs").DeleteOne(ctx, bson.M{"org_id": orgID})
	return err
}
```

- [ ] **Step 2: Write user provisioning**

Create `shared/provision/user.go`:

```go
package provision

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/google/uuid"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"golang.org/x/crypto/bcrypt"
)

// BcryptCost is the work factor for every password hash Vantage writes.
// Changing it changes nothing about existing hashes, which carry their own cost.
const BcryptCost = 12

// ErrEmailTaken is returned when the unique index on users.email rejects an insert.
var ErrEmailTaken = errors.New("email already registered")

// CreateUser hashes password and inserts the user. An empty password leaves the
// hash empty, which is how OIDC users are stored.
func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role, authSource string) (*models.User, error) {
	var hash string
	if password != "" {
		b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
		if err != nil {
			return nil, err
		}
		hash = string(b)
	}
	return CreateUserWithHash(ctx, db, orgID, email, hash, role, authSource)
}

// CreateUserWithHash inserts a user whose password was already hashed
// elsewhere. sitesvc hashes at signup and only holds the hash by the time the
// verification link is opened.
func CreateUserWithHash(ctx context.Context, db *mongo.Database, orgID, email, passwordHash, role, authSource string) (*models.User, error) {
	email = strings.ToLower(strings.TrimSpace(email))
	if email == "" {
		return nil, fmt.Errorf("email required")
	}
	if !models.ValidRole(role) {
		return nil, fmt.Errorf("invalid role %q", role)
	}

	u := &models.User{
		UserID:       uuid.NewString(),
		OrgID:        orgID,
		Email:        email,
		PasswordHash: passwordHash,
		Role:         role,
		AuthSource:   authSource,
		CreatedAt:    time.Now().UTC(),
	}
	if _, err := db.Collection("users").InsertOne(ctx, u); err != nil {
		if mongo.IsDuplicateKeyError(err) {
			return nil, ErrEmailTaken
		}
		return nil, err
	}
	return u, nil
}
```

- [ ] **Step 3: Build**

Run: `cd shared && go build ./... && go vet ./...`
Expected: no output.

- [ ] **Step 4: Commit**

```bash
git add shared/provision/org.go shared/provision/user.go
git commit -m "feat(shared): add CreateOrg, RollbackOrg and CreateUser

Adopts sitesvc's retry-on-duplicate-key slug loop. The control plane
previously returned an error when it lost the slug race."
```

---

### Task 4: Core indexes

`users.email` and `orgs.slug` unique indexes are a tenant-isolation property, not an optimisation — `GetUserByEmail` does an unscoped `FindOne`, so a duplicate email would let the OIDC cross-org guard compare against an arbitrary user. Both services declare them, and both treat failure as fatal.

**Files:**

- Create: `shared/indexes/indexes.go`

**Interfaces:**

- Consumes: nothing
- Produces: `func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error`

- [ ] **Step 1: Write it**

Create `shared/indexes/indexes.go`:

```go
// Package indexes declares the MongoDB indexes more than one Vantage service
// depends on.
package indexes

import (
	"context"
	"fmt"

	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// EnsureCoreIndexes declares the unique indexes on users.email and orgs.slug.
//
// These are a security property, not an optimisation. GetUserByEmail does an
// unscoped FindOne, so a duplicate email would let the OIDC cross-org guard
// compare against an arbitrary user. Every caller must treat a failure here as
// fatal.
//
// Creating an index that already exists with the same specification is a no-op,
// so this is safe to call at every boot from every service.
func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
	if _, err := db.Collection("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 := db.Collection("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)
	}

	return nil
}
```

- [ ] **Step 2: Build and confirm the dependency list is still clean**

```bash
cd c:/Work/Repos/vantage/shared
go build ./... && go vet ./...
go list -m all | grep -Ev "^gitea.hostxtra.co.uk/mrhid6/vantage/shared$|mongo-driver|golang.org/x|github.com/google/uuid|github.com/golang/snappy|github.com/klauspost|github.com/xdg-go|github.com/youmark|go.mongodb.org"
```

Expected: no output from the build, and no unexpected module from the list. Gin, redis or guac appearing means something was moved into `shared` that should not have been.

- [ ] **Step 3: Commit**

```bash
git add shared/indexes
git commit -m "feat(shared): add EnsureCoreIndexes"
```

---

### Task 5: Wire the control plane to shared

Server keeps its own package paths so no call site outside these files changes. `server/internal/models` re-exports the shared types as aliases — a type alias is identical to the aliased type, so `models.Org` in existing server code keeps working untouched.

**Files:**

- Modify: `server/go.mod`
- Modify: `server/internal/models/org.go`, `user.go`, `settings.go`
- Modify: `server/internal/services/orgs.go` (`CreateOrg`, `AdoptOrg`, delete `reservedSlugs`)
- Modify: `server/internal/services/users.go` (`CreateUser`)
- Modify: `server/internal/services/migrate.go` (`EnsureAuthIndexes`)

**Interfaces:**

- Consumes: everything from Tasks 2–4
- Produces: no new exported API. `services.CreateOrg(name string) (*models.Org, error)` and `services.CreateUser(orgID, email, password, role, authSource string) (*models.User, error)` keep their exact signatures.

- [ ] **Step 1: Add the dependency**

```bash
cd c:/Work/Repos/vantage/server
go mod edit -require=gitea.hostxtra.co.uk/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=gitea.hostxtra.co.uk/mrhid6/vantage/shared=../shared
go mod tidy
```

- [ ] **Step 2: Alias the models**

Replace the entire contents of `server/internal/models/org.go` with:

```go
package models

import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"

// Org is defined in the shared module because sitesvc writes the same
// documents. Aliased rather than re-declared so existing call sites are
// unchanged and the two services cannot drift.
type Org = shared.Org
```

Replace the entire contents of `server/internal/models/user.go` with:

```go
package models

import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"

type User = shared.User

const (
	RoleOwner  = shared.RoleOwner
	RoleAdmin  = shared.RoleAdmin
	RoleMember = shared.RoleMember
)

func ValidRole(role string) bool { return shared.ValidRole(role) }
```

Replace the entire contents of `server/internal/models/settings.go` with:

```go
package models

import shared "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"

type (
	Settings        = shared.Settings
	AlertSettings   = shared.AlertSettings
	EmailSettings   = shared.EmailSettings
	SecretsSettings = shared.SecretsSettings
)
```

- [ ] **Step 3: Verify the build still passes**

Run: `cd server && go build ./...`
Expected: no output. If anything fails, an alias is missing — add it rather than reverting.

- [ ] **Step 4: Delegate CreateOrg**

In `server/internal/services/orgs.go`, delete the `reservedSlugs` package variable and replace the whole `CreateOrg` function with:

```go
// CreateOrg creates an organisation and seeds its default workflow steps.
//
// The creation rules live in shared/provision because sitesvc creates
// organisations too. Seeding stays here: shared must not know about workflow
// steps.
func CreateOrg(name string) (*models.Org, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	o, err := provision.CreateOrg(ctx, db.Database, name)
	if err != nil {
		return nil, err
	}

	if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil {
		log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err)
	} else {
		log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated)
	}
	return o, nil
}
```

`AdoptOrg` also references `reservedSlugs` and `Slugify` and hard-codes the lengths `3` and `40`. Change its references to `provision.ReservedSlugs`, `provision.Slugify`, `provision.MinSlugLength` and `provision.MaxSlugLength`.

Add to the import block:

```go
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
```

Remove `"github.com/google/uuid"` and `"go.mongodb.org/mongo-driver/v2/mongo"` **only if** nothing else in the file still uses them — `go build` will tell you.

- [ ] **Step 5: Delegate CreateUser**

In `server/internal/services/users.go`, replace the whole `CreateUser` function with:

```go
func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	u, err := provision.CreateUser(ctx, db.Database, orgID, email, password, role, authSource)
	if errors.Is(err, provision.ErrEmailTaken) {
		return nil, fmt.Errorf("email already registered")
	}
	return u, err
}
```

The `ErrEmailTaken` translation preserves the exact error string the API returns today. Add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"` to the imports.

- [ ] **Step 6: Delegate EnsureAuthIndexes**

In `server/internal/services/migrate.go`, replace the body of `EnsureAuthIndexes` with:

```go
func EnsureAuthIndexes() error {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	return indexes.EnsureCoreIndexes(ctx, db.Database)
}
```

Add `"gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"` to the imports.

- [ ] **Step 7: Build and vet**

```bash
cd c:/Work/Repos/vantage/server
go build ./... && go vet ./...
```

Expected: no output from either.

- [ ] **Step 8: Confirm no duplicated rules remain in the server**

```bash
cd c:/Work/Repos/vantage
grep -rn "reservedSlugs\|bcrypt.GenerateFromPassword\|func Slugify" server/internal/
```

Expected: no hits in `services/orgs.go` or `services/users.go`. Hits elsewhere — a password-change handler, for example — are fine **only** if they use `provision.BcryptCost` rather than a literal `12`. Fix any that do not.

- [ ] **Step 9: Commit**

```bash
git add server/go.mod server/go.sum server/internal/models server/internal/services
git commit -m "refactor(server): use shared models, provision and indexes"
```

---

### Task 6: Wire sitesvc to shared and delete the duplicates

**Files:**

- Modify: `sitesvc/go.mod`
- Modify: `sitesvc/internal/models/models.go`
- Modify: `sitesvc/internal/store/store.go`
- Delete: `sitesvc/internal/provision/provision.go`

**Interfaces:**

- Consumes: everything from Tasks 2–4
- Produces: `store.Verify(ctx, rawToken) (*sharedmodels.Org, error)`. `store.CreatePending`, `store.EmailTaken`, `store.Connect`, `store.EnsureIndexes` keep their signatures.

- [ ] **Step 1: Add the dependency**

```bash
cd c:/Work/Repos/vantage/sitesvc
go mod edit -require=gitea.hostxtra.co.uk/mrhid6/vantage/shared@v0.0.0
go mod edit -replace=gitea.hostxtra.co.uk/mrhid6/vantage/shared=../shared
go mod tidy
```

- [ ] **Step 2: Reduce the models file**

Replace the entire contents of `sitesvc/internal/models/models.go` with:

```go
package models

import (
	"time"

	"go.mongodb.org/mongo-driver/v2/bson"
)

// 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
// gitea.hostxtra.co.uk/mrhid6/vantage/shared/models, which is the only copy.
type PendingSignup struct {
	ID           bson.ObjectID `bson:"_id,omitempty"`
	PendingID    string        `bson:"pending_id"`
	OrgName      string        `bson:"org_name"`
	Email        string        `bson:"email"`
	PasswordHash string        `bson:"password_hash"`
	TokenHash    string        `bson:"token_hash"`
	CreatedAt    time.Time     `bson:"created_at"`
	ExpiresAt    time.Time     `bson:"expires_at"`
}
```

- [ ] **Step 3: Delete the duplicated rules**

```bash
cd c:/Work/Repos/vantage
rm -rf sitesvc/internal/provision
```

- [ ] **Step 4: Rewrite the store's provisioning paths**

In `sitesvc/internal/store/store.go`:

Replace the two local imports

```go
	"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/provision"
```

with

```go
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/indexes"
	sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
	"gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc/internal/models"
```

Replace the error variables so `errors.Is` keeps working for existing callers in `internal/api`:

```go
var (
	ErrEmailTaken   = provision.ErrEmailTaken
	ErrBadToken     = errors.New("verification link is invalid or has expired")
	ErrNameRejected = provision.ErrNameRejected
)
```

Replace `EnsureIndexes` with:

```go
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
	// 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{
		{
			Keys:    bson.D{{Key: "token_hash", Value: 1}},
			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),
		},
	})
	if err != nil {
		return fmt.Errorf("pending signup indexes: %w", err)
	}
	return nil
}
```

Delete the local `createOrg` and `rollbackOrg` functions entirely.

Replace `Verify` with:

```go
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),
		"expires_at": bson.M{"$gt": time.Now().UTC()},
	}).Decode(&pending)
	if errors.Is(err, mongo.ErrNoDocuments) {
		return nil, ErrBadToken
	}
	if err != nil {
		return nil, err
	}

	org, err := provision.CreateOrg(ctx, database, pending.OrgName)
	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,
		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)
		}
		return nil, err
	}

	return org, nil
}
```

`CreatePending` already calls `provision.BaseSlug` and `provision.BcryptCost`; those now resolve to the shared package with no line changes beyond the import swap.

- [ ] **Step 5: Build and vet**

```bash
cd c:/Work/Repos/vantage/sitesvc
go build ./... && go vet ./...
```

Expected: no output. If `internal/api` fails because `Verify` now returns a different `*models.Org`, update its import to `sharedmodels` there too.

- [ ] **Step 6: Confirm the duplication is gone**

```bash
cd c:/Work/Repos/vantage
grep -rn "func Slugify\|ReservedSlugs =\|BcryptCost =\|bson:\"org_id\"" sitesvc/
```

Expected: no output. Any `provision.`-qualified _references_ are fine; what must be gone are local _definitions_.

- [ ] **Step 7: Commit**

```bash
git add sitesvc/
git commit -m "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."
```

---

### Task 7: Docker and CI

`replace => ../shared` cannot resolve when the build context is the module directory. Both Go images build from the repo root instead.

**Files:**

- Modify: `server/Dockerfile`, `sitesvc/Dockerfile`
- Modify: `.gitea/workflows/server-deploy.yml`

**Interfaces:**

- Consumes: the module layout from Tasks 1–6
- Produces: images identical in content to today's, built from a different context

- [ ] **Step 1: Rewrite the server Dockerfile build stage**

Replace the build stage of `server/Dockerfile` with:

```dockerfile
# Build stage
#
# Context is the repository root, not server/, because server depends on the
# shared module through a replace directive.
FROM golang:1.26 AS builder

WORKDIR /src

# Manifests first so the dependency layer caches independently of source edits.
COPY shared/go.mod shared/go.sum ./shared/
COPY server/go.mod server/go.sum ./server/
RUN cd server && go mod download

COPY shared/ ./shared/
COPY server/ ./server/

ARG VERSION=dev
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
      -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
```

Leave the runtime stage exactly as it is.

- [ ] **Step 2: Rewrite the sitesvc Dockerfile build stage**

Replace the build stage of `sitesvc/Dockerfile` with:

```dockerfile
# Context is the repository root; sitesvc depends on the shared module.
FROM golang:1.26-alpine AS builder

WORKDIR /src

COPY shared/go.mod shared/go.sum ./shared/
COPY sitesvc/go.mod sitesvc/go.sum ./sitesvc/
RUN cd sitesvc && go mod download

COPY shared/ ./shared/
COPY sitesvc/ ./sitesvc/

RUN cd sitesvc && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd
```

Leave the runtime stage exactly as it is.

- [ ] **Step 3: Build both images locally**

```bash
cd c:/Work/Repos/vantage
docker build -f server/Dockerfile -t vantage-server:test .
docker build -f sitesvc/Dockerfile -t vantage-sitesvc:test .
```

Expected: both succeed, each ending with `naming to docker.io/library/vantage-...:test`.

- [ ] **Step 4: Update the CI workflow**

In `.gitea/workflows/server-deploy.yml`, for the `server` and `sitesvc` image build steps only:

```yaml
context: .
file: server/Dockerfile
```

and

```yaml
context: .
file: sitesvc/Dockerfile
```

Leave the `web` and `site` build steps untouched — they are Node images and do not use the shared module.

- [ ] **Step 5: Confirm the agent pipeline is untouched**

```bash
cd c:/Work/Repos/vantage
git diff --name-only HEAD -- .gitea/workflows/agent-release.yml agent/
```

Expected: no output. If either appears, revert those changes — the agent is explicitly out of scope.

- [ ] **Step 6: Commit**

```bash
git add server/Dockerfile sitesvc/Dockerfile .gitea/workflows/server-deploy.yml
git commit -m "build: build Go images from the repo root for the shared module"
```

---

### Task 8: End-to-end verification

No new code. This is the gate, and with no automated tests it is the **only** evidence the refactor worked. Every step must be performed and its output observed — not assumed.

**Files:** none

- [ ] **Step 1: Build everything, including the agent**

```bash
cd c:/Work/Repos/vantage
(cd shared  && go build ./... && go vet ./...)
(cd server  && go build ./... && go vet ./...)
(cd sitesvc && go build ./... && go vet ./...)
(cd agent   && go build ./... && go vet ./...)
```

Expected: no output from any of the eight commands.

- [ ] **Step 2: Cross-compile the agent**

```bash
cd c:/Work/Repos/vantage/agent
GOOS=linux   GOARCH=amd64 go build -o /tmp/a1     ./cmd
GOOS=linux   GOARCH=arm64 go build -o /tmp/a2     ./cmd
GOOS=windows GOARCH=amd64 go build -o /tmp/a3.exe ./cmd
```

Expected: all three succeed. This proves the workspace did not leak into the agent's build.

- [ ] **Step 3: Boot the control plane against a scratch database**

```bash
cd c:/Work/Repos/vantage/server
MONGO_URI=mongodb://localhost:27017 MONGO_DB=vantage_scratch \
  GRPC_HOST=localhost:9090 go run ./cmd
```

Expected in the logs: `connected to MongoDB`, no index error, no migration error, and the HTTP listener starting.

Confirm the indexes were created — in `mongosh`:

```javascript
use vantage_scratch
db.users.getIndexes()
db.orgs.getIndexes()
```

Expected: a unique index on `email` and a unique index on `slug` respectively.

- [ ] **Step 4: Bootstrap through the control plane**

With the server still running:

```bash
curl -s localhost:8080/auth/bootstrap-status
curl -s -X POST localhost:8080/auth/bootstrap \
  -H 'Content-Type: application/json' \
  -d '{"org_name":"Acme Corp","email":"owner@example.com","password":"hunter2hunter2"}'
```

Expected: bootstrap succeeds. Then in `mongosh`:

```javascript
db.orgs.findOne({}, { org_id: 1, name: 1, slug: 1 });
```

Expected: `slug: "acme-corp"`, a non-empty `org_id`, `name: "Acme Corp"`.

This exercises `shared.CreateOrg` and `shared.CreateUser` through the control plane.

- [ ] **Step 5: Confirm slug collision handling**

```bash
curl -s -X POST localhost:8080/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"owner@example.com","password":"hunter2hunter2"}' -c /tmp/c.txt
```

Then create two more organisations named `Acme Corp` — through the UI, or directly in `mongosh` by calling the server again if a bootstrap-only path is not available. Confirm:

```javascript
db.orgs.find({}, { slug: 1 }).sort({ slug: 1 });
```

Expected: `acme-corp`, `acme-corp-2`, `acme-corp-3`.

- [ ] **Step 6: The end-to-end agreement test**

**This is the step that proves the refactor worked.** It confirms the two services still agree about the documents they share.

Start sitesvc against the same scratch database:

```bash
cd c:/Work/Repos/vantage/sitesvc
MONGO_URI=mongodb://localhost:27017/vantage_scratch \
  PUBLIC_URL=http://localhost:8082 \
  SITE_ORIGIN=http://localhost:3001 \
  SMTP_HOST=localhost SMTP_PORT=1025 SMTP_FROM=noreply@example.com \
  go run ./cmd
```

Use a local mail catcher for SMTP: `docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog`

Then:

1. `curl -s -X POST localhost:8082/api/signup -H 'Content-Type: application/json' -d '{"org_name":"Globex Ltd","email":"new@example.com","password":"hunter2hunter2"}'`
2. Open MailHog at `http://localhost:8025`, copy the verification link.
3. Open the verification link.
4. Log into the control plane with the new credentials:

```bash
curl -s -X POST localhost:8080/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"new@example.com","password":"hunter2hunter2"}' -c /tmp/c2.txt
curl -s localhost:8080/auth/me -b /tmp/c2.txt
```

Expected: login succeeds and `/auth/me` returns the org sitesvc created, with slug `globex-ltd`.

**If login fails, the two services disagree about a document shape. The cause is in Task 5 or 6.** Do not proceed.

- [ ] **Step 7: Confirm rollback still refuses**

Create a throwaway `shared/cmd/rbcheck/main.go`:

```go
package main

import (
	"context"
	"fmt"
	"os"

	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

func main() {
	c, err := mongo.Connect(options.Client().ApplyURI("mongodb://localhost:27017"))
	if err != nil {
		panic(err)
	}
	err = provision.RollbackOrg(context.Background(), c.Database("vantage_scratch"), os.Args[1])
	fmt.Println("err =", err)
}
```

Run it with the `org_id` of an org that has a user:

```bash
cd shared && go run ./cmd/rbcheck <org-id-with-a-user>
```

Expected: `err = refusing to roll back organisation <id>: it has 1 user(s)`

Then run it with a manually inserted org that has no users. Expected: `err = <nil>`, and the org is gone.

Delete the throwaway: `rm -rf shared/cmd/rbcheck`

- [ ] **Step 8: Confirm the duplication is gone repo-wide**

```bash
cd c:/Work/Repos/vantage
grep -rn "func Slugify" --include=*.go .
grep -rn "ReservedSlugs = map" --include=*.go .
grep -rn "bson:\"org_id\"" --include=*.go .
```

Expected:

- `func Slugify` — exactly one hit, in `shared/provision/slug.go`
- `ReservedSlugs = map` — exactly one hit, in `shared/provision/slug.go`
- `bson:"org_id"` — hits only in `shared/models/` and `server/internal/models/` for server-only documents. **No hit anywhere under `sitesvc/`.**

- [ ] **Step 9: Drop the scratch database and commit**

```javascript
use vantage_scratch
db.dropDatabase()
```

```bash
git add -A
git commit -m "chore: verify shared module extraction end to end"
```

---

## Rollout

Single release, no database migration, no downtime beyond the normal restart.

`server` and `sitesvc` images are built from one commit and deployed together. A version skew is harmless here because no document changed — but there is no reason to split it.

```bash
cd /opt/vantage && \
  docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
  docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
```

Post-deploy checks:

1. Control plane login works.
2. A signup through the marketing site completes and the new owner can log in.
3. Server logs show no index errors at boot.

## What this unblocks

Plan 0b (`instance-rename`) becomes a rename inside one module plus its two
consumers, rather than a rename across three independent copies of the same
structs. That is the reason this plan goes first.
