# Cloud Instance Creation — Phase 3: Accounts, People and Membership

> **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:** An HQ account becomes a team: it can invite people, give each of them access to individual cloud instances at a chosen role, revoke that access, and change one password that reaches every instance they belong to.

**Architecture:** Grants **project, they do not federate** — granting a person access to a cloud instance writes a real control-plane `users` row with `auth_source: "hq"` and `hq_user_id`, and the instance authenticates it exactly as it authenticates anyone else, with no runtime dependency on admin. Because two writers of one row is two answers, the control plane refuses to change an `hq`-sourced row's role or existence locally, and HQ's password is the single source of truth, propagated best-effort and repaired by a 15-minute reconciler pass.

**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, Next.js 16, TanStack Query, `shared/provision`, `shared/models`.

## Global Constraints

- **No automated Go tests.** This repo has no Go test suite. Verification is by compiler, `grep`, and running built images against scratch databases. Every "confirm" step is a command with expected output. Do not add `*_test.go` files.
- **Never run `go` or `npm` on the host.** Everything runs in a container. The wrapper already exists at `/tmp/gorun.sh`:
    ```sh
    # /tmp/gorun.sh <module-dir> <command...>
    DIR="$1"; shift
    MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \
      -v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" \
      golang:1.26 "$@"
    ```
- **`MSYS_NO_PATHCONV=1` on every `docker` call.** Git Bash rewrites container paths otherwise.
- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry".
- **Admin's control-plane writes stay confined to `cloudprov`.** It writes `instances` and `users` and nothing else. `inject` still writes exactly `license_blob`, `license_tier`, `license_expiry`. **Do not widen `inject`** — the password reconciliation pass added by this phase lives in its own package, `hqsync`, precisely so `inject` stays licence-only.
- **Customer endpoints answer 404, never 403,** for another account's resource. Every handler naming an instance goes through `ownedInstance`.
- **Self-hosted instances are never projected into.** Every membership endpoint refuses when `deployment != cloud`. The customer's own deployment is theirs; we cannot see it and have no business writing to it.
- **Only cloud roles are `owner`/`admin`/`member`, and account roles use the same three words on purpose.** Do not invent a second vocabulary.
- Paddle is **out of scope**. Billing stays owner-only by role check, but no billing behaviour changes here.

## Context this plan inherits

Phase 1 and phase 2 shipped (`da3afca`..`aef5811`, on `main`, **not yet pushed**):

- `users` is unique on `(instance_id, email)`. The same address may hold a user in several instances — that is what makes a grant possible at all.
- `shared/models.User` already carries `AuthSource` and `HQUserID`, and the constants `AuthLocal`, `AuthOIDC`, `AuthHQ`.
- `admin/internal/cloudprov` exists and writes the control plane's `instances` and `users`. `CreateInstance` already sets `auth_source: "hq"` and `hq_user_id` on the owner it creates.
- `admin/internal/auth.CreateCustomerUser` already does the unverified-row-plus-verification-email dance and rolls the row back when the email fails.
- `POST /api/instances` creates an instance plus an `admin_instances` row. **It does not create an `instance_members` row — that collection does not exist yet.** Task 1 creates it and backfills the owners phase 2 left implicit.
- `customer_users` has **no** `account_role` field. Every existing row is an account creator, so they all backfill to `owner`.
- The control plane has **no local password-change endpoint at all** (`grep -n "password" server/internal/api/*.go` finds only the console's RDP password and the create-user body). So "the instance refuses to change an `hq`-sourced user's password locally" needs no code: there is no such path to refuse. What does need refusing is role change and deletion — Task 7.

Spec: [`docs/superpowers/specs/2026-07-26-cloud-instance-creation-design.md`](../specs/2026-07-26-cloud-instance-creation-design.md), "Phase 3 — accounts, people and membership".

---

## File Structure

**Created:**

| Path                                             | Responsibility                                                           |
| ------------------------------------------------ | ------------------------------------------------------------------------ |
| `admin/internal/models/members.go`               | `InstanceMember`, account-role constants                                 |
| `admin/internal/db/backfill.go`                  | one-shot boot backfill: account roles, and members for phase-2 instances |
| `admin/internal/api/people.go`                   | account people: list, invite, role, delete, password                     |
| `admin/internal/api/members.go`                  | instance members: list, grant, role, revoke                              |
| `admin/internal/hqsync/hqsync.go`                | the 15-minute password repair pass                                       |
| `adminsite/app/(customer)/users/page.tsx`        | the account's people                                                     |
| `adminsite/app/(customer)/users/InvitePanel.tsx` | invite form + people table                                               |
| `adminsite/app/(customer)/settings/page.tsx`     | change password                                                          |
| `adminsite/components/MembersPanel.tsx`          | who is on one instance                                                   |
| `adminsite/app/accept-invite/page.tsx`           | an invitee sets their own password                                       |

**Modified:**

| Path                                                    | Change                                                                                              |
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `admin/internal/models/models.go`                       | `CustomerUser.AccountRole`, `HQSyncFailedAt`                                                        |
| `admin/internal/db/db.go`                               | `instance_members` indexes                                                                          |
| `admin/internal/auth/customer.go`                       | `CreateInvitedUser`, `HandleAcceptInvite`, verify peek, role on signup                              |
| `admin/internal/auth/middleware.go`                     | `RequireAccountRole`, `CurrentUser`                                                                 |
| `admin/internal/cloudprov/cloudprov.go`                 | `GrantUser`, `RevokeUser`, `SetMemberRole`, `CountOtherOwners`, `SetPasswordHash`, `ProjectedUsers` |
| `admin/internal/api/customer.go`                        | `createInstance` writes the owner's `instance_members` row                                          |
| `admin/internal/api/routes.go`                          | the ten new customer routes                                                                         |
| `admin/internal/mail/mail.go`                           | `SendInvite`                                                                                        |
| `admin/cmd/main.go`                                     | run the backfill, start `hqsync`                                                                    |
| `server/internal/services/users.go`                     | `ErrHQManaged` on role change and delete                                                            |
| `server/internal/api/instance.go`                       | map `ErrHQManaged` to 409                                                                           |
| `web/lib/api.ts`                                        | `auth_source: "hq"`, `hq_user_id` on `InstanceUser`                                                 |
| `web/app/(app)/settings/instance/page.tsx`              | read-only treatment for `hq` rows                                                                   |
| `web/Dockerfile`, `.gitea/workflows/server-deploy.yml`  | `NEXT_PUBLIC_HQ_URL`                                                                                |
| `adminsite/lib/api.ts`                                  | member/people/password calls, `AccountRole`                                                         |
| `adminsite/app/(customer)/layout.tsx`                   | People and Settings nav                                                                             |
| `adminsite/app/(customer)/instances/[id]/page.tsx`      | mount `MembersPanel`                                                                                |
| `adminsite/app/(customer)/instances/new/CreateForm.tsx` | the password copy is now a lie; fix it                                                              |
| `adminsite/app/verify/page.tsx`                         | route an invite token to `/accept-invite`                                                           |
| `CLAUDE.md`                                             | membership model, the new routes, `NEXT_PUBLIC_HQ_URL`                                              |

---

### Task 1: The membership model, its indexes, and the phase-2 backfill

**Files:**

- Create: `admin/internal/models/members.go`, `admin/internal/db/backfill.go`
- Modify: `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`

**Interfaces:**

- Consumes: `db.Admin`, `db.Control`, `shared/models.RoleOwner`, `shared/license.DeploymentCloud`.
- Produces:
    - `models.AccountRoleOwner|AccountRoleAdmin|AccountRoleMember string`, `models.ValidAccountRole(string) bool`, `models.AccountRoleAtLeastAdmin(string) bool`
    - `models.InstanceMember` struct
    - `models.CustomerUser.AccountRole string`, `models.CustomerUser.HQSyncFailedAt *time.Time`
    - `db.Backfill(ctx context.Context) error`

- [ ] **Step 1: Add the account-role constants and the member document**

Create `admin/internal/models/members.go`:

```go
package models

import (
	"time"

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

// Account roles.
//
// Deliberately the same three words as the control plane's own roles rather
// than a second vocabulary: a customer who reads "admin" in the portal and
// "admin" in their instance should not have to learn that they mean different
// things. They govern different scopes — this one governs the HQ account —
// but they mean the same thing about power.
//
// Billing stays owner-only. Owners and admins may invite people, create
// instances and grant instance access.
const (
	AccountRoleOwner  = "owner"
	AccountRoleAdmin  = "admin"
	AccountRoleMember = "member"
)

func ValidAccountRole(r string) bool {
	switch r {
	case AccountRoleOwner, AccountRoleAdmin, AccountRoleMember:
		return true
	}
	return false
}

// AccountRoleAtLeastAdmin is the single definition of "may manage people and
// instances". Every guard calls this rather than comparing strings, so widening
// the rule is one edit.
func AccountRoleAtLeastAdmin(r string) bool {
	return r == AccountRoleOwner || r == AccountRoleAdmin
}

// InstanceMember records that one HQ person holds a projected user inside one
// cloud instance.
//
// It is admin's index of the projection, not the authority: the control-plane
// `users` row IS the access. This row exists so the portal can list who is on
// an instance without reading the control plane, and so a password change can
// find every row to update without scanning every instance.
//
// ControlUserID is the projected users.user_id. Role is the role that user
// holds INSIDE the instance, which is not the person's account role.
type InstanceMember struct {
	ID             bson.ObjectID `bson:"_id,omitempty" json:"-"`
	MemberID       string        `bson:"member_id" json:"member_id"`
	AccountID      string        `bson:"account_id" json:"account_id"`
	InstanceID     string        `bson:"instance_id" json:"instance_id"`
	CustomerUserID string        `bson:"customer_user_id" json:"customer_user_id"`
	ControlUserID  string        `bson:"control_user_id" json:"control_user_id"`
	Role           string        `bson:"role" json:"role"`
	Email          string        `bson:"email" json:"email"`
	CreatedAt      time.Time     `bson:"created_at" json:"created_at"`
}
```

- [ ] **Step 2: Add the two new `CustomerUser` fields**

In `admin/internal/models/models.go`, replace the `CustomerUser` struct and its doc comment with:

```go
// CustomerUser is one person on an HQ account.
//
// AccountRole governs what they may do to the ACCOUNT — invite people, create
// instances, grant access. It says nothing about what they may do inside any
// instance; that is the role on their InstanceMember row.
type CustomerUser struct {
	ID                bson.ObjectID `bson:"_id,omitempty" json:"-"`
	UserID            string        `bson:"user_id" json:"user_id"`
	AccountID         string        `bson:"account_id" json:"account_id"`
	Email             string        `bson:"email" json:"email"`
	PasswordHash      string        `bson:"password_hash" json:"-"`
	AccountRole       string        `bson:"account_role" json:"account_role"`
	VerifiedAt        *time.Time    `bson:"verified_at,omitempty" json:"verified_at,omitempty"`
	VerifyTokenHash   string        `bson:"verify_token_hash,omitempty" json:"-"`
	VerifyTokenExpiry *time.Time    `bson:"verify_token_expiry,omitempty" json:"-"`
	// HQSyncFailedAt is set when a password change could not be written to
	// every projected control-plane row. It is visibility only — hqsync repairs
	// by comparing hashes, not by reading this field.
	HQSyncFailedAt *time.Time `bson:"hq_sync_failed_at,omitempty" json:"hq_sync_failed_at,omitempty"`
	CreatedAt      time.Time  `bson:"created_at" json:"created_at"`
}
```

- [ ] **Step 3: Index `instance_members`**

In `admin/internal/db/db.go`, inside `EnsureIndexes`, immediately before the closing `return nil`, add:

```go
	// One person holds at most one user in one instance. This is the property
	// that makes a grant idempotent-by-refusal rather than silently doubling a
	// projection, and it mirrors users' own (instance_id, email) uniqueness.
	if _, err := Admin("instance_members").Indexes().CreateOne(ctx, mongo.IndexModel{
		Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "customer_user_id", Value: 1}},
		Options: options.Index().SetUnique(true).
			SetName("instance_customer_user_unique"),
	}); err != nil {
		return fmt.Errorf("index instance_members.(instance_id,customer_user_id): %w", err)
	}
	for _, keys := range []bson.D{
		{{Key: "account_id", Value: 1}},
		{{Key: "customer_user_id", Value: 1}},
	} {
		if _, err := Admin("instance_members").Indexes().CreateOne(ctx,
			mongo.IndexModel{Keys: keys}); err != nil {
			return fmt.Errorf("index instance_members: %w", err)
		}
	}
```

- [ ] **Step 4: Write the backfill**

Create `admin/internal/db/backfill.go`:

```go
package db

import (
	"context"
	"log"
	"time"

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

// Backfill brings pre-phase-3 data up to the membership model.
//
// It runs on every boot and is idempotent by construction: both passes filter
// on the absence of what they write. There is no migrations collection in
// admin, and adding one for two `$exists: false` queries would be more
// machinery than the job deserves.
func Backfill(ctx context.Context) error {
	// Pass 1: every existing customer_user created their own account, so they
	// are all owners. A row with no account_role would otherwise be able to do
	// nothing at all once the guards land — including managing the account it
	// created.
	res, err := Admin("customer_users").UpdateMany(ctx,
		bson.M{"account_role": bson.M{"$exists": false}},
		bson.M{"$set": bson.M{"account_role": models.AccountRoleOwner}})
	if err != nil {
		return err
	}
	if res.ModifiedCount > 0 {
		log.Printf("backfill: set account_role=owner on %d customer_users", res.ModifiedCount)
	}

	// Pass 2: phase 2 created cloud instances and their owners without an
	// instance_members row, because the collection did not exist. Reconstruct
	// one per instance from the control-plane owner it actually created.
	cur, err := Admin("admin_instances").Find(ctx, bson.M{
		"deployment": license.DeploymentCloud,
		"status":     bson.M{"$ne": models.StatusDeleted},
	})
	if err != nil {
		return err
	}
	var instances []models.Instance
	if err := cur.All(ctx, &instances); err != nil {
		return err
	}

	created := 0
	for _, inst := range instances {
		n, err := Admin("instance_members").CountDocuments(ctx,
			bson.M{"instance_id": inst.InstanceID})
		if err != nil {
			return err
		}
		if n > 0 {
			continue
		}

		// Only an hq-sourced owner can be reconstructed: a control-plane owner
		// with no hq_user_id was created inside the instance and belongs to
		// nobody on this side. Leaving it unrecorded is correct.
		var owner sharedmodels.User
		err = Control("users").FindOne(ctx, bson.M{
			"instance_id": inst.InstanceID,
			"role":        sharedmodels.RoleOwner,
			"hq_user_id":  bson.M{"$nin": bson.A{nil, ""}},
		}).Decode(&owner)
		if err != nil {
			if err != mongo.ErrNoDocuments {
				return err
			}
			log.Printf("backfill: instance %s has no hq-sourced owner; left unrecorded", inst.InstanceID)
			continue
		}

		if _, err := Admin("instance_members").InsertOne(ctx, models.InstanceMember{
			MemberID:       uuid.NewString(),
			AccountID:      inst.AccountID,
			InstanceID:     inst.InstanceID,
			CustomerUserID: owner.HQUserID,
			ControlUserID:  owner.UserID,
			Role:           sharedmodels.RoleOwner,
			Email:          owner.Email,
			CreatedAt:      time.Now().UTC(),
		}); err != nil {
			return err
		}
		created++
	}
	if created > 0 {
		log.Printf("backfill: recorded %d pre-existing instance owners", created)
	}
	return nil
}
```

- [ ] **Step 5: Run the backfill at boot**

In `admin/cmd/main.go`, inside the `idxCtx` block, after the `models.SeedPlans` check and before `idxCancel()`, add:

```go
	if err := db.Backfill(idxCtx); err != nil {
		idxCancel()
		log.Fatalf("backfill: %v", err)
	}
```

Fatal on purpose: a boot that half-applies the membership model gives some people a role and not others, and the guards would then lock the wrong people out silently.

- [ ] **Step 6: Compile**

Run: `sh /tmp/gorun.sh admin go build ./...`
Expected: no output.

- [ ] **Step 7: Confirm the shape is what the rest of the phase expects**

Run: `grep -n "AccountRoleAtLeastAdmin\|customer_user_id\|instance_customer_user_unique" admin/internal/models/members.go admin/internal/db/db.go`
Expected: `AccountRoleAtLeastAdmin` defined once, `customer_user_id` in both the struct tag and the index, `instance_customer_user_unique` once.

- [ ] **Step 8: Commit**

```bash
git add admin/internal/models/members.go admin/internal/models/models.go \
  admin/internal/db/db.go admin/internal/db/backfill.go admin/cmd/main.go
git commit -m "feat(admin): account roles and the instance_members index

Phase 2 created cloud instances without recording who owns them on this
side, because the collection did not exist. The boot backfill reconstructs
one member row per instance from the hq-sourced control-plane owner, and
marks every existing customer_user an owner — they all created their own
account.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 2: `cloudprov` learns to project, revoke and repair

**Files:**

- Modify: `admin/internal/cloudprov/cloudprov.go`

**Interfaces:**

- Consumes: `db.Control`, `db.ControlDB`, `shared/provision.CreateUserWithHash`, `shared/models`.
- Produces:
    - `cloudprov.GrantUser(ctx, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error)`
    - `cloudprov.RevokeUser(ctx, instanceID, hqUserID string) error`
    - `cloudprov.SetMemberRole(ctx, instanceID, hqUserID, role string) error`
    - `cloudprov.CountOtherOwners(ctx, instanceID, exceptHQUserID string) (int64, error)`
    - `cloudprov.SetPasswordHash(ctx, hqUserID, hash string) (int64, error)`
    - `cloudprov.ProjectedUsers(ctx, hqUserID string) ([]sharedmodels.User, error)`

- [ ] **Step 1: Append the projection functions**

In `admin/internal/cloudprov/cloudprov.go`, append at the end of the file:

```go
// GrantUser projects an HQ person into a control-plane instance.
//
// The password hash is copied from customer_users rather than re-derived: HQ
// owns the password, and a grant that asked for a password again would create
// a second credential for one person.
//
// The row is written with auth_source "hq" and hq_user_id set, which is what
// makes the control plane refuse to edit it locally and what lets a password
// change find it later.
func GrantUser(ctx context.Context, instanceID, email, passwordHash, role, hqUserID string) (*sharedmodels.User, error) {
	u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), instanceID,
		email, passwordHash, role, sharedmodels.AuthHQ)
	if err != nil {
		return nil, err
	}
	if _, err := db.Control("users").UpdateOne(ctx,
		bson.M{"user_id": u.UserID},
		bson.M{"$set": bson.M{"hq_user_id": hqUserID}}); err != nil {
		// Unwind: a projected row with no hq_user_id is invisible to revoke and
		// to password propagation, which is worse than no row at all.
		_, _ = db.Control("users").DeleteOne(ctx, bson.M{"user_id": u.UserID})
		return nil, fmt.Errorf("set hq_user_id: %w", err)
	}
	u.HQUserID = hqUserID
	return u, nil
}

