diff --git a/docs/superpowers/plans/2026-08-12-instance-rename.md b/docs/superpowers/plans/2026-08-12-instance-rename.md new file mode 100644 index 0000000..981a4f6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-instance-rename.md @@ -0,0 +1,993 @@ +# Instance Rename in Vantage HQ — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an HQ customer (owner or admin) rename a cloud instance, which re-derives its slug and moves it to a new DNS host, with staff able to do the same without the cooldown. + +**Architecture:** Slug derivation stays in `shared/provision`, beside the create path that already owns it. Admin reaches the control plane only through `cloudprov`, writing `instances` — a collection it already writes. Admin's own row (`admin_instances`) is updated second and carries the 24h cooldown timestamp, because the cooldown is admin's policy and the control plane has no opinion about it. The portal shows the new host and asks the customer to click through; it does not redirect. + +**Tech Stack:** Go 1.x (gin, mongo-driver v2), Next.js 16 App Router + TanStack Query + Tailwind 3 (`adminsite`). + +**Spec:** `docs/superpowers/specs/2026-08-12-instance-rename-design.md` + +## Global Constraints + +- A licence binds an instance **UUID**, not a slug. A rename must not issue a licence, call Paddle, or touch `licenses`, `subscriptions` or `entitlements`. +- Admin's control-plane write boundary is unchanged: `cloudprov` writes `instances` and `users` only. Do not add a write to any other control-plane collection. +- Customer rename is **cloud only**. Self-hosted is refused with the existing `selfHostedRefusal` constant and HTTP **400**, matching `members.go`. +- Cooldown for customers is **24 hours**, tracked by `admin_instances.renamed_at`. Staff bypass it and must **not** write `renamed_at`. +- No `-2` suffix loop on rename. A taken slug is a refusal (`ErrSlugTaken` → HTTP 409). +- No component in `adminsite` may carry a hex colour; use the existing token classes (`text-ink-2`, `text-ink-3`, `border-rule`, `text-accent`, `text-expired`, `bg-panel-2`). +- The host domain used for display is `vantage.hostxtra.co.uk`, already hardcoded in `adminsite/components/InstanceRecord.tsx` and the customer instance page. +- Commit messages follow the repo's existing style: `feat: Sentence case summary` / `fix: …` / `docs: …`. + +--- + +### Task 1: Slug derivation and the control-plane rename + +**Files:** +- Modify: `shared/provision/instance.go` +- Create: `shared/provision/slug_test.go` + +**Interfaces:** +- Consumes: `BaseSlug(name string) (string, error)`, `ErrNameRejected` — both already in `shared/provision`. +- Produces: + - `provision.ErrSlugTaken` (`error`) + - `provision.RenameSlug(name, currentSlug string) (string, error)` + - `provision.RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)` + - `provision.RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error` + +- [ ] **Step 1: Write the failing test** + +Create `shared/provision/slug_test.go`: + +```go +package provision + +import ( + "errors" + "testing" +) + +func TestRenameSlug(t *testing.T) { + cases := []struct { + name string + newName string + currentSlug string + want string + wantErr error + }{ + {"derives a new slug", "Acme Ltd", "acme", "acme-ltd", nil}, + {"unchanged when the name still derives to the current slug", "ACME!", "acme", "acme", nil}, + // A creation-time collision suffix does not derive from any name, so a + // rename off it is a real move even when the name is untouched. + {"moves off a collision suffix", "Acme", "acme-2", "acme", nil}, + {"too short is rejected", "ab", "acme", "", ErrNameRejected}, + {"reserved is rejected", "Admin", "acme", "", ErrNameRejected}, + {"punctuation only is rejected", "!!!", "acme", "", ErrNameRejected}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := RenameSlug(tc.newName, tc.currentSlug) + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("RenameSlug(%q, %q) error = %v, want %v", tc.newName, tc.currentSlug, err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("RenameSlug(%q, %q) unexpected error: %v", tc.newName, tc.currentSlug, err) + } + if got != tc.want { + t.Fatalf("RenameSlug(%q, %q) = %q, want %q", tc.newName, tc.currentSlug, got, tc.want) + } + }) + } +} + +// A name longer than MaxSlugLength is truncated rather than refused, exactly as +// the create path truncates it — the two must not disagree about what is legal. +func TestRenameSlugTruncatesLongNames(t *testing.T) { + long := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 50 chars + got, err := RenameSlug(long, "old") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != MaxSlugLength { + t.Fatalf("slug length = %d, want %d", len(got), MaxSlugLength) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /go-projects/vantage && go test ./shared/provision/ -run TestRenameSlug -v` +Expected: FAIL — `undefined: RenameSlug` + +- [ ] **Step 3: Write the implementation** + +Append to `shared/provision/instance.go`: + +```go +// ErrSlugTaken means the slug a new name derives to already belongs to another +// instance. +// +// Rename refuses rather than appending a counter the way creation does. Creation +// appends because the customer is waiting on an instance and any free slug will +// do; a rename is a request for one specific host, and silently landing them on +// "acme-2" answers a question they did not ask. +var ErrSlugTaken = errors.New("slug taken") + +// RenameSlug derives the slug a rename to name would move an instance to, given +// the slug it holds now. +// +// It returns the current slug unchanged when the name still derives to it, so a +// cosmetic edit — capitalisation, punctuation, a trailing "Ltd." — is not a move +// and cannot collide with the instance's own slug. +func RenameSlug(name, currentSlug string) (string, error) { + base, err := BaseSlug(name) + if err != nil { + return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) + } + if base == currentSlug { + return currentSlug, nil + } + return base, nil +} + +// RenameInstance changes an instance's name and re-derives its slug from it. +// +// The count-then-update is racy on its own, and is safe for the same reason +// CreateInstanceWithID's loop is: instances.slug carries a unique index, so a +// lost race surfaces as a duplicate-key error. Unlike creation there is nothing +// to retry with — the caller asked for one specific name — so it becomes +// ErrSlugTaken. Do not remove the duplicate-key branch, and do not remove the +// index. +func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) { + var inst models.Instance + if err := db.Collection("instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&inst); err != nil { + return nil, err + } + + slug, err := RenameSlug(name, inst.Slug) + if err != nil { + return nil, err + } + + if slug != inst.Slug { + n, err := db.Collection("instances").CountDocuments(ctx, bson.M{ + "slug": slug, + "instance_id": bson.M{"$ne": instanceID}, + }) + if err != nil { + return nil, err + } + if n > 0 { + return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug) + } + } + + if _, err := db.Collection("instances").UpdateOne(ctx, + bson.M{"instance_id": instanceID}, + bson.M{"$set": bson.M{"name": name, "slug": slug}}); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, fmt.Errorf("%w: %s", ErrSlugTaken, slug) + } + return nil, err + } + + inst.Name = name + inst.Slug = slug + return &inst, nil +} + +// RestoreInstanceIdentity writes an exact name and slug back, unwinding a rename +// whose caller-side bookkeeping then failed. +// +// It derives nothing. The values being restored may include a creation-time +// collision suffix that no name derives to, so re-running RenameInstance with the +// old name would not reproduce them. +func RestoreInstanceIdentity(ctx context.Context, db *mongo.Database, instanceID, name, slug string) error { + _, err := db.Collection("instances").UpdateOne(ctx, + bson.M{"instance_id": instanceID}, + bson.M{"$set": bson.M{"name": name, "slug": slug}}) + return err +} +``` + +- [ ] **Step 4: Run the tests** + +Run: `cd /go-projects/vantage && go test ./shared/provision/ -v && go build ./shared/...` +Expected: PASS on every subtest; build clean. + +- [ ] **Step 5: Commit** + +```bash +git add shared/provision/instance.go shared/provision/slug_test.go +git commit -m "feat: Add instance rename to shared provisioning" +``` + +--- + +### Task 2: Admin's row and the cloudprov wrappers + +**Files:** +- Modify: `admin/internal/models/models.go` (the `Instance` struct, ~line 129; constants block near `RenewWindow`, ~line 105) +- Modify: `admin/internal/cloudprov/cloudprov.go` + +**Interfaces:** +- Consumes: `provision.RenameInstance`, `provision.RestoreInstanceIdentity` (Task 1). +- Produces: + - `models.RenameCooldown` (`time.Duration`) + - `models.Instance.RenamedAt *time.Time` (bson `renamed_at`, json `renamed_at`) + - `cloudprov.RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)` + - `cloudprov.RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error` + +- [ ] **Step 1: Add the cooldown constant** + +In `admin/internal/models/models.go`, directly beneath the `RenewWindow` block: + +```go +// RenameCooldown is how long a customer must wait between renames of one +// instance. +// +// A rename moves the instance's DNS host and invalidates every saved link to it, +// so this exists to make that a considered act rather than a slider. Staff are +// not subject to it: a support conversation about a name is already a human +// deciding. +const RenameCooldown = 24 * time.Hour +``` + +- [ ] **Step 2: Add the field to `Instance`** + +In the same file, inside the `Instance` struct, after `RelinkCount`: + +```go + // RenamedAt is when this instance last changed name, and backs the customer + // rename cooldown. It is a pointer because absent means "never renamed"; a + // zero time.Time would read as year 1 — an inert cooldown, but only by + // accident. Staff renames deliberately leave it alone. + RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"` +``` + +- [ ] **Step 3: Add the cloudprov wrappers** + +Append to `admin/internal/cloudprov/cloudprov.go`: + +```go +// RenameInstance changes a cloud instance's name and moves it to the slug that +// name derives to. +// +// It writes `instances` and nothing else, so admin's control-plane write +// boundary is unchanged. It issues no licence: a licence binds the instance +// UUID, which a rename never touches. +func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error) { + return provision.RenameInstance(ctx, db.ControlDB(), instanceID, name) +} + +// RestoreInstanceIdentity puts an instance's previous name and slug back, for a +// caller unwinding a rename whose admin-side write failed. Leaving the two +// databases disagreeing would have HQ print a host that is not the host. +func RestoreInstanceIdentity(ctx context.Context, instanceID, name, slug string) error { + return provision.RestoreInstanceIdentity(ctx, db.ControlDB(), instanceID, name, slug) +} +``` + +- [ ] **Step 4: Build** + +Run: `cd /go-projects/vantage && go build ./admin/... ./shared/...` +Expected: clean build, no output. + +- [ ] **Step 5: Commit** + +```bash +git add admin/internal/models/models.go admin/internal/cloudprov/cloudprov.go +git commit -m "feat: Add rename cooldown field and cloudprov rename" +``` + +--- + +### Task 3: Customer rename endpoint + +**Files:** +- Modify: `admin/internal/api/customer.go` (add handler; `loginURLFor` at ~line 442 is already in this file) +- Modify: `admin/internal/api/routes.go` (~line 77, beside the other `/instances/:id/*` customer routes) + +**Interfaces:** +- Consumes: `ownedInstance(c, id) (*models.Instance, bool)`, `selfHostedRefusal` (`members.go`), `loginURLFor(slug) string`, `cloudprov.RenameInstance`, `cloudprov.RestoreInstanceIdentity`, `models.RenameCooldown`, `provision.ErrSlugTaken`, `provision.ErrNameRejected`. +- Produces: `PUT /api/instances/:id/name` returning `{instance_id, name, slug, login_url}`. + +- [ ] **Step 1: Write the handler** + +Append to `admin/internal/api/customer.go`: + +```go +// renameInstance changes a cloud instance's name and moves it to the slug that +// name derives to. +// +// The control plane is written FIRST, because instances.slug carries the unique +// index and that index is what actually settles a race between two accounts +// reaching for the same name. Admin's own row follows; if that write fails the +// control plane is put back, because HQ printing a host that is not the host is +// worse than a failed rename. +// +// No licence is issued and Paddle is not called: a licence binds the instance +// UUID, and a rename does not change it. +func renameInstance(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 + } + if inst.Placeholder { + c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"}) + return + } + + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + if inst.RenamedAt != nil { + if until := inst.RenamedAt.Add(models.RenameCooldown); time.Now().UTC().Before(until) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": fmt.Sprintf("this instance was renamed recently; it can be renamed again after %s UTC", until.Format("2 Jan 2006 15:04")), + "retry_after": until, + }) + return + } + } + + ctx := c.Request.Context() + renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name) + switch { + case errors.Is(err, provision.ErrSlugTaken): + c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"}) + return + case errors.Is(err, provision.ErrNameRejected): + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + case err != nil: + log.Printf("renameInstance: control plane rename of %s: %v", inst.InstanceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"}) + return + } + + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$set": bson.M{ + "name": renamed.Name, + "slug": renamed.Slug, + "renamed_at": time.Now().UTC(), + }}); err != nil { + if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil { + log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr) + } + log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"}) + return + } + + s := auth.Current(c) + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID, + Target: inst.InstanceID, Detail: inst.Slug + " -> " + renamed.Slug, IP: c.ClientIP()}) + + c.JSON(http.StatusOK, gin.H{ + "instance_id": inst.InstanceID, + "name": renamed.Name, + "slug": renamed.Slug, + // The same builder the licence emails use, rather than a second opinion + // about how a tenant host is spelled. Empty when APP_LOGIN_URL is unset. + "login_url": loginURLFor(renamed.Slug), + }) +} +``` + +- [ ] **Step 2: Check the imports** + +`customer.go` must import `errors`, `fmt`, `log`, `net/http`, `strings`, `time`, `audit`, `auth`, `cloudprov`, `db`, `models`, `license`, `provision`, `gin`, `bson`. Most are already there — add only what the compiler asks for. `provision` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision`; `license` is `gitea.hostxtra.co.uk/mrhid6/vantage/shared/license`. + +- [ ] **Step 3: Mount the route** + +In `admin/internal/api/routes.go`, in the `cust` group beside the other instance routes (after `cust.POST("/instances/:id/claim-free", …)`): + +```go + // Renaming moves the instance's DNS host, so it is owner-or-admin like + // every other instance mutation. Cloud only; the handler refuses the rest. + cust.PUT("/instances/:id/name", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + renameInstance) +``` + +- [ ] **Step 4: Build** + +Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add admin/internal/api/customer.go admin/internal/api/routes.go +git commit -m "feat: Add customer instance rename endpoint" +``` + +--- + +### Task 4: Staff rename endpoint + +**Files:** +- Modify: `admin/internal/api/staff.go` +- Modify: `admin/internal/api/routes.go` (the `staff` group, beside `staff.POST("/instances/:id/relink", …)`) + +**Interfaces:** +- Consumes: everything Task 3 consumes, plus `db.Admin`. +- Produces: `PUT /api/staff/instances/:id/name` returning `{instance_id, name, slug}`. + +- [ ] **Step 1: Write the handler** + +Append to `admin/internal/api/staff.go`: + +```go +// staffRenameInstance renames any instance, with no cooldown. +// +// It does NOT write renamed_at: a staff rename must not start the customer's +// 24h clock, or fixing a name for someone locks them out of fixing it further. +// +// On self-hosted it changes admin's label only. There is no control-plane row to +// write — the install is the customer's — and no slug, because self-hosted has +// no tenant subdomain. +func staffRenameInstance(c *gin.Context) { + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + name := strings.TrimSpace(body.Name) + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + ctx := c.Request.Context() + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + set := bson.M{"name": name} + slug := inst.Slug + + if inst.Deployment == license.DeploymentCloud && !inst.Placeholder { + renamed, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name) + switch { + case errors.Is(err, provision.ErrSlugTaken): + c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use"}) + return + case errors.Is(err, provision.ErrNameRejected): + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + case err != nil: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + slug = renamed.Slug + set["slug"] = renamed.Slug + } + + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set}); err != nil { + if inst.Deployment == license.DeploymentCloud && !inst.Placeholder { + if rbErr := cloudprov.RestoreInstanceIdentity(ctx, inst.InstanceID, inst.Name, inst.Slug); rbErr != nil { + log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr) + } + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + audit.Write(ctx, models.AuditEntry{ + Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID, + Target: inst.InstanceID, Detail: inst.Slug + " -> " + slug, IP: c.ClientIP()}) + + c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug}) +} +``` + +`staff.go` will need `errors`, `log`, `cloudprov` and `provision` added to its imports; `fmt`, `net/http`, `strings`, `time`, `audit`, `auth`, `db`, `models`, `license`, `bson` are already there. + +- [ ] **Step 2: Mount the route** + +In `routes.go`, in the `staff` group after `staff.POST("/instances/:id/relink", staffRelink)`: + +```go + staff.PUT("/instances/:id/name", staffRenameInstance) +``` + +- [ ] **Step 3: Build** + +Run: `cd /go-projects/vantage && go build ./admin/... && go vet ./admin/internal/api/` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add admin/internal/api/staff.go admin/internal/api/routes.go +git commit -m "feat: Add staff instance rename endpoint" +``` + +--- + +### Task 5: `adminsite` API client and slug preview + +**Files:** +- Create: `adminsite/lib/slug.ts` +- Modify: `adminsite/lib/api.ts` (the `Instance` interface ~line 123; the `api` object's instance calls ~line 305; `api.staff` ~line 360) + +**Interfaces:** +- Consumes: `PUT /api/instances/:id/name`, `PUT /api/staff/instances/:id/name` (Tasks 3 and 4). +- Produces: + - `INSTANCE_DOMAIN`, `slugify(name: string): string`, `slugError(name: string): string | undefined` from `@/lib/slug` + - `RenameResult` interface, `api.renameInstance(id, name): Promise`, `api.staff.renameInstance(id, name): Promise` + - `Instance.renamed_at?: string` + +- [ ] **Step 1: Create the slug mirror** + +Create `adminsite/lib/slug.ts`: + +```ts +/* + * A TypeScript mirror of shared/provision's slug rules, used ONLY to preview the + * host a rename would move an instance to while the customer types. + * + * It is a second implementation of Slugify, BaseSlug and ReservedSlugs, and it + * must change in the same commit as the Go one — the same hazard as + * web/lib/targets.ts. The preview is a courtesy; the server's 409 is the + * boundary, and the two are allowed to disagree without anything breaking. + */ + +/** Mirrors provision.MinSlugLength / MaxSlugLength. */ +export const MIN_SLUG_LENGTH = 3; +export const MAX_SLUG_LENGTH = 40; + +/** Mirrors provision.ReservedSlugs. */ +const RESERVED = new Set([ + "www", "api", "app", "admin", "auth", + "install", "static", "_next", "default", +]); + +/* + * The tenant subdomain namespace. Also hardcoded in InstanceRecord.tsx and the + * customer instance page; those predate this file and are left alone rather than + * refactored under a rename change. + */ +export const INSTANCE_DOMAIN = "vantage.hostxtra.co.uk"; + +/** Mirrors provision.Slugify. */ +export function slugify(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Mirrors provision.BaseSlug's truncation. */ +export function baseSlug(name: string): string { + return slugify(name).slice(0, MAX_SLUG_LENGTH); +} + +/** The reason a name cannot become a slug, or undefined when it can. */ +export function slugError(name: string): string | undefined { + const base = slugify(name); + if (base.length < MIN_SLUG_LENGTH) { + return `Needs at least ${MIN_SLUG_LENGTH} letters or digits.`; + } + if (RESERVED.has(base.slice(0, MAX_SLUG_LENGTH))) { + return "That name is reserved."; + } + return undefined; +} + +/** The host an instance on this slug is reached at. */ +export function hostFor(slug: string): string { + return `${slug}.${INSTANCE_DOMAIN}`; +} +``` + +- [ ] **Step 2: Extend the API client** + +In `adminsite/lib/api.ts`, add `renamed_at` to `Instance` (after `relink_count`): + +```ts + renamed_at?: string; +``` + +Add the response type beside the other interfaces: + +```ts +export interface RenameResult { + instance_id: string; + name: string; + slug: string; + /** Empty when APP_LOGIN_URL is unset on the server. */ + login_url?: string; +} +``` + +Add the call to the `api` object, after `renewInstance`: + +```ts + renameInstance: (id: string, name: string) => + put(`/api/instances/${id}/name`, { name }), +``` + +And to `api.staff`, after `relink`: + +```ts + renameInstance: (id: string, name: string) => + put(`/api/staff/instances/${id}/name`, { name }), +``` + +- [ ] **Step 3: Type-check** + +Run: `cd /go-projects/vantage/adminsite && npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add adminsite/lib/slug.ts adminsite/lib/api.ts +git commit -m "feat: Add rename calls and slug preview to the HQ client" +``` + +--- + +### Task 6: The rename panel and the customer instance page + +**Files:** +- Create: `adminsite/components/RenamePanel.tsx` +- Modify: `adminsite/app/(customer)/instances/[id]/page.tsx` + +**Interfaces:** +- Consumes: `api.renameInstance` / `api.staff.renameInstance`, `RenameResult` (Task 5); `slugError`, `baseSlug`, `hostFor` (Task 5); `Panel`, `Note` (`@/components/Panel`), `Button` (`@/components/Button`), `Field` (`@/components/Field`), `ApiError` (`@/lib/api`). +- Produces: `RenamePanel({ currentName, currentSlug, onRename })` — a default-collapsed control; `onRename` is `(name: string) => Promise`. + +- [ ] **Step 1: Create the component** + +Create `adminsite/components/RenamePanel.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { Button } from "./Button"; +import { Field } from "./Field"; +import { Note } from "./Panel"; +import { ApiError, type RenameResult } from "@/lib/api"; +import { baseSlug, hostFor, slugError } from "@/lib/slug"; + +/* + * The rename control, and only the control — the same shape as RelinkPanel: an + * input that expands in place rather than a modal, because this app has no modal + * and one action with one field does not need one. + * + * The host preview is drawn from lib/slug.ts, a mirror of the Go rules. It can + * disagree with the server; the 409 that comes back is the answer that counts. + */ +export function RenamePanel({ + currentName, + currentSlug, + onRename, +}: { + currentName: string; + currentSlug: string; + onRename: (name: string) => Promise; +}) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(currentName); + const [error, setError] = useState(); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(); + + const name = value.trim(); + const derived = baseSlug(name); + const invalid = slugError(name); + // A cosmetic edit that lands on the same slug is still a rename worth doing — + // the name is what the customer reads. Only an empty or unchanged name is + // nothing to submit. + const unchanged = name === currentName.trim(); + + async function submit() { + setError(undefined); + setBusy(true); + try { + const res = await onRename(name); + setDone(res); + setOpen(false); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Rename failed. Try again."); + } finally { + setBusy(false); + } + } + + if (done) { + const host = done.login_url || `https://${hostFor(done.slug)}`; + return ( + + + + This instance is now {done.name}, at{" "} + {hostFor(done.slug)}. The old address has stopped working, and your + sign-in does not follow it — you will need to sign in again there. + + + Open {hostFor(done.slug)} → + + + + ); + } + + return ( +
+ {open && ( + setValue(e.target.value)} + error={error ?? (name ? invalid : undefined)} + hint={ + name && !invalid ? ( + <> + Moves to {hostFor(derived)} + {derived === currentSlug && " — the address does not change"} + + ) : ( + "Letters and digits; everything else becomes a hyphen." + ) + } + /> + )} +
+ + {open && ( + + Anyone signed in will need to sign in again at the new address, and links to the old one stop working. + + )} +
+
+ ); +} +``` + +`Note` is `({ tone = "accent" | "warn" | "expired", children })` and renders a `

