From fbad3e44e52fe6094e0933818d3930a835e9a7e2 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 15:42:07 +0100 Subject: [PATCH] doc: Added phase 3 plan --- .../2026-07-26-cloud-instance-membership.md | 2903 +++++++++++++++++ 1 file changed, 2903 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-cloud-instance-membership.md diff --git a/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md b/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md new file mode 100644 index 0000000..9c761ac --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-cloud-instance-membership.md @@ -0,0 +1,2903 @@ +# 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 + 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" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + sharedmodels "github.com/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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 `"github.com/mrhid6/vantage/admin/internal/db"`, `"github.com/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" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/cloudprov" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + sharedmodels "github.com/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 `"github.com/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 " +``` + +--- + +### 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 "github.com/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" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/cloudprov" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" + "github.com/mrhid6/vantage/shared/provision" + sharedmodels "github.com/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 " +``` + +--- + +### 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 " +``` + +--- + +### 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" + + "github.com/mrhid6/vantage/admin/internal/cloudprov" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/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 `"github.com/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 " +``` + +--- + +### 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 " +``` + +--- + +### 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 = (path: string, payload?: unknown) => + req(path, { method: "PUT", body: payload ? JSON.stringify(payload) : undefined }); + +const del = (path: string) => req(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("/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(`/api/instances/${instanceId}/members`), + grantMember: (instanceId: string, user_id: string, role: InstanceRole) => + post(`/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("member"); + const [error, setError] = useState(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 ; + + const myRole = session?.account_role; + const canManage = myRole === "owner" || myRole === "admin"; + const assignable = myRole === "owner" ? ROLES : ROLES.filter((r) => r !== "owner"); + + return ( +
+ {error && ( +

+ {error} +

+ )} + + + + + + + + + + + {(users.data ?? []).map((u) => { + const isSelf = u.email === session?.email; + return ( + + + + + + + ); + })} + +
EmailAccount roleStatus +
+ {u.email} + {isSelf && (you)} + + {canManage && !isSelf ? ( + + ) : ( + + {u.account_role} + + )} + + {u.verified_at ? "Active" : "Invitation pending"} + + {canManage && !isSelf && ( + + )} +
+ + {canManage && ( +
{ + e.preventDefault(); + setError(null); + if (email.trim()) invite.mutate(); + }} + > +

Invite someone

+ setEmail(e.target.value)} + required + hint="They choose their own password from the emailed link. Nothing happens until they open it." + /> + +

+ An account role is not access to an instance. Give them that on the + instance itself. +

+ + + )} +
+ ); +} +``` + +Create `adminsite/app/(customer)/users/page.tsx`: + +```tsx +import { InvitePanel } from "./InvitePanel"; + +export default function UsersPage() { + return ( +
+
+

People

+

+ Everyone on this account. Owners and admins can invite people and grant them + access to instances; billing stays with owners. +

+
+ +
+ ); +} +``` + +- [ ] **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("member"); + const [error, setError] = useState(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 ( +
+
+

Who can sign in

+

+ Each person here has a real user inside this instance and signs in with their + Vantage HQ password. +

+
+ + {error &&

{error}

} + +
    + {(members.data ?? []).map((m) => ( +
  • + {m.email} + + {canManage ? ( + + ) : ( + {m.role} + )} + {canManage && ( + + )} + +
  • + ))} + {members.data?.length === 0 && ( +
  • Nobody has been added yet.
  • + )} +
+ + {canManage && ( +
{ + e.preventDefault(); + setError(null); + if (selected) grant.mutate(); + }} + > + + + +
+ )} + + {canManage && pending > 0 && ( +

+ {pending} invited {pending === 1 ? "person has" : "people have"} not accepted + yet and cannot be added until they do. +

+ )} +
+ ); +} +``` + +- [ ] **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 `` of the returned tree: + +```tsx + {instance.deployment === "cloud" ? ( + + ) : ( +

+ Users for this install are managed inside it, in Settings → Instance. We do not + have access to your own deployment. +

+ )} +``` + +- [ ] **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(null); + const [done, setDone] = useState(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 ( +
+
+

Settings

+

+ Your password signs you in here and into every Vantage instance you belong to. + Changing it changes all of them. +

+
+ +
{ + e.preventDefault(); + setError(null); + setDone(null); + change.mutate(); + }} + > + setCurrent(e.target.value)} + required + /> + setNext(e.target.value)} + required + minLength={12} + hint="At least 12 characters." + error={error ?? undefined} + /> + {done &&

{done}

} + + +
+ ); +} +``` + +- [ ] **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(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

That link is missing its token.

; + if (done) + return ( +
+

You're in

+

Sign in with your email address and new password.

+ + Sign in + +
+ ); + + return ( +
{ + e.preventDefault(); + setError(null); + accept.mutate(); + }} + > +

Choose a password

+

+ This password signs you into Vantage HQ and into every instance you are given + access to. Nobody who invited you can see it. +

+ setPassword(e.target.value)} + required + minLength={12} + hint="At least 12 characters." + error={error ?? undefined} + /> + + + ); +} + +export default function AcceptInvitePage() { + return ( +
+ Loading…

}> + +
+
+ ); +} +``` + +- [ ] **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 + + People + + + Settings + +``` + +In `adminsite/app/(customer)/instances/new/CreateForm.tsx`, phase 2's copy is now false — the password does propagate. Replace that paragraph with: + +```tsx +

+ You sign in to it with this same email address and password. Changing your Vantage + HQ password changes it here too. +

+``` + +- [ ] **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 " +``` + +--- + +### 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 + + + {u.auth_source === "oidc" ? "SSO" : u.auth_source === "hq" ? "Vantage HQ" : "Password"} + + +``` + +And replace the actions cell with: + +```tsx + + {managedByHQ ? ( + hqUrl ? ( + + Managed in Vantage HQ + + ) : ( + Managed in Vantage HQ + ) + ) : ( + !locked && ( + + ) + )} + +``` + +- [ ] **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 " +``` + +--- + +### 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://.vantage.hostxtra.co.uk/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"tester@example.com","password":""}' +``` +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:"",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://.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://.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 " +``` + +--- + +## 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.