// RevokeUser deletes the projected row for one person in one instance.
//
// Deleting rather than disabling is deliberate: the control plane has no
// concept of a disabled user, and a row that still exists is a row that can
// still sign in.
func RevokeUser(ctx context.Context, instanceID, hqUserID string) error {
	_, err := db.Control("users").DeleteOne(ctx, bson.M{
		"instance_id": instanceID,
		"hq_user_id":  hqUserID,
	})
	return err
}

// SetMemberRole changes a projected user's role inside one instance.
func SetMemberRole(ctx context.Context, instanceID, hqUserID, role string) error {
	if !sharedmodels.ValidRole(role) {
		return fmt.Errorf("invalid role %q", role)
	}
	res, err := db.Control("users").UpdateOne(ctx,
		bson.M{"instance_id": instanceID, "hq_user_id": hqUserID},
		bson.M{"$set": bson.M{"role": role}})
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return fmt.Errorf("no projected user in instance %s", instanceID)
	}
	return nil
}

// CountOtherOwners counts owners of an instance other than one HQ person.
//
// It counts CONTROL-PLANE owners, so an owner created locally inside the
// instance counts too. That matters: refusing to revoke the last HQ owner of
// an instance that has three local owners would be a refusal with no cause.
//
// $ne matches documents where the field is absent, which is exactly how a
// locally-created owner is stored.
func CountOtherOwners(ctx context.Context, instanceID, exceptHQUserID string) (int64, error) {
	return db.Control("users").CountDocuments(ctx, bson.M{
		"instance_id": instanceID,
		"role":        sharedmodels.RoleOwner,
		"hq_user_id":  bson.M{"$ne": exceptHQUserID},
	})
}

// SetPasswordHash writes one hash to every row projected from one HQ person,
// across every instance, and reports how many it changed.
func SetPasswordHash(ctx context.Context, hqUserID, hash string) (int64, error) {
	res, err := db.Control("users").UpdateMany(ctx,
		bson.M{"hq_user_id": hqUserID},
		bson.M{"$set": bson.M{"password_hash": hash}})
	if err != nil {
		return 0, err
	}
	return res.ModifiedCount, nil
}

// ProjectedUsers returns every control-plane row projected from one HQ person.
// hqsync uses it to compare hashes.
func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User, error) {
	cur, err := db.Control("users").Find(ctx, bson.M{"hq_user_id": hqUserID})
	if err != nil {
		return nil, err
	}
	var users []sharedmodels.User
	if err := cur.All(ctx, &users); err != nil {
		return nil, err
	}
	return users, nil
}
```

- [ ] **Step 2: Compile**

Run: `sh /tmp/gorun.sh admin go build ./...`
Expected: no output.

- [ ] **Step 3: Confirm the write boundary did not move**

Run: `grep -n 'db.Control("' admin/internal/cloudprov/cloudprov.go admin/internal/inject/inject.go | grep -o 'db.Control("[a-z_]*")' | sort -u`
Expected exactly two lines:

```
db.Control("instances")
db.Control("users")
```

If a third collection appears, stop — that is the design change the package comment forbids.

- [ ] **Step 4: Commit**

```bash
git add admin/internal/cloudprov/cloudprov.go
git commit -m "feat(admin): cloudprov projects, revokes and repairs users

A grant is a real control-plane users row with auth_source hq, not a
federation shim: the instance authenticates it with no runtime dependency
on admin. CountOtherOwners counts control-plane owners so a locally-created
owner satisfies the last-owner rule too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 3: The account's people — invite, accept, role, remove

**Files:**

- Create: `admin/internal/api/people.go`
- Modify: `admin/internal/auth/customer.go`, `admin/internal/auth/middleware.go`, `admin/internal/mail/mail.go`, `admin/internal/api/routes.go`

**Interfaces:**

- Consumes: `models.AccountRole*`, `models.InstanceMember`, `cloudprov.RevokeUser`, `cloudprov.CountOtherOwners`, `auth.CreateCustomerUser`.
- Produces:
    - `auth.CurrentUser(c *gin.Context) *models.CustomerUser`
    - `auth.RequireAccountRole(roles ...string) gin.HandlerFunc`
    - `auth.CreateInvitedUser(ctx context.Context, accountID, email, accountRole string) error`
    - `auth.HandleAcceptInvite(c *gin.Context)`
    - `mail.SendInvite(to, accountName string) error`
    - routes: `GET,POST /api/account/users`, `PUT /api/account/users/:id/role`, `DELETE /api/account/users/:id`, `POST /auth/accept-invite`

- [ ] **Step 1: Add `CurrentUser` and the role guard**

In `admin/internal/auth/middleware.go`, add the import block entries `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"` and `"go.mongodb.org/mongo-driver/v2/bson"`, then append:

```go
const ctxCustomerUser = "admin_customer_user"

// CurrentUser returns the calling customer's own row, loaded once per request
// by RequireAccountRole.
//
// It is nil behind RequireCustomer alone. A handler that needs the role must
// sit behind RequireAccountRole, which is the only thing that loads it.
func CurrentUser(c *gin.Context) *models.CustomerUser {
	if v, ok := c.Get(ctxCustomerUser); ok {
		if u, ok := v.(*models.CustomerUser); ok {
			return u
		}
	}
	return nil
}

// RequireAccountRole admits a customer holding one of the given account roles.
//
// The role is read from the database on every request rather than carried in
// the session. A session lives 24 hours; a demotion that only takes effect
// when someone signs out again is not a demotion.
func RequireAccountRole(roles ...string) gin.HandlerFunc {
	return func(c *gin.Context) {
		s := Current(c)
		if s == nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
			return
		}
		var u models.CustomerUser
		if err := db.Admin("customer_users").FindOne(c.Request.Context(),
			bson.M{"user_id": s.UserID}).Decode(&u); err != nil {
			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
			return
		}
		if !slices.Contains(roles, u.AccountRole) {
			// 403 rather than 404 here: this is the caller's OWN account, so
			// there is no existence to disclose — the 404 rule protects other
			// accounts' resources, not the caller's view of their own.
			c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
				"error": "your account role does not allow this"})
			return
		}
		c.Set(ctxCustomerUser, &u)
		c.Next()
	}
}
```

Add `"slices"` to the imports.

- [ ] **Step 2: Give signup an explicit owner role**

In `admin/internal/auth/customer.go`, change `CreateCustomerUser`'s signature and the struct literal so the role is passed in rather than assumed. Replace the function header and the `u := models.CustomerUser{...}` literal:

```go
// CreateCustomerUser creates an unverified HQ login with a chosen password and
// emails the verification link. Used by signup and by staff.
func CreateCustomerUser(ctx context.Context, accountID, email, password, accountRole string) error {
```

and

```go
	u := models.CustomerUser{
		UserID:            uuid.NewString(),
		AccountID:         accountID,
		Email:             strings.ToLower(strings.TrimSpace(email)),
		PasswordHash:      string(hash),
		AccountRole:       accountRole,
		VerifyTokenHash:   hex.EncodeToString(sum[:]),
		VerifyTokenExpiry: &expiry,
		CreatedAt:         time.Now().UTC(),
	}
```

Update the two existing callers:

- in `HandleSignup`: `CreateCustomerUser(ctx, acct.AccountID, email, body.Password, models.AccountRoleOwner)`
- in `admin/internal/api/staff.go`, `staffCreateAccountUser`: `auth.CreateCustomerUser(ctx, accountID, email, body.Password, models.AccountRoleOwner)` — staff attaching a legacy customer are attaching the person who runs that account.

- [ ] **Step 3: Add the invite path**

An invitation cannot carry a password chosen by the inviter. The HQ password is what signs the invitee into every instance they are later granted, so a password the inviter knows is a shared credential to every one of those instances. The invited row is therefore created with an **empty hash**, which cannot authenticate, and the invitee sets their own when they open the link.

Append to `admin/internal/auth/customer.go`:

```go
// CreateInvitedUser creates a passwordless, unverified member of an existing
// account and emails them a link to set a password.
//
// The empty hash is load-bearing: bcrypt.CompareHashAndPassword against "" can
// never succeed, so the row cannot sign in and cannot usefully be projected
// into an instance until the invitee has been through /accept-invite. That is
// also why a grant refuses an unverified user.
func CreateInvitedUser(ctx context.Context, accountID, accountName, email, accountRole string) error {
	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return err
	}
	token := hex.EncodeToString(raw)
	sum := sha256.Sum256([]byte(token))
	expiry := time.Now().UTC().Add(VerifyWindow)

	u := models.CustomerUser{
		UserID:            uuid.NewString(),
		AccountID:         accountID,
		Email:             strings.ToLower(strings.TrimSpace(email)),
		AccountRole:       accountRole,
		VerifyTokenHash:   hex.EncodeToString(sum[:]),
		VerifyTokenExpiry: &expiry,
		CreatedAt:         time.Now().UTC(),
	}
	if _, err := db.Admin("customer_users").InsertOne(ctx, u); err != nil {
		return err
	}

	if err := mail.SendInvite(u.Email, accountName, token); err != nil {
		// Same rollback rule, and the same detached context, as signup: a row
		// whose link was never delivered can never be signed in to and holds
		// the unique index on email against the person it was meant for.
		rbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
		defer cancel()
		if _, dErr := db.Admin("customer_users").DeleteOne(rbCtx, bson.M{"user_id": u.UserID}); dErr != nil {
			log.Printf("invite: FAILED to roll back customer_user %s (%s) after mail error: %v",
				u.UserID, u.Email, dErr)
		}
		return err
	}
	return nil
}

// HandleAcceptInvite consumes an invitation token and sets the password.
//
// Verification and password-setting are one step for an invitee, because the
// link IS the proof of address and there is nothing to verify separately.
func HandleAcceptInvite(c *gin.Context) {
	var body struct {
		Token    string `json:"token"`
		Password string `json:"password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Token == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "missing token"})
		return
	}
	if len(body.Password) < 12 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
		return
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), BcryptCost)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
		return
	}

	sum := sha256.Sum256([]byte(body.Token))
	now := time.Now().UTC()
	res, err := db.Admin("customer_users").UpdateOne(c.Request.Context(),
		bson.M{
			"verify_token_hash":   hex.EncodeToString(sum[:]),
			"verify_token_expiry": bson.M{"$gt": now},
		},
		bson.M{
			"$set":   bson.M{"verified_at": now, "password_hash": string(hash)},
			"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
		})
	if err != nil || res.MatchedCount == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"accepted": true})
}
```

- [ ] **Step 4: Teach `HandleVerify` to recognise an invitation**

An invitation link and a verification link are the same shape. `HandleVerify` must not consume an invitation, because doing so would verify a row that still has no password and leave the invitee unable to do anything. Replace the body of `HandleVerify` after the `sum := sha256.Sum256(...)` line with:

```go
	now := time.Now().UTC()
	ctx := c.Request.Context()
	hashed := hex.EncodeToString(sum[:])

	// Peek first. An invited row has no password yet, so consuming its token
	// here would verify an account nobody can sign in to and burn the only
	// link that could fix it.
	var u models.CustomerUser
	if err := db.Admin("customer_users").FindOne(ctx, bson.M{
		"verify_token_hash":   hashed,
		"verify_token_expiry": bson.M{"$gt": now},
	}).Decode(&u); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
		return
	}
	if u.PasswordHash == "" {
		c.JSON(http.StatusOK, gin.H{"verified": false, "needs_password": true})
		return
	}

	res, err := db.Admin("customer_users").UpdateOne(ctx,
		bson.M{
			"verify_token_hash":   hashed,
			"verify_token_expiry": bson.M{"$gt": now},
		},
		bson.M{
			"$set":   bson.M{"verified_at": now},
			"$unset": bson.M{"verify_token_hash": "", "verify_token_expiry": ""},
		})
	if err != nil || res.MatchedCount == 0 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "that link is invalid or has expired"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"verified": true})
```

- [ ] **Step 5: Add the invite email**

In `admin/internal/mail/mail.go`, after `SendVerification`, add:

```go
// SendInvite asks someone to join an existing account and set their own
// password. It names the account, because an unexpected invitation from a
// service you have never used is otherwise indistinguishable from spam.
func SendInvite(to, accountName, token string) error {
	link := fmt.Sprintf("%s/accept-invite?token=%s", cfg.PublicURL, token)
	return send(to, "You have been invited to "+sanitizeHeader(accountName)+" on Vantage",
		fmt.Sprintf("You have been invited to join %s on Vantage.\n\n"+
			"Set your password and finish joining:\n\n%s\n\n"+
			"This link expires in 24 hours. If you were not expecting this, ignore it — "+
			"nothing happens until you open the link.\n", accountName, link))
}
```

- [ ] **Step 6: Write the people handlers**

Create `admin/internal/api/people.go`:

```go
package api

import (
	"log"
	"net/http"
	"strings"

	"github.com/gin-gonic/gin"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// listAccountUsers returns the account's people, newest last.
//
// Any signed-in member may read this. Knowing who your colleagues are is not
// privileged, and hiding it would make the members panel unusable for the
// people it is meant to inform.
func listAccountUsers(c *gin.Context) {
	s := auth.Current(c)
	ctx := c.Request.Context()
	cur, err := db.Admin("customer_users").Find(ctx, bson.M{"account_id": s.AccountID})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	users := []models.CustomerUser{}
	if err := cur.All(ctx, &users); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, users)
}