`, which is why the success state wraps its two lines in a `` rather than block elements. + +- [ ] **Step 2: Mount it on the customer instance page** + +In `adminsite/app/(customer)/instances/[id]/page.tsx`: + +Add the imports: + +```tsx +import { RenamePanel } from "@/components/RenamePanel"; +``` + +and + +```tsx +import { useSession } from "@/lib/session"; +``` + +Inside `InstancePage`, with the other hooks (hooks must precede the early returns already in this component): + +```tsx + // useSession is the app's one way to ask who the caller is — it shares the + // ["me"] query, so this adds no request. + const { session } = useSession(); +``` + +and after the `cloud` const: + +```tsx + const mayRename = session?.account_role === "owner" || session?.account_role === "admin"; +``` + +Then add the panel to `PageFrame`'s children, directly after the `MembersPanel` line: + +```tsx + {/* + * Address rather than "Rename": the panel is about where this + * instance lives, and the rename is how you change it. Cloud + * only — a self-hosted install has no tenant subdomain for us to + * move. + */} + {cloud && mayRename && ( + +

+ The instance name is where its address comes from. Renaming moves it to a new address and releases the old + one, so saved links and bookmarks to it stop working. +

+ { + const res = await api.renameInstance(instance.instance_id, name); + qc.invalidateQueries({ queryKey: ["account"] }); + return res; + }} + /> + + )} +``` + +- [ ] **Step 3: Build** + +Run: `cd /go-projects/vantage/adminsite && npm run build` +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add adminsite/components/RenamePanel.tsx "adminsite/app/(customer)/instances/[id]/page.tsx" +git commit -m "feat: Let a customer rename a cloud instance from HQ" +``` + +--- + +### Task 7: Staff instance page rename + +**Files:** +- Modify: `adminsite/app/(staff)/staff/instances/[id]/page.tsx` + +**Interfaces:** +- Consumes: `RenamePanel` (Task 6), `api.staff.renameInstance` (Task 5). +- Produces: nothing later tasks depend on. + +- [ ] **Step 1: Add the panel** + +In `adminsite/app/(staff)/staff/instances/[id]/page.tsx`, add the imports: + +```tsx +import { RenamePanel } from "@/components/RenamePanel"; +``` + +and, inside `StaffInstancePage`, add `const qc = useQueryClient();` at the top of the component if it is not already there (`useQueryClient` is already imported for `EntitlementSection`). + +Add this panel after the "Licence history" panel: + +```tsx + {/* + * Staff rename has no cooldown and does not start the customer's: + * fixing a name on someone's behalf must not spend their next 24 + * hours. + */} + + { + const res = await api.staff.renameInstance(data.instance.instance_id, name); + qc.invalidateQueries({ queryKey: ["staff-instance", id] }); + return res; + }} + /> + +``` + +- [ ] **Step 2: Build** + +Run: `cd /go-projects/vantage/adminsite && npm run build` +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add "adminsite/app/(staff)/staff/instances/[id]/page.tsx" +git commit -m "feat: Let staff rename an instance" +``` + +--- + +### Task 8: Documentation and end-to-end verification + +**Files:** +- Modify: `CLAUDE.md` (the Admin REST API route list, and the `admin_instances` note under MongoDB Collections) + +**Interfaces:** +- Consumes: everything above. +- Produces: nothing. + +- [ ] **Step 1: Update the Admin REST API route list** + +In `CLAUDE.md`, in the customer-session block, after the `POST /instances/:id/claim-free` line: + +``` +PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown) +``` + +and in the staff-session block, after `POST /instances/:id/issue · /instances/:id/relink`: + +``` +PUT /instances/:id/name # rename any instance, no cooldown +``` + +- [ ] **Step 2: Add the design note** + +In `CLAUDE.md`, under "Grants project, they do not federate" (admin's control-plane write boundary is described nearby), add a short paragraph: + +```markdown +**A rename moves the host, and the licence does not care.** `PUT +/api/instances/:id/name` re-derives the slug from the new name through +`provision.RenameSlug` — the same rules that named the instance at creation — +and writes the control plane first, because `instances.slug`'s unique index is +what settles a race between two accounts reaching for one name. A taken slug is +a refusal, not an `acme-2`: creation appends a counter because any free slug +will do, and a rename is a request for one specific host. A licence binds the +instance UUID, so nothing is reissued and Paddle is not called. The old host +keeps resolving for up to 60s (`instancehost.go`'s cache, which admin cannot +reach into), and `km_session` is host-only, so the customer signs in again on +the new address — the portal says so rather than redirecting them into a login +screen with no explanation. The 24h cooldown lives on `admin_instances.renamed_at` +because it is admin's policy; staff bypass it and must not write the field. +``` + +- [ ] **Step 3: Full build and test** + +Run: +```bash +cd /go-projects/vantage && go build ./... && go test ./shared/... && (cd adminsite && npm run build) +``` +Expected: all clean. + +- [ ] **Step 4: Manual verification against a running stack** + +Work through each and record the result: + +1. Rename a cloud instance from `/instances/` as an owner. Panel reports the new host. +2. In Mongo: `db.instances.findOne({instance_id})` and `db.admin_instances.findOne({instance_id})` agree on `name` and `slug`; `admin_instances.renamed_at` is set. +3. The new host serves a login page. The old host stops resolving to the instance within ~60 seconds. +4. A second rename inside 24 hours answers `429` with the unlock time. +5. Renaming onto a slug another instance holds answers `409` and changes neither database. +6. `PUT /api/instances/:id/name` on a self-hosted instance answers `400` with the `selfHostedRefusal` message. +7. `GET /api/staff/audit` shows `instance.renamed` with `old-slug -> new-slug`. +8. Staff rename of the same instance succeeds immediately and leaves `renamed_at` unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: Document instance rename in HQ" +``` + +- [ ] **Step 6: Refresh the knowledge graph** + +```bash +graphify update . +``` diff --git a/docs/superpowers/specs/2026-08-12-instance-rename-design.md b/docs/superpowers/specs/2026-08-12-instance-rename-design.md index 61ecdcc..671e2e3 100644 --- a/docs/superpowers/specs/2026-08-12-instance-rename-design.md +++ b/docs/superpowers/specs/2026-08-12-instance-rename-design.md @@ -143,7 +143,7 @@ Refusals, in the order checked: | Condition | Status | Body | |---|---|---| -| `deployment != cloud` | 409 | `self_hosted` | +| `deployment != cloud` | 400 | `selfHostedRefusal`, the same constant and status the member endpoints already answer with | | `placeholder` | 409 | instance is not provisioned yet | | within 24h of `renamed_at` | 429 | includes the UTC time it unlocks | | `provision.ErrNameRejected` | 422 | the wrapped reason, verbatim | @@ -175,24 +175,28 @@ warning as `web/lib/targets.ts`: it is a second implementation and must change i the same commit as the Go one. The preview can disagree with the server — the 409 is the answer that counts. -### `components/RenameInstanceModal.tsx` +### `components/RenamePanel.tsx` + +An inline panel, not a modal — `adminsite` has no modal component, and the +codebase's idiom for a destructive-ish action with one input is `RelinkPanel`: +a control that expands in place inside a `Panel`. Prefilled with the current name. Below the input, a live line reading `acme-ltd.vantage.hostxtra.co.uk` as the customer types, and a note that they will need to sign in again on the new host. Submit is disabled while the derived slug is unchanged or invalid. -Mounted from a `Rename` action in `PageHeader` on -`app/(customer)/instances/[id]/page.tsx`, rendered only when the instance is -cloud and `account_role` is `owner` or `admin`. The staff instance page mounts -the same component against the staff route. +It lives in an "Address" panel on `app/(customer)/instances/[id]/page.tsx`, +rendered only when the instance is cloud and `account_role` is `owner` or +`admin`. The staff instance page mounts the same component against the staff +route. `InstanceRecord` on the Overview page is not touched: it stays a summary, and the rename is a decision that deserves the detail page. ### After a successful rename -Invalidate `["account"]`, close the modal, and let the page redraw with the new +Invalidate `["account"]`, collapse the panel, and let the page redraw with the new name and host. The Console rail card shows the new host, with a note: > This instance now lives at `acme-ltd.vantage.hostxtra.co.uk`. You will need to