// inviteAccountUser adds a person to the account.
//
// It never sets a password: see CreateInvitedUser. Only an owner may invite
// another owner, mirroring the control plane's own rule that an admin cannot
// mint someone with more power than themselves.
func inviteAccountUser(c *gin.Context) {
	var body struct {
		Email string `json:"email"`
		Role  string `json:"role"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
		return
	}
	email := strings.ToLower(strings.TrimSpace(body.Email))
	if email == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
		return
	}
	if body.Role == "" {
		body.Role = models.AccountRoleMember
	}
	if !models.ValidAccountRole(body.Role) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
		return
	}
	me := auth.CurrentUser(c)
	if body.Role == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
		c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can invite another owner"})
		return
	}

	ctx := c.Request.Context()
	s := auth.Current(c)

	// customer_users.email is globally unique, so an address already in use
	// anywhere cannot be invited here. Say so plainly: unlike signup there is
	// nothing to conceal, because the inviter already knows this address.
	if n, _ := db.Admin("customer_users").CountDocuments(ctx, bson.M{"email": email}); n > 0 {
		c.JSON(http.StatusConflict, gin.H{
			"error": "that address already has a Vantage HQ account"})
		return
	}

	var acct models.Account
	if err := db.Admin("accounts").FindOne(ctx,
		bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
		return
	}

	if err := auth.CreateInvitedUser(ctx, s.AccountID, acct.Name, email, body.Role); err != nil {
		log.Printf("invite %s to %s: %v", email, s.AccountID, err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not send the invitation"})
		return
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "account_user.invited", AccountID: s.AccountID,
		Target: email, Detail: "role=" + body.Role, IP: c.ClientIP()})
	c.JSON(http.StatusCreated, gin.H{"invited": true})
}

// accountUser loads one person and confirms they are on the caller's account.
func accountUser(c *gin.Context, userID string) (*models.CustomerUser, bool) {
	s := auth.Current(c)
	var u models.CustomerUser
	if err := db.Admin("customer_users").FindOne(c.Request.Context(),
		bson.M{"user_id": userID, "account_id": s.AccountID}).Decode(&u); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return nil, false
	}
	return &u, true
}

// countOtherAccountOwners counts owners of an account other than one person.
func countOtherAccountOwners(c *gin.Context, exceptUserID string) (int64, error) {
	s := auth.Current(c)
	return db.Admin("customer_users").CountDocuments(c.Request.Context(), bson.M{
		"account_id":   s.AccountID,
		"account_role": models.AccountRoleOwner,
		"user_id":      bson.M{"$ne": exceptUserID},
	})
}

func updateAccountUserRole(c *gin.Context) {
	var body struct {
		Role string `json:"role"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || !models.ValidAccountRole(body.Role) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
		return
	}
	target, ok := accountUser(c, c.Param("id"))
	if !ok {
		return
	}
	me := auth.CurrentUser(c)
	if target.UserID == me.UserID {
		c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
		return
	}
	if (body.Role == models.AccountRoleOwner || target.AccountRole == models.AccountRoleOwner) &&
		me.AccountRole != models.AccountRoleOwner {
		c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
		return
	}
	if target.AccountRole == models.AccountRoleOwner && body.Role != models.AccountRoleOwner {
		others, err := countOtherAccountOwners(c, target.UserID)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		if others == 0 {
			c.JSON(http.StatusConflict, gin.H{
				"error": "this is the account's last owner; promote someone else first"})
			return
		}
	}

	ctx := c.Request.Context()
	if _, err := db.Admin("customer_users").UpdateOne(ctx,
		bson.M{"user_id": target.UserID},
		bson.M{"$set": bson.M{"account_role": body.Role}}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	s := auth.Current(c)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "account_user.role_changed", AccountID: s.AccountID,
		Target: target.Email, Detail: "role=" + body.Role, IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

// deleteAccountUser removes a person and every instance they hold.
//
// Grants go first, and the whole request is refused if any of them would strand
// an instance with no owner. Removing the person but leaving their projected
// rows behind would leave working logins for someone the account has removed —
// the exact failure this endpoint exists to prevent.
func deleteAccountUser(c *gin.Context) {
	target, ok := accountUser(c, c.Param("id"))
	if !ok {
		return
	}
	me := auth.CurrentUser(c)
	if target.UserID == me.UserID {
		c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
		return
	}
	if target.AccountRole == models.AccountRoleOwner && me.AccountRole != models.AccountRoleOwner {
		c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
		return
	}
	if target.AccountRole == models.AccountRoleOwner {
		others, err := countOtherAccountOwners(c, target.UserID)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		if others == 0 {
			c.JSON(http.StatusConflict, gin.H{
				"error": "this is the account's last owner; promote someone else first"})
			return
		}
	}

	ctx := c.Request.Context()
	s := auth.Current(c)

	cur, err := db.Admin("instance_members").Find(ctx,
		bson.M{"customer_user_id": target.UserID})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	members := []models.InstanceMember{}
	if err := cur.All(ctx, &members); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	// Check every instance BEFORE deleting anything, so a refusal leaves the
	// person exactly as they were rather than half-revoked.
	for _, m := range members {
		if m.Role != sharedmodels.RoleOwner {
			continue
		}
		others, err := cloudprov.CountOtherOwners(ctx, m.InstanceID, target.UserID)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		if others == 0 {
			c.JSON(http.StatusConflict, gin.H{
				"error": "they are the last owner of an instance; give someone else that instance's owner role first"})
			return
		}
	}

	for _, m := range members {
		if err := cloudprov.RevokeUser(ctx, m.InstanceID, target.UserID); err != nil {
			log.Printf("deleteAccountUser: revoke %s from %s: %v", target.Email, m.InstanceID, err)
			c.JSON(http.StatusInternalServerError, gin.H{
				"error": "could not remove their instance access; nothing was deleted"})
			return
		}
		if _, err := db.Admin("instance_members").DeleteOne(ctx,
			bson.M{"member_id": m.MemberID}); err != nil {
			log.Printf("deleteAccountUser: drop member row %s: %v", m.MemberID, err)
		}
	}

	if _, err := db.Admin("customer_users").DeleteOne(ctx,
		bson.M{"user_id": target.UserID}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "account_user.removed", AccountID: s.AccountID,
		Target: target.Email, Detail: fmt.Sprintf("revoked %d instance(s)", len(members)),
		IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"deleted": true})
}
```

Add `"fmt"` to the imports.

- [ ] **Step 7: Mount the routes**

In `admin/internal/api/routes.go`, add `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"` to the imports, add the unauthenticated invite route after `r.POST("/auth/signup", auth.HandleSignup)`:

```go
	r.POST("/auth/accept-invite", auth.HandleAcceptInvite)
```

and inside the `cust` group, after `cust.GET("/account", getAccount)`:

```go
		// People. Reading is open to any member; changing anything is
		// owner-or-admin, enforced per route rather than by splitting the group,
		// so the guard is visible next to the route it guards.
		cust.GET("/account/users", listAccountUsers)
		cust.POST("/account/users",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			inviteAccountUser)
		cust.PUT("/account/users/:id/role",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			updateAccountUserRole)
		cust.DELETE("/account/users/:id",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			deleteAccountUser)
```

Also guard instance creation, which is now a privileged act: change

```go
		cust.POST("/instances", createInstance)
```

to

```go
		cust.POST("/instances",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			createInstance)
```

- [ ] **Step 8: Compile**

Run: `sh /tmp/gorun.sh admin go build ./...`
Expected: no output.

- [ ] **Step 9: Confirm no route escaped a guard**

Run: `grep -n "cust\." admin/internal/api/routes.go`
Expected: every mutating route either names `RequireAccountRole` or is a per-instance action guarded by `ownedInstance` (`relink`, `renew`). `GET` routes are unguarded beyond `RequireCustomer`.

- [ ] **Step 10: Commit**

```bash
git add admin/internal/api/people.go admin/internal/api/routes.go \
  admin/internal/api/staff.go admin/internal/auth/customer.go \
  admin/internal/auth/middleware.go admin/internal/mail/mail.go
git commit -m "feat(admin): invite people to an account and give them roles

An invitation carries no password. The HQ password is what signs someone
into every instance they are granted, so a password the inviter chose would
be a shared credential to all of them — the invited row has an empty hash,
which cannot authenticate, until /accept-invite sets one.

Removing a person revokes every projected instance user first, and refuses
outright if any of those is an instance's last owner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 4: Instance members — grant, role, revoke

**Files:**

- Create: `admin/internal/api/members.go`
- Modify: `admin/internal/api/customer.go`, `admin/internal/api/routes.go`

**Interfaces:**

- Consumes: `ownedInstance`, `cloudprov.GrantUser/RevokeUser/SetMemberRole/CountOtherOwners`, `models.InstanceMember`.
- Produces: routes `GET,POST /api/instances/:id/members`, `PUT /api/instances/:id/members/:uid/role`, `DELETE /api/instances/:id/members/:uid`. `:uid` is the **customer_users.user_id**, not the control-plane user_id — the portal never has to know the projected ID.

- [ ] **Step 1: Record the owner when an instance is created**

`createInstance` currently leaves the owner's membership implicit. In `admin/internal/api/customer.go`, immediately after the successful `db.Admin("admin_instances").InsertOne(ctx, rec)` block and before the `audit.Write` call, add:

```go
	// Record the owner's membership. Best-effort: the projected user already
	// exists and is what actually grants access, so a missing row here costs a
	// line in the members panel, not access — and the boot backfill rebuilds it.
	ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
	if err != nil {
		log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
	} else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
		MemberID:       uuid.NewString(),
		AccountID:      s.AccountID,
		InstanceID:     inst.InstanceID,
		CustomerUserID: cu.UserID,
		ControlUserID:  ownerID,
		Role:           sharedmodels.RoleOwner,
		Email:          cu.Email,
		CreatedAt:      time.Now().UTC(),
	}); err != nil {
		log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
	}
```

Add `"github.com/google/uuid"` and `sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"` to that file's imports.

- [ ] **Step 2: Write the member handlers**

Create `admin/internal/api/members.go`:

```go
package api

import (
	"log"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
	sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"errors"
)

// selfHostedRefusal is the one message every membership endpoint gives for a
// self-hosted instance. Their users live in their own deployment, which we
// cannot see and must not write to.
const selfHostedRefusal = "this install manages its own users; add them in Settings → Instance inside your Vantage install"

func listInstanceMembers(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	ctx := c.Request.Context()
	cur, err := db.Admin("instance_members").Find(ctx,
		bson.M{"instance_id": inst.InstanceID})
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	members := []models.InstanceMember{}
	if err := cur.All(ctx, &members); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, members)
}

// grantInstanceMember projects an account person into a cloud instance.
func grantInstanceMember(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	if inst.Deployment != license.DeploymentCloud {
		c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
		return
	}

	var body struct {
		UserID string `json:"user_id"`
		Role   string `json:"role"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.UserID == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"})
		return
	}
	if body.Role == "" {
		body.Role = sharedmodels.RoleMember
	}
	if !sharedmodels.ValidRole(body.Role) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
		return
	}

	target, ok := accountUser(c, body.UserID)
	if !ok {
		return
	}
	if target.VerifiedAt == nil || target.PasswordHash == "" {
		// The projection copies a hash. An unverified invitee has no hash, so
		// the row would exist and be unusable — and an address nobody has
		// proven they control would hold a login inside a real instance.
		c.JSON(http.StatusConflict, gin.H{
			"error": "they have not accepted their invitation yet"})
		return
	}

	ctx := c.Request.Context()
	s := auth.Current(c)

	u, err := cloudprov.GrantUser(ctx, inst.InstanceID, target.Email,
		target.PasswordHash, body.Role, target.UserID)
	if err != nil {
		if errors.Is(err, provision.ErrEmailTaken) {
			c.JSON(http.StatusConflict, gin.H{
				"error": "that address already has a user inside this instance"})
			return
		}
		log.Printf("grant %s to %s: %v", target.Email, inst.InstanceID, err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
		return
	}

	m := models.InstanceMember{
		MemberID:       uuid.NewString(),
		AccountID:      inst.AccountID,
		InstanceID:     inst.InstanceID,
		CustomerUserID: target.UserID,
		ControlUserID:  u.UserID,
		Role:           body.Role,
		Email:          target.Email,
		CreatedAt:      time.Now().UTC(),
	}
	if _, err := db.Admin("instance_members").InsertOne(ctx, m); err != nil {
		// Unwind the projection: a control-plane login nobody on this side
		// records is a login nobody can revoke through the portal.
		if rErr := cloudprov.RevokeUser(ctx, inst.InstanceID, target.UserID); rErr != nil {
			log.Printf("grant: FAILED to unwind projection of %s in %s: %v",
				target.Email, inst.InstanceID, rErr)
		}
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not grant access"})
		return
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance_member.granted", AccountID: s.AccountID,
		Target: inst.InstanceID, Detail: target.Email + " role=" + body.Role, IP: c.ClientIP()})
	c.JSON(http.StatusCreated, m)
}

// memberRow loads one membership on an instance the caller owns.
func memberRow(c *gin.Context, instanceID, customerUserID string) (*models.InstanceMember, bool) {
	var m models.InstanceMember
	if err := db.Admin("instance_members").FindOne(c.Request.Context(), bson.M{
		"instance_id":      instanceID,
		"customer_user_id": customerUserID,
	}).Decode(&m); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
		return nil, false
	}
	return &m, true
}

func updateInstanceMemberRole(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	if inst.Deployment != license.DeploymentCloud {
		c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
		return
	}
	var body struct {
		Role string `json:"role"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || !sharedmodels.ValidRole(body.Role) {
		c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
		return
	}
	m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
	if !ok {
		return
	}

	ctx := c.Request.Context()
	if m.Role == sharedmodels.RoleOwner && body.Role != sharedmodels.RoleOwner {
		others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		if others == 0 {
			c.JSON(http.StatusConflict, gin.H{
				"error": "this is the instance's last owner; make someone else an owner first"})
			return
		}
	}

	if err := cloudprov.SetMemberRole(ctx, inst.InstanceID, m.CustomerUserID, body.Role); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not change their role"})
		return
	}
	if _, err := db.Admin("instance_members").UpdateOne(ctx,
		bson.M{"member_id": m.MemberID},
		bson.M{"$set": bson.M{"role": body.Role}}); err != nil {
		log.Printf("member role: control plane updated but member row %s did not: %v", m.MemberID, err)
	}

	s := auth.Current(c)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance_member.role_changed", AccountID: s.AccountID,
		Target: inst.InstanceID, Detail: m.Email + " role=" + body.Role, IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"ok": true})
}

func revokeInstanceMember(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	if inst.Deployment != license.DeploymentCloud {
		c.JSON(http.StatusBadRequest, gin.H{"error": selfHostedRefusal})
		return
	}
	m, ok := memberRow(c, inst.InstanceID, c.Param("uid"))
	if !ok {
		return
	}

	ctx := c.Request.Context()
	if m.Role == sharedmodels.RoleOwner {
		others, err := cloudprov.CountOtherOwners(ctx, inst.InstanceID, m.CustomerUserID)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
			return
		}
		if others == 0 {
			c.JSON(http.StatusConflict, gin.H{
				"error": "this is the instance's last owner; make someone else an owner first"})
			return
		}
	}

	if err := cloudprov.RevokeUser(ctx, inst.InstanceID, m.CustomerUserID); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not revoke access"})
		return
	}
	if _, err := db.Admin("instance_members").DeleteOne(ctx,
		bson.M{"member_id": m.MemberID}); err != nil {
		log.Printf("revoke: control-plane user deleted but member row %s remains: %v", m.MemberID, err)
	}

	s := auth.Current(c)
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance_member.revoked", AccountID: s.AccountID,
		Target: inst.InstanceID, Detail: m.Email, IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"revoked": true})
}
```

- [ ] **Step 3: Mount the member routes**

In `admin/internal/api/routes.go`, inside the `cust` group after `cust.GET("/instances/:id/license/download", downloadInstanceLicense)`:

```go
		cust.GET("/instances/:id/members", listInstanceMembers)
		cust.POST("/instances/:id/members",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			grantInstanceMember)
		cust.PUT("/instances/:id/members/:uid/role",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			updateInstanceMemberRole)
		cust.DELETE("/instances/:id/members/:uid",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			revokeInstanceMember)
```

- [ ] **Step 4: Compile**

Run: `sh /tmp/gorun.sh admin go build ./...`
Expected: no output. If gin panics at boot instead with a wildcard conflict, the `:uid` segment collided — check that no other route uses a different name in that position.

- [ ] **Step 5: Confirm the self-hosted refusal is on all three mutating endpoints**

Run: `grep -c "selfHostedRefusal" admin/internal/api/members.go`
Expected: `4` (one definition, three uses). `listInstanceMembers` deliberately does not refuse — reading an empty list is harmless and the panel needs a truthful answer.

- [ ] **Step 6: Commit**

```bash
git add admin/internal/api/members.go admin/internal/api/routes.go admin/internal/api/customer.go
git commit -m "feat(admin): grant, re-role and revoke instance members

A grant writes a real control-plane user; the instance_members row is only
admin's index of it, which is why a failed insert unwinds the projection.
Self-hosted instances refuse all three mutations: their users live in a
deployment we cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 5: One password, every instance

**Files:**

- Modify: `admin/internal/api/people.go`, `admin/internal/api/routes.go`

**Interfaces:**

- Consumes: `cloudprov.SetPasswordHash`, `auth.BcryptCost`.
- Produces: route `PUT /api/account/password`, handler `changeAccountPassword`.

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

Append to `admin/internal/api/people.go`:

```go
// changeAccountPassword sets one password and pushes it everywhere.
//
// HQ's hash is the single source of truth for every hq-sourced row, and the
// control plane has no local password-change path for them, so there is no
// competing writer.
//
// Propagation is best-effort ON PURPOSE. Failing the password change because
// one of three instances was briefly unreachable would leave the customer with
// the password they were trying to get rid of; hqsync repairs a stale instance
// within fifteen minutes, which is recoverable.
func changeAccountPassword(c *gin.Context) {
	var body struct {
		CurrentPassword string `json:"current_password"`
		NewPassword     string `json:"new_password"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "current and new password are required"})
		return
	}
	if len(body.NewPassword) < 12 {
		c.JSON(http.StatusBadRequest, gin.H{"error": "choose a password of at least 12 characters"})
		return
	}

	s := auth.Current(c)
	ctx := c.Request.Context()

	var me models.CustomerUser
	if err := db.Admin("customer_users").FindOne(ctx,
		bson.M{"user_id": s.UserID}).Decode(&me); err != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"error": "sign in required"})
		return
	}
	if bcrypt.CompareHashAndPassword([]byte(me.PasswordHash), []byte(body.CurrentPassword)) != nil {
		c.JSON(http.StatusForbidden, gin.H{"error": "that is not your current password"})
		return
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), auth.BcryptCost)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
		return
	}
	if _, err := db.Admin("customer_users").UpdateOne(ctx,
		bson.M{"user_id": me.UserID},
		bson.M{"$set": bson.M{"password_hash": string(hash)}}); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": "could not set the password"})
		return
	}

	pending := false
	if n, err := cloudprov.SetPasswordHash(ctx, me.UserID, string(hash)); err != nil {
		pending = true
		now := time.Now().UTC()
		log.Printf("password: propagation for %s failed, hqsync will repair: %v", me.Email, err)
		_, _ = db.Admin("customer_users").UpdateOne(ctx,
			bson.M{"user_id": me.UserID},
			bson.M{"$set": bson.M{"hq_sync_failed_at": now}})
	} else {
		log.Printf("password: %s propagated to %d instance user(s)", me.Email, n)
		_, _ = db.Admin("customer_users").UpdateOne(ctx,
			bson.M{"user_id": me.UserID},
			bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
	}

	audit.Write(ctx, models.AuditEntry{
		Actor: me.Email, Action: "account_user.password_changed", AccountID: s.AccountID,
		Target: me.Email, IP: c.ClientIP()})
	c.JSON(http.StatusOK, gin.H{"updated": true, "propagation_pending": pending})
}
```

Add `"time"` and `"golang.org/x/crypto/bcrypt"` to that file's imports.

- [ ] **Step 2: Mount it**

In `admin/internal/api/routes.go`, inside the `cust` group after the account-users routes:

```go
		// Any member may change their own password — it is theirs. There is no
		// endpoint for changing anyone else's.
		cust.PUT("/account/password", changeAccountPassword)
```

- [ ] **Step 3: Compile**

Run: `sh /tmp/gorun.sh admin go build ./...`
Expected: no output.

- [ ] **Step 4: Confirm there is exactly one password writer for hq rows**

Run: `grep -rn "password_hash" admin/internal/ server/internal/api/ | grep -v "_test"`
Expected: writes only in `auth/customer.go` (signup and accept-invite), `api/people.go` (this handler), `cloudprov.SetPasswordHash`, and `hqsync` once Task 6 lands. Nothing in `server/internal/api/` writes `password_hash` for an existing user.

- [ ] **Step 5: Commit**

```bash
git add admin/internal/api/people.go admin/internal/api/routes.go
git commit -m "feat(admin): one password change reaches every instance

Best-effort by design: refusing the change because one instance was
unreachable would leave the customer holding the password they were trying
to replace. A failure is flagged and hqsync repairs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 6: `hqsync` — the fifteen-minute repair

**Files:**

- Create: `admin/internal/hqsync/hqsync.go`
- Modify: `admin/cmd/main.go`

**Interfaces:**

- Consumes: `cloudprov.ProjectedUsers`, `cloudprov.SetPasswordHash`, `db.Admin`.
- Produces: `hqsync.Reconcile(ctx) (checked, repaired int, err error)`, `hqsync.Start(ctx context.Context)`.

- [ ] **Step 1: Write the package**

Create `admin/internal/hqsync/hqsync.go`:

```go
// Package hqsync keeps projected control-plane users consistent with the HQ
// people they were projected from.
//
// It is separate from inject on purpose. inject writes exactly three licence
// fields on `instances` and that narrowness is the reason admin's reach into
// the control plane is reviewable at all; a password repair pass bolted onto it
// would quietly turn it into "the package that writes whatever admin wants".
// This one goes through cloudprov, which is the sanctioned user write path.
package hqsync

import (
	"context"
	"log"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// Interval matches inject's reconciler. Fifteen minutes is the worst-case
// staleness a password change can suffer, which the spec accepts as
// recoverable.
const Interval = 15 * time.Minute

// Reconcile compares every projected user's stored hash against the HQ hash it
// came from, and repairs mismatches.
//
// The comparison is on the hash string, not the password: two bcrypt hashes of
// one password differ by salt, so this repairs by COPYING HQ's hash rather than
// re-hashing. That is also why propagation copies rather than re-derives.
func Reconcile(ctx context.Context) (checked, repaired int, err error) {
	cur, err := db.Admin("customer_users").Find(ctx,
		bson.M{"password_hash": bson.M{"$nin": bson.A{nil, ""}}})
	if err != nil {
		return 0, 0, err
	}
	var people []models.CustomerUser
	if err := cur.All(ctx, &people); err != nil {
		return 0, 0, err
	}

	for _, p := range people {
		projected, err := cloudprov.ProjectedUsers(ctx, p.UserID)
		if err != nil {
			log.Printf("hqsync: read projections of %s: %v", p.Email, err)
			continue
		}
		stale := false
		for _, u := range projected {
			checked++
			if u.PasswordHash != p.PasswordHash {
				stale = true
			}
		}
		if !stale {
			// Clear a stale failure flag: the instances agree, whatever the
			// flag says. Nothing reads the flag to decide what to repair.
			if p.HQSyncFailedAt != nil {
				_, _ = db.Admin("customer_users").UpdateOne(ctx,
					bson.M{"user_id": p.UserID},
					bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
			}
			continue
		}

		n, err := cloudprov.SetPasswordHash(ctx, p.UserID, p.PasswordHash)
		if err != nil {
			log.Printf("hqsync: repair %s: %v", p.Email, err)
			continue
		}
		repaired += int(n)
		log.Printf("hqsync: repaired %d projected user(s) for %s", n, p.Email)
		_, _ = db.Admin("customer_users").UpdateOne(ctx,
			bson.M{"user_id": p.UserID},
			bson.M{"$unset": bson.M{"hq_sync_failed_at": ""}})
	}
	return checked, repaired, nil
}

// Start runs once at boot, then on a ticker until ctx is cancelled.
//
// The boot pass is for the same reason inject's is: the likeliest moment for a
// half-applied write is a deploy or a crash, and waiting a full interval to
// notice means a customer's new password does not work somewhere for fifteen
// minutes after we already know how to fix it.
func Start(ctx context.Context) {
	go func() {
		runOnce(ctx)
		t := time.NewTicker(Interval)
		defer t.Stop()
		for {
			select {
			case <-ctx.Done():
				return
			case <-t.C:
				runOnce(ctx)
			}
		}
	}()
}

func runOnce(ctx context.Context) {
	runCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
	defer cancel()

	checked, repaired, err := Reconcile(runCtx)
	if err != nil {
		log.Printf("hqsync: %v", err)
		return
	}
	if repaired > 0 {
		log.Printf("hqsync: checked %d projected user(s), repaired %d", checked, repaired)
	}
}
```

- [ ] **Step 2: Start it at boot**

In `admin/cmd/main.go`, add the import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/hqsync"` and, immediately after `inject.StartReconciler(reconcileCtx)`:

```go
	hqsync.Start(reconcileCtx)
```

- [ ] **Step 3: Compile**

Run: `sh /tmp/gorun.sh admin go build ./...`
Expected: no output.

- [ ] **Step 4: Confirm inject stayed narrow**

Run: `grep -n "password_hash\|role" admin/internal/inject/inject.go`
Expected: no matches. If either appears, the pass was put in the wrong package.

- [ ] **Step 5: Commit**

```bash
git add admin/internal/hqsync/hqsync.go admin/cmd/main.go
git commit -m "feat(admin): hqsync repairs stale projected passwords

Its own package rather than a pass inside inject: inject writes three
licence fields and nothing else, and that narrowness is what makes admin's
reach into the control plane reviewable.

Repairs by copying HQ's hash, not by re-hashing — two bcrypt hashes of one
password differ by salt, so a re-hash would never converge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 7: The control plane refuses to edit what HQ owns

**Files:**

- Modify: `server/internal/services/users.go`, `server/internal/api/instance.go`

**Interfaces:**

- Produces: `services.ErrHQManaged error`; `UpdateUserRole` and `DeleteUser` return it for `auth_source == "hq"`; the API maps it to 409.

- [ ] **Step 1: Add the error and the two guards**

In `server/internal/services/users.go`, after the `ErrLastOwner` declaration, add:

```go
// ErrHQManaged is returned when a caller tries to change a user this instance
// does not own.
//
// An hq-sourced row is projected from a Vantage HQ account: HQ owns its role,
// its password and its existence. A role editable in two places is a role with
// two answers, and the loser is whichever writer ran first. Refusing here
// rather than merely hiding the control in web/ is the point — the API is the
// boundary, the UI is a courtesy.
var ErrHQManaged = errors.New("this member is managed in Vantage HQ; change their role or remove them from the HQ portal")
```

In `UpdateUserRole`, immediately after the `target, err := GetUserInInstance(...)` error check:

```go
	if target.AuthSource == models.AuthHQ {
		return ErrHQManaged
	}
```

Add the identical block in `DeleteUser` after its own `GetUserInInstance` check.

- [ ] **Step 2: Map it to a status**

In `server/internal/api/instance.go`, replace `orgUserErrStatus` with:

```go
func orgUserErrStatus(err error) int {
	if errors.Is(err, services.ErrLastOwner) || errors.Is(err, services.ErrHQManaged) {
		return http.StatusConflict
	}
	return http.StatusInternalServerError
}
```

409 rather than 403: the caller has the right to manage members, and the request is refused because of the resource's state, not their permissions.

- [ ] **Step 3: Compile**

Run: `sh /tmp/gorun.sh server go build ./...`
Expected: no output.

- [ ] **Step 4: Confirm both paths are guarded**

Run: `grep -n "ErrHQManaged" server/internal/services/users.go server/internal/api/instance.go`
Expected: four lines — the declaration, two returns, and the status mapping.

- [ ] **Step 5: Commit**

```bash
git add server/internal/services/users.go server/internal/api/instance.go
git commit -m "feat(server): refuse local edits to hq-sourced users

The API is the boundary; hiding the control in web/ is a courtesy. A role
editable in two places is a role with two answers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 8: The HQ portal — people, members, password

**Files:**

- Create: `adminsite/app/(customer)/users/page.tsx`, `adminsite/app/(customer)/users/InvitePanel.tsx`, `adminsite/app/(customer)/settings/page.tsx`, `adminsite/app/accept-invite/page.tsx`, `adminsite/components/MembersPanel.tsx`
- Modify: `adminsite/lib/api.ts`, `adminsite/app/(customer)/layout.tsx`, `adminsite/app/(customer)/instances/[id]/page.tsx`, `adminsite/app/(customer)/instances/new/CreateForm.tsx`, `adminsite/app/verify/page.tsx`

**Interfaces:**

- Consumes: every route from Tasks 3–5.
- Produces: `AccountRole`, `InstanceRole`, `AccountUser`, `InstanceMember` types; `api.accountUsers`, `api.invite`, `api.setAccountRole`, `api.removeAccountUser`, `api.changePassword`, `api.acceptInvite`, `api.members`, `api.grantMember`, `api.setMemberRole`, `api.revokeMember`.

- [ ] **Step 1: Extend the API client**

In `adminsite/lib/api.ts`, add after the `post` helper:

```ts
const put = <T>(path: string, payload?: unknown) => req<T>(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined });

const del = <T>(path: string) => req<T>(path, { method: "DELETE" });
```

Add to the types section:

```ts
/*
 * Two role vocabularies, same three words. AccountRole governs the HQ account:
 * who may invite, create instances and grant access. InstanceRole is the role a
 * projected user holds INSIDE one instance. A person can be an account member
 * and an instance owner at once — that is normal, not a mistake.
 */
export type AccountRole = "owner" | "admin" | "member";
export type InstanceRole = "owner" | "admin" | "member";

export interface AccountUser {
    user_id: string;
    account_id: string;
    email: string;
    account_role: AccountRole;
    verified_at?: string | null;
    hq_sync_failed_at?: string | null;
    created_at: string;
}

export interface InstanceMember {
    member_id: string;
    account_id: string;
    instance_id: string;
    customer_user_id: string;
    control_user_id: string;
    role: InstanceRole;
    email: string;
    created_at: string;
}
```

Extend the `Session` interface with the caller's own role so the UI can hide what the backend would refuse:

```ts
export interface Session {
    kind: "staff" | "customer";
    email: string;
    account_id?: string;
    account_role?: AccountRole;
}
```

Add to the `api` object, after `subscriptions`:

```ts
    accountUsers: () => req<AccountUser[]>("/api/account/users"),
    invite: (email: string, role: AccountRole) =>
        post<{ invited: boolean }>("/api/account/users", { email, role }),
    setAccountRole: (userId: string, role: AccountRole) =>
        put<{ ok: boolean }>(`/api/account/users/${userId}/role`, { role }),
    removeAccountUser: (userId: string) =>
        del<{ deleted: boolean }>(`/api/account/users/${userId}`),
    changePassword: (current_password: string, new_password: string) =>
        put<{ updated: boolean; propagation_pending: boolean }>("/api/account/password", {
            current_password,
            new_password,
        }),
    acceptInvite: (token: string, password: string) =>
        post<{ accepted: boolean }>("/auth/accept-invite", { token, password }),

    members: (instanceId: string) =>
        req<InstanceMember[]>(`/api/instances/${instanceId}/members`),
    grantMember: (instanceId: string, user_id: string, role: InstanceRole) =>
        post<InstanceMember>(`/api/instances/${instanceId}/members`, { user_id, role }),
    setMemberRole: (instanceId: string, userId: string, role: InstanceRole) =>
        put<{ ok: boolean }>(`/api/instances/${instanceId}/members/${userId}/role`, { role }),
    revokeMember: (instanceId: string, userId: string) =>
        del<{ revoked: boolean }>(`/api/instances/${instanceId}/members/${userId}`),
```

Also change `verify` to reflect the new response shape:

```ts
    verify: (token: string) =>
        req<{ verified: boolean; needs_password?: boolean }>(
            `/auth/verify?token=${encodeURIComponent(token)}`,
        ),
```

- [ ] **Step 2: Report the account role from `/auth/me`**

The UI needs the caller's role to decide what to render. In `admin/internal/api/customer.go`, replace the body of `getMe` after the nil check with:

```go
	out := gin.H{"kind": s.Kind, "email": s.Email, "account_id": s.AccountID}
	if s.Kind == auth.KindCustomer {
		var u models.CustomerUser
		if err := db.Admin("customer_users").FindOne(c.Request.Context(),
			bson.M{"user_id": s.UserID}).Decode(&u); err == nil {
			out["account_role"] = u.AccountRole
		}
	}
	c.JSON(http.StatusOK, out)
```

Run: `sh /tmp/gorun.sh admin go build ./...` — expected no output.

- [ ] **Step 3: The people page**

Create `adminsite/app/(customer)/users/InvitePanel.tsx`:

```tsx
"use client";

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { API_BASE, ApiError, NotConnected, api, type AccountRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { NotConnectedPanel } from "@/components/NotConnected";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";

const ROLES: AccountRole[] = ["owner", "admin", "member"];

export function InvitePanel() {
    const qc = useQueryClient();
    const { session } = useSession();
    const [email, setEmail] = useState("");
    const [role, setRole] = useState<AccountRole>("member");
    const [error, setError] = useState<string | null>(null);

    const users = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });
    const refresh = () => qc.invalidateQueries({ queryKey: ["account-users"] });
    const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");

    const invite = useMutation({
        mutationFn: () => api.invite(email.trim().toLowerCase(), role),
        onSuccess: () => {
            setEmail("");
            setRole("member");
            refresh();
        },
        onError: fail,
    });
    const setRoleFor = useMutation({
        mutationFn: (v: { id: string; role: AccountRole }) => api.setAccountRole(v.id, v.role),
        onSuccess: refresh,
        onError: fail,
    });
    const remove = useMutation({
        mutationFn: (id: string) => api.removeAccountUser(id),
        onSuccess: refresh,
        onError: fail,
    });

    if (users.error instanceof NotConnected) return <NotConnectedPanel url={API_BASE} />;

    const myRole = session?.account_role;
    const canManage = myRole === "owner" || myRole === "admin";
    const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner");

    return (
        <div className="grid gap-6">
            {error && <p className="rounded border border-expired bg-panel p-3 text-[0.9rem] text-expired">{error}</p>}

            <table className="w-full border-collapse text-left text-[0.9rem]">
                <thead>
                    <tr className="border-b border-rule font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">
                        <th className="py-2">Email</th>
                        <th className="py-2">Account role</th>
                        <th className="py-2">Status</th>
                        <th className="py-2" />
                    </tr>
                </thead>
                <tbody>
                    {(users.data ?? []).map((u) => {
                        const isSelf = u.email === session?.email;
                        return (
                            <tr key={u.user_id} className="border-b border-rule-soft">
                                <td className="py-2.5">
                                    {u.email}
                                    {isSelf && <span className="ml-2 text-ink-3">(you)</span>}
                                </td>
                                <td className="py-2.5">
                                    {canManage && !isSelf ? (
                                        <select
                                            value={u.account_role}
                                            onChange={(e) =>
                                                setRoleFor.mutate({
                                                    id: u.user_id,
                                                    role: e.target.value as AccountRole,
                                                })
                                            }
                                            className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
                                        >
                                            {assignable.map((r) => (
                                                <option key={r} value={r}>
                                                    {r}
                                                </option>
                                            ))}
                                        </select>
                                    ) : (
                                        <span className="font-mono text-[0.82rem]">{u.account_role}</span>
                                    )}
                                </td>
                                <td className="py-2.5 text-ink-2">{u.verified_at ? "Active" : "Invitation pending"}</td>
                                <td className="py-2.5 text-right">
                                    {canManage && !isSelf && (
                                        <button
                                            type="button"
                                            className="text-[0.82rem] font-semibold text-expired underline"
                                            onClick={() => {
                                                if (confirm(`Remove ${u.email}? They lose access to every instance on this account.`)) remove.mutate(u.user_id);
                                            }}
                                        >
                                            Remove
                                        </button>
                                    )}
                                </td>
                            </tr>
                        );
                    })}
                </tbody>
            </table>

            {canManage && (
                <form
                    className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
                    onSubmit={(e) => {
                        e.preventDefault();
                        setError(null);
                        if (email.trim()) invite.mutate();
                    }}
                >
                    <h2 className="text-xl">Invite someone</h2>
                    <Field
                        label="Email"
                        type="email"
                        value={email}
                        onChange={(e) => setEmail(e.target.value)}
                        required
                        hint="They choose their own password from the emailed link. Nothing happens until they open it."
                    />
                    <label className="grid max-w-md gap-1.5">
                        <span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Account role</span>
                        <select value={role} onChange={(e) => setRole(e.target.value as AccountRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
                            {assignable.map((r) => (
                                <option key={r} value={r}>
                                    {r}
                                </option>
                            ))}
                        </select>
                    </label>
                    <p className="text-[0.82rem] text-ink-2">An account role is not access to an instance. Give them that on the instance itself.</p>
                    <Button type="submit" disabled={invite.isPending || !email.trim()}>
                        {invite.isPending ? "Sending…" : "Send invitation"}
                    </Button>
                </form>
            )}
        </div>
    );
}
```

Create `adminsite/app/(customer)/users/page.tsx`:

```tsx
import { InvitePanel } from "./InvitePanel";

export default function UsersPage() {
    return (
        <div className="grid gap-8">
            <header className="grid gap-2">
                <h1 className="text-3xl">People</h1>
                <p className="text-ink-2">Everyone on this account. Owners and admins can invite people and grant them access to instances; billing stays with owners.</p>
            </header>
            <InvitePanel />
        </div>
    );
}
```

- [ ] **Step 4: The members panel**

Create `adminsite/components/MembersPanel.tsx`:

```tsx
"use client";

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, type InstanceRole } from "@/lib/api";
import { useSession } from "@/lib/session";
import { Button } from "@/components/Button";

const ROLES: InstanceRole[] = ["owner", "admin", "member"];

/*
 * Absent entirely for self-hosted instances — the backend refuses those, and a
 * panel that renders controls the server will reject is a panel that lies.
 */
export function MembersPanel({ instanceId }: { instanceId: string }) {
    const qc = useQueryClient();
    const { session } = useSession();
    const [selected, setSelected] = useState("");
    const [role, setRole] = useState<InstanceRole>("member");
    const [error, setError] = useState<string | null>(null);

    const members = useQuery({
        queryKey: ["members", instanceId],
        queryFn: () => api.members(instanceId),
    });
    const people = useQuery({ queryKey: ["account-users"], queryFn: api.accountUsers });

    const refresh = () => qc.invalidateQueries({ queryKey: ["members", instanceId] });
    const fail = (e: unknown) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");

    const grant = useMutation({
        mutationFn: () => api.grantMember(instanceId, selected, role),
        onSuccess: () => {
            setSelected("");
            setRole("member");
            refresh();
        },
        onError: fail,
    });
    const changeRole = useMutation({
        mutationFn: (v: { uid: string; role: InstanceRole }) => api.setMemberRole(instanceId, v.uid, v.role),
        onSuccess: refresh,
        onError: fail,
    });
    const revoke = useMutation({
        mutationFn: (uid: string) => api.revokeMember(instanceId, uid),
        onSuccess: refresh,
        onError: fail,
    });

    const myRole = session?.account_role;
    const canManage = myRole === "owner" || myRole === "admin";

    const granted = new Set((members.data ?? []).map((m) => m.customer_user_id));
    const candidates = (people.data ?? []).filter((p) => !granted.has(p.user_id) && p.verified_at);
    const pending = (people.data ?? []).filter((p) => !p.verified_at).length;

    return (
        <section className="grid gap-4 rounded border border-rule bg-panel p-5">
            <div className="grid gap-1">
                <h2 className="text-xl">Who can sign in</h2>
                <p className="text-[0.82rem] text-ink-2">Each person here has a real user inside this instance and signs in with their Vantage HQ password.</p>
            </div>

            {error && <p className="text-[0.9rem] text-expired">{error}</p>}

            <ul className="grid gap-2">
                {(members.data ?? []).map((m) => (
                    <li key={m.member_id} className="flex flex-wrap items-center justify-between gap-3 border-b border-rule-soft pb-2">
                        <span>{m.email}</span>
                        <span className="flex items-center gap-3">
                            {canManage ? (
                                <select
                                    value={m.role}
                                    onChange={(e) =>
                                        changeRole.mutate({
                                            uid: m.customer_user_id,
                                            role: e.target.value as InstanceRole,
                                        })
                                    }
                                    className="rounded border border-rule bg-panel-2 px-2 py-1 font-mono text-[0.82rem] text-ink"
                                >
                                    {ROLES.map((r) => (
                                        <option key={r} value={r}>
                                            {r}
                                        </option>
                                    ))}
                                </select>
                            ) : (
                                <span className="font-mono text-[0.82rem]">{m.role}</span>
                            )}
                            {canManage && (
                                <button
                                    type="button"
                                    className="text-[0.82rem] font-semibold text-expired underline"
                                    onClick={() => {
                                        if (confirm(`Remove ${m.email} from this instance?`)) revoke.mutate(m.customer_user_id);
                                    }}
                                >
                                    Remove
                                </button>
                            )}
                        </span>
                    </li>
                ))}
                {members.data?.length === 0 && <li className="text-ink-2">Nobody has been added yet.</li>}
            </ul>

            {canManage && (
                <form
                    className="flex flex-wrap items-end gap-3"
                    onSubmit={(e) => {
                        e.preventDefault();
                        setError(null);
                        if (selected) grant.mutate();
                    }}
                >
                    <label className="grid gap-1.5">
                        <span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Add someone</span>
                        <select value={selected} onChange={(e) => setSelected(e.target.value)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
                            <option value="">Choose a person…</option>
                            {candidates.map((p) => (
                                <option key={p.user_id} value={p.user_id}>
                                    {p.email}
                                </option>
                            ))}
                        </select>
                    </label>
                    <label className="grid gap-1.5">
                        <span className="font-mono text-[0.72rem] uppercase tracking-[0.1em] text-ink-3">Role here</span>
                        <select value={role} onChange={(e) => setRole(e.target.value as InstanceRole)} className="rounded border border-rule bg-panel-2 px-2.5 py-2 font-mono text-ink">
                            {ROLES.map((r) => (
                                <option key={r} value={r}>
                                    {r}
                                </option>
                            ))}
                        </select>
                    </label>
                    <Button type="submit" disabled={!selected || grant.isPending}>
                        {grant.isPending ? "Adding…" : "Add"}
                    </Button>
                </form>
            )}

            {canManage && pending > 0 && (
                <p className="text-[0.82rem] text-ink-3">
                    {pending} invited {pending === 1 ? "person has" : "people have"} not accepted yet and cannot be added until they do.
                </p>
            )}
        </section>
    );
}
```

- [ ] **Step 5: Mount the panel on the instance page**

In `adminsite/app/(customer)/instances/[id]/page.tsx`, add the import `import { MembersPanel } from "@/components/MembersPanel";` and, immediately before the closing `</div>` of the returned tree:

```tsx
{
    instance.deployment === "cloud" ? (
        <MembersPanel instanceId={instance.instance_id} />
    ) : (
        <p className="rounded border border-rule bg-panel p-5 text-ink-2">Users for this install are managed inside it, in Settings → Instance. We do not have access to your own deployment.</p>
    );
}
```

- [ ] **Step 6: The settings page**

Create `adminsite/app/(customer)/settings/page.tsx`:

```tsx
"use client";

import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";

export default function SettingsPage() {
    const [current, setCurrent] = useState("");
    const [next, setNext] = useState("");
    const [error, setError] = useState<string | null>(null);
    const [done, setDone] = useState<string | null>(null);

    const change = useMutation({
        mutationFn: () => api.changePassword(current, next),
        onSuccess: (res) => {
            setCurrent("");
            setNext("");
            setDone(res.propagation_pending ? "Password changed. One of your instances could not be updated just now; it will catch up within fifteen minutes." : "Password changed everywhere.");
        },
        onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
    });

    return (
        <div className="grid gap-8">
            <header className="grid gap-2">
                <h1 className="text-3xl">Settings</h1>
                <p className="text-ink-2">Your password signs you in here and into every Vantage instance you belong to. Changing it changes all of them.</p>
            </header>

            <form
                className="grid max-w-md gap-4 rounded border border-rule bg-panel p-5"
                onSubmit={(e) => {
                    e.preventDefault();
                    setError(null);
                    setDone(null);
                    change.mutate();
                }}
            >
                <Field label="Current password" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
                <Field
                    label="New password"
                    type="password"
                    autoComplete="new-password"
                    value={next}
                    onChange={(e) => setNext(e.target.value)}
                    required
                    minLength={12}
                    hint="At least 12 characters."
                    error={error ?? undefined}
                />
                {done && <p className="text-[0.9rem] text-valid">{done}</p>}
                <Button type="submit" disabled={change.isPending || next.length < 12}>
                    {change.isPending ? "Changing…" : "Change password"}
                </Button>
            </form>
        </div>
    );
}
```

- [ ] **Step 7: The accept-invite page**

Create `adminsite/app/accept-invite/page.tsx`:

```tsx
"use client";

import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { ApiError, api } from "@/lib/api";
import { Button } from "@/components/Button";
import { Field } from "@/components/Field";

function AcceptForm() {
    const token = useSearchParams().get("token") ?? "";
    const [password, setPassword] = useState("");
    const [error, setError] = useState<string | null>(null);
    const [done, setDone] = useState(false);

    const accept = useMutation({
        mutationFn: () => api.acceptInvite(token, password),
        onSuccess: () => setDone(true),
        onError: (e) => setError(e instanceof ApiError ? e.message : "Something went wrong. Try again."),
    });

    if (!token) return <p className="text-ink-2">That link is missing its token.</p>;
    if (done)
        return (
            <div className="grid gap-3">
                <h1 className="text-3xl">You&apos;re in</h1>
                <p className="text-ink-2">Sign in with your email address and new password.</p>
                <Link href="/login" className="font-semibold text-accent underline">
                    Sign in
                </Link>
            </div>
        );

    return (
        <form
            className="grid max-w-md gap-4"
            onSubmit={(e) => {
                e.preventDefault();
                setError(null);
                accept.mutate();
            }}
        >
            <h1 className="text-3xl">Choose a password</h1>
            <p className="text-ink-2">This password signs you into Vantage HQ and into every instance you are given access to. Nobody who invited you can see it.</p>
            <Field
                label="New password"
                type="password"
                autoComplete="new-password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                required
                minLength={12}
                hint="At least 12 characters."
                error={error ?? undefined}
            />
            <Button type="submit" disabled={accept.isPending || password.length < 12}>
                {accept.isPending ? "Setting…" : "Set password"}
            </Button>
        </form>
    );
}

export default function AcceptInvitePage() {
    return (
        <main className="mx-auto max-w-rail px-5 py-16">
            <Suspense fallback={<p className="text-ink-3">Loading…</p>}>
                <AcceptForm />
            </Suspense>
        </main>
    );
}
```

- [ ] **Step 8: Send an invite token from `/verify` to the right place**

Read `adminsite/app/verify/page.tsx`, find where it renders success from `api.verify(token)`, and add a branch before it: when the response has `needs_password`, redirect with

```tsx
if (data?.needs_password) {
    router.replace(`/accept-invite?token=${encodeURIComponent(token)}`);
    return null;
}
```

using `useRouter` from `next/navigation`. This exists because an invitation and a verification link are the same shape, and someone will paste one into the other.

- [ ] **Step 9: Nav and the corrected copy**

In `adminsite/app/(customer)/layout.tsx`, add two links inside the nav after the Billing link:

```tsx
                    <Link href="/users" className="text-ink-3">
                        People
                    </Link>
                    <Link href="/settings" className="text-ink-3">
                        Settings
                    </Link>
```

In `adminsite/app/(customer)/instances/new/CreateForm.tsx`, phase 2's copy is now false — the password does propagate. Replace that paragraph with:

```tsx
<p className="text-[0.82rem] text-ink-2">You sign in to it with this same email address and password. Changing your Vantage HQ password changes it here too.</p>
```

- [ ] **Step 10: Build the site**

Run:

```bash
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/adminsite":/app -w /app node:26-alpine \
  sh -c "npm ci --silent && npm run build"
```

Expected: a successful build listing `/users`, `/settings` and `/accept-invite` among the routes.

- [ ] **Step 11: Confirm no hex colours crept in**

Run: `grep -nE "#[0-9a-fA-F]{3,8}\b" adminsite/components/MembersPanel.tsx "adminsite/app/(customer)/users/InvitePanel.tsx" "adminsite/app/(customer)/settings/page.tsx" adminsite/app/accept-invite/page.tsx`
Expected: no matches. `CLAUDE.md`'s rule is that Tailwind in `adminsite/` maps `var(--…)` only and no component may carry a hex value.

- [ ] **Step 12: Commit**

```bash
git add adminsite admin/internal/api/customer.go
git commit -m "feat(adminsite): people, instance members and one password

The members panel is absent for self-hosted instances rather than disabled:
the backend refuses those, and a panel rendering controls the server will
reject is a panel that lies.

/auth/me now reports the caller's account role, so the UI hides what the
backend would refuse rather than discovering it in an error toast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 9: `web/` shows what it does not own

**Files:**

- Modify: `web/lib/api.ts`, `web/app/(app)/settings/instance/page.tsx`, `web/Dockerfile`, `.gitea/workflows/server-deploy.yml`

**Interfaces:**

- Consumes: `InstanceUser.auth_source === "hq"` from the existing `/instance/users` response.
- Produces: `NEXT_PUBLIC_HQ_URL` at build time; locked rows in `MembersCard`.

- [ ] **Step 1: Widen the type**

In `web/lib/api.ts`, replace the `InstanceUser` interface with:

```ts
export interface InstanceUser {
    user_id: string;
    instance_id: string;
    email: string;
    role: Role;
    // "hq" means the row was projected from a Vantage HQ account. Its role,
    // password and existence belong to HQ; this instance refuses to change them.
    auth_source: "local" | "oidc" | "hq";
    hq_user_id?: string;
    created_at: string;
    last_login?: string;
}
```

- [ ] **Step 2: Lock the hq rows**

In `web/app/(app)/settings/instance/page.tsx`, inside `MembersCard`, add above the `return`:

```tsx
const hqUrl = process.env.NEXT_PUBLIC_HQ_URL ?? "";
```

Then, in the row map, replace

```tsx
const locked = isSelf || (u.role === "owner" && !isOwner);
```

with

```tsx
const managedByHQ = u.auth_source === "hq";
// Locked here is a courtesy: the API returns 409 for an hq-sourced
// role change or deletion whether or not this select is rendered.
const locked = isSelf || managedByHQ || (u.role === "owner" && !isOwner);
```

Replace the sign-in cell with:

```tsx
<Td>
    <Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"}</Badge>
</Td>
```

And replace the actions cell with:

```tsx
<Td className="text-right">
    {managedByHQ ? (
        hqUrl ? (
            <a href={hqUrl} target="_blank" rel="noreferrer" className="text-xs text-text-secondary underline">
                Managed in Vantage HQ
            </a>
        ) : (
            <span className="text-xs text-text-tertiary">Managed in Vantage HQ</span>
        )
    ) : (
        !locked && (
            <Button
                variant="ghost"
                size="sm"
                onClick={() => {
                    if (confirm(`Remove ${u.email} from this instance?`)) removeUser(u.user_id);
                }}
            >
                Remove
            </Button>
        )
    )}
</Td>
```

- [ ] **Step 3: Wire the build variable**

In `web/Dockerfile`, after the existing `ARG`/`ENV` pair:

```dockerfile
ARG NEXT_PUBLIC_HQ_URL=
ENV NEXT_PUBLIC_HQ_URL=$NEXT_PUBLIC_HQ_URL
```

Empty default on purpose: a self-hosted install has no HQ portal, and the label falls back to plain text rather than linking somewhere that does not serve them.

In `.gitea/workflows/server-deploy.yml`, in the web image step, add the build arg:

```yaml
--build-arg NEXT_PUBLIC_HQ_URL="${{ vars.HQ_URL }}" \
```

- [ ] **Step 4: Build**

Run:

```bash
MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)/web":/app -w /app node:26-alpine \
  sh -c "npm ci --silent && npm run build"
```

Expected: a successful build.

- [ ] **Step 5: Commit**

```bash
git add web/lib/api.ts "web/app/(app)/settings/instance/page.tsx" web/Dockerfile \
  .gitea/workflows/server-deploy.yml
git commit -m "feat(web): hq-sourced members are read-only here

The lock is a courtesy — the API answers 409 either way. NEXT_PUBLIC_HQ_URL
defaults empty so a self-hosted install shows a plain label rather than a
link to a portal that does not serve them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

### Task 10: Documentation and the end-to-end proof

**Files:**

- Modify: `CLAUDE.md`

This task proves the phase. With no test suite, this transcript is the only evidence — run it in full. Numbers in brackets are the spec's phase-3 test list.

- [ ] **Step 1: Bring up a scratch stack**

```bash
cd deploy
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --build admin adminsite server web
docker compose logs -f admin | head -40
```

Expected in the log: `connected: admin=… control=…`, then any `backfill:` lines, then `admin listening on :8083`. A `backfill: FATAL` stops the phase — read it before continuing.

- [ ] **Step 2: Confirm the backfill did its two jobs**

```bash
docker compose exec -T mongo mongosh --quiet vantage_admin --eval '
  print("no role: " + db.customer_users.countDocuments({account_role:{$exists:false}}));
  print("members: " + db.instance_members.countDocuments({}));
  print("cloud instances: " + db.admin_instances.countDocuments({deployment:"cloud",status:{$ne:"deleted"}}));'
```

Expected: `no role: 0`, and `members` equal to the cloud-instance count (or lower only where the log said an instance had no hq-sourced owner).

Restart admin and run the same query. Expected: identical numbers — the backfill is idempotent.

- [ ] **Step 3: Invite, and prove a grant is refused before acceptance [23]**

Sign in as an account owner in the portal, invite `tester@example.com` as a member, then:

```bash
curl -sk -b cookies.txt -X POST https://vantage-hq.hostxtra.co.uk/api/instances/$INSTANCE/members \
  -H 'Content-Type: application/json' -d '{"user_id":"'$TESTER_USER_ID'","role":"member"}'
```

Expected: HTTP 409, `{"error":"they have not accepted their invitation yet"}`, and `db.users.countDocuments({instance_id:"…",email:"tester@example.com"})` still `0`.

- [ ] **Step 4: Accept, grant, and sign in to the instance [24]**

Open the emailed `/accept-invite` link, set a password, then grant them `member` on the instance. Then:

```bash
curl -sk -X POST https://<slug>.vantage.hostxtra.co.uk/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"tester@example.com","password":"<the password they chose>"}'
```

Expected: HTTP 200. This is the payoff — the HQ password authenticates against the instance with no call to admin.

- [ ] **Step 5: Two instances, two roles [25]**

Create a second Free instance is refused (one per account), so use a second account, or grant the same person into an instance from a staff-created account. Grant `admin` there and confirm:

```bash
docker compose exec -T mongo mongosh --quiet vantage --eval '
  db.users.find({hq_user_id:"'$TESTER_USER_ID'"},{instance_id:1,role:1,_id:0}).forEach(printjson)'
```

Expected: two rows, different `instance_id`, roles `member` and `admin`.

- [ ] **Step 6: Revoke removes the login but only there [26]**

Revoke the first instance. Expected: `db.users.countDocuments({instance_id:"<first>",hq_user_id:"…"})` is `0`, login against the first instance answers 401, and login against the second still answers 200.

- [ ] **Step 7: The last owner is protected, including a local one [27]**

Try to revoke the instance's sole owner. Expected: 409, "this is the instance's last owner". Now create a second owner **inside the instance** through `web/` (a local user, `auth_source: local`, role `owner`) and try again. Expected: it now succeeds, because `CountOtherOwners` counts control-plane owners rather than HQ members.

- [ ] **Step 8: Self-hosted refuses and writes nothing [28]**

Against a linked self-hosted instance:

```bash
curl -sk -b cookies.txt -X POST https://vantage-hq.hostxtra.co.uk/api/instances/$SELFHOSTED/members \
  -H 'Content-Type: application/json' -d '{"user_id":"'$TESTER_USER_ID'","role":"member"}'
```

Expected: HTTP 400 with the "manages its own users" message, and `db.instance_members.countDocuments({instance_id:"$SELFHOSTED"})` is `0`.

- [ ] **Step 9: A member can do none of it [29]**

Sign in as the `member`-role person and try each of: `POST /api/account/users`, `POST /api/instances`, `POST /api/instances/:id/members`.
Expected: HTTP 403 "your account role does not allow this" from all three. `GET /api/account/users` still answers 200.

- [ ] **Step 10: Password propagation, and the reconciler's repair [30]**

Change the tester's password in `/settings`. Expected: `{"updated":true,"propagation_pending":false}`, and their instance login now takes only the new password.

Now force a failure: set one instance's projected row back by hand, which is exactly what a failed write leaves behind.

```bash
docker compose exec -T mongo mongosh --quiet vantage --eval '
  db.users.updateOne({hq_user_id:"'$TESTER_USER_ID'"},{$set:{password_hash:"$2a$12$stale"}})'
docker compose restart admin
docker compose logs admin | grep hqsync
```

Expected: `hqsync: repaired 1 projected user(s) for tester@example.com` on the boot pass, and the hash matches `customer_users` again.

- [ ] **Step 11: The instance API refuses, not just the UI [31]**

As an instance admin inside `web/`, call the API directly:

```bash
curl -sk -b app_cookies.txt -X PUT https://<slug>.vantage.hostxtra.co.uk/api/instance/users/$HQ_CONTROL_USER_ID/role \
  -H 'Content-Type: application/json' -d '{"role":"owner"}'
curl -sk -b app_cookies.txt -X DELETE https://<slug>.vantage.hostxtra.co.uk/api/instance/users/$HQ_CONTROL_USER_ID
```

Expected: HTTP 409 from both, with the "managed in Vantage HQ" message. **This is the check that matters most** — the UI lock is decoration; this is the boundary.

- [ ] **Step 12: Update `CLAUDE.md`**

Under **Signup and verification**, after the existing bullets, add:

```markdown
An account is a team, not a person. `customer_users.account_role` is `owner`,
`admin` or `member` — the same three words as the control plane's roles, on
purpose. Owners and admins invite people, create instances and grant instance
access; billing is owner-only.

An invitation creates a `customer_user` with an **empty password hash**, which
cannot authenticate, and the invitee sets their own at `/accept-invite`. An
inviter-chosen password would be a shared credential to every instance that
person is later granted. `GET /auth/verify` therefore peeks before it consumes:
a token belonging to a passwordless row answers `{"needs_password":true}` and is
left unspent.
```

Under **Shared provisioning**, add a new subsection:

```markdown
### Grants project, they do not federate

Granting someone access to a cloud instance writes a real control-plane `users`
row through `cloudprov`, with `auth_source: "hq"` and `hq_user_id` set. The
instance authenticates it exactly as it authenticates anyone else, with **no
runtime dependency on admin**. Revoking deletes that row — the control plane has
no disabled state, and a row that exists is a row that can sign in.

`instance_members` in admin's database is only admin's _index_ of those
projections; the control-plane row is the access. That is why a failed
`instance_members` insert unwinds the projection, and why the boot backfill can
rebuild the index from the control plane but never the other way round.

**Self-hosted instances are never projected into.** All three mutating member
endpoints refuse when `deployment != cloud`.

The HQ password is the single source of truth for every `hq`-sourced row.
`PUT /api/account/password` rehashes and has `cloudprov` copy the hash to every
projected row; propagation is best-effort, and `admin/internal/hqsync` compares
and repairs every 15 minutes. It is **its own package rather than a pass inside
`inject`** — `inject` writes three licence fields and nothing else, and that
narrowness is what makes admin's reach into the control plane reviewable.

The control plane refuses to change an `hq`-sourced user's role or delete it
(`services.ErrHQManaged`, 409). `web/` shows those rows read-only with a link to
the portal, but the API is the boundary; the UI is a courtesy. There is no local
password-change endpoint at all, so there is no competing writer for the hash.
```

In the **Admin REST API** customer-session block, add:

```
GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id
PUT  /account/password                         # propagates to every projected user
GET,POST /instances/:id/members                # cloud only
PUT  /instances/:id/members/:uid/role · DELETE /instances/:id/members/:uid
```

and add `POST /auth/accept-invite` to the unauthenticated block.

In **MongoDB Collections**, note admin's `instance_members` alongside the existing admin-side collections description, and in the CI **Secrets / variables** table add:

```markdown
| `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. |
```

- [ ] **Step 13: Commit**

```bash
git add CLAUDE.md
git commit -m "docs: phase 3, grants project rather than federate

Records why instance_members is an index and not the authority, why hqsync
is not part of inject, and why an invitation carries no password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>"
```

---

## Done when

- Every step above is checked, and the Task 10 transcript ran end to end with the stated outputs.
- `admin`, `server`, `web` and `adminsite` all build.
- `grep -o 'db.Control("[a-z_]*")' admin/internal/**/*.go | sort -u` names exactly `instances` and `users`.
- `grep -n "password_hash\|role" admin/internal/inject/inject.go` is empty.
- Spec items 23–31 all observed, item 31 in particular against the API rather than the UI.

## Deployment order

1. **`server` first.** `ErrHQManaged` must be live before anyone can create an `hq`-sourced row through the portal, or a member could be edited in two places during the window.
2. **`admin` next.** Its boot backfill needs the control plane reachable, which it already is.
3. **`adminsite` and `web` last.** They only render what the two services above already enforce.

Rolling back `admin` alone is safe: projected users keep working, since they are ordinary control-plane rows. Rolling back `server` alone reopens the two-writer window, so do not.

## Not in this phase

Paddle billing, seat limits per plan, per-instance SSO for HQ-sourced people, and staff-side management of an account's people beyond the existing `staffCreateAccountUser`. A licence does not yet cap how many people an account may invite — `license.Limits` has no seat count, and adding one is a licensing change, not a membership one.
