Compare commits

...
14 Commits
14 changed files with 1893 additions and 1 deletions
+16
View File
@@ -551,6 +551,20 @@ The control plane refuses to change an `hq`-sourced user's role or delete it
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.
**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.
---
## Auth and Orgs
@@ -692,6 +706,7 @@ GET /account # account, instances, max_relinks
POST /instances # create a cloud instance (Free tier, one Free per account per deployment)
POST /instances/:id/renew # Free renewal; refuses outside the renewal window
POST /instances/:id/claim-free # issue Free on a linked self-hosted instance
PUT /instances/:id/name # rename a cloud instance; moves its slug (owner|admin, 24h cooldown)
POST /instances/link · /instances/:id/relink
GET /instances/:id/entitlement
GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV
@@ -717,6 +732,7 @@ Staff-session (`/api/staff`):
GET,POST /accounts · GET /accounts/:id # search by name, email, Paddle ID or instance UUID
GET,POST /instances · GET /instances/:id # instance + account + licence history + injection state
POST /instances/:id/issue · /instances/:id/relink
PUT /instances/:id/name # rename any instance, no cooldown
GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier
GET,PUT /catalogue
GET,PUT /instances/:id/entitlement
+181
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"errors"
"fmt"
"log"
@@ -23,6 +24,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ownedInstance resolves an instance and confirms the session's account owns it.
@@ -538,6 +540,185 @@ func claimFree(c *gin.Context) {
c.JSON(http.StatusCreated, lic)
}
// 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
}
ctx := c.Request.Context()
// The unwind and the audit write run on a context detached from the request.
// The commonest reason the admin-side write fails at all is the caller
// walking away, and an unwind sharing that context fails with it — leaving
// the control plane renamed and admin's row not, which is the exact
// divergence this handler is arranged to prevent.
//
// Only the cancellation is detached here; each deadline is derived at its use
// site below. A deadline started before the forward work is a deadline the
// unwind may never get to use — a control plane slow enough to make the admin
// write fail is exactly the one that would have spent it already.
detached := context.WithoutCancel(ctx)
// Claim the cooldown atomically BEFORE the control-plane call. Checking it
// and then acting lets two parallel PUTs both pass the check and then
// interleave their two-database writes, which ends with the two databases
// disagreeing about the host — a worse outcome than either rename losing.
// The conditional update IS the cooldown; there is no second reading of it.
now := time.Now().UTC()
var claimed models.Instance
err := db.Admin("admin_instances").FindOneAndUpdate(ctx,
bson.M{
"instance_id": inst.InstanceID,
"account_id": inst.AccountID,
"$or": []bson.M{
{"renamed_at": bson.M{"$exists": false}},
{"renamed_at": bson.M{"$lte": now.Add(-models.RenameCooldown)}},
},
},
bson.M{"$set": bson.M{"renamed_at": now}}).Decode(&claimed)
if err != nil {
if !errors.Is(err, mongo.ErrNoDocuments) {
log.Printf("renameInstance: claiming the cooldown on %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
// No match means the cooldown is live or the row has gone; only a
// re-read tells those apart, and they are different answers.
var cur models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": inst.InstanceID, "account_id": inst.AccountID}).Decode(&cur); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if cur.RenamedAt != nil {
until := cur.RenamedAt.Add(models.RenameCooldown)
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
}
// The row is here and its cooldown is spent, yet the claim matched
// nothing: it changed under us. Nothing has been written, so refuse
// rather than guess which way.
log.Printf("renameInstance: cooldown claim on %s matched nothing against an eligible row", inst.InstanceID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
// releaseClaim puts renamed_at back to whatever the claim overwrote — the
// previous instant, or absent when there was none. Every failure past the
// claim owes the customer their rename back.
releaseClaim := func(after string) {
undo := bson.M{"$unset": bson.M{"renamed_at": ""}}
if claimed.RenamedAt != nil {
undo = bson.M{"$set": bson.M{"renamed_at": *claimed.RenamedAt}}
}
rcCtx, cancel := context.WithTimeout(detached, 5*time.Second)
defer cancel()
if _, err := db.Admin("admin_instances").UpdateOne(rcCtx,
bson.M{"instance_id": inst.InstanceID}, undo); err != nil {
log.Printf("renameInstance: releasing the cooldown claim on %s after %s: %v", inst.InstanceID, after, err)
}
}
renamed, prevName, prevSlug, err := cloudprov.RenameInstance(ctx, inst.InstanceID, name)
switch {
case errors.Is(err, provision.ErrSlugTaken):
releaseClaim("a taken slug")
c.JSON(http.StatusConflict, gin.H{"error": "that name is already in use — try another"})
return
case errors.Is(err, provision.ErrNameRejected):
releaseClaim("a rejected name")
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
case err != nil:
releaseClaim("a failed control-plane rename")
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
}
// A matched count of zero is a silent version of the same failure: the
// control plane moved and admin's row did not.
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$set": bson.M{"name": renamed.Name, "slug": renamed.Slug}})
if err == nil && res.MatchedCount == 0 {
err = errors.New("admin_instances row matched nothing")
}
if err != nil {
// The control plane's own previous values, not admin's copy: admin's may
// be stale, and its slug is omitempty.
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("renameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
rbCancel()
releaseClaim("a failed record write")
log.Printf("renameInstance: record rename of %s: %v", inst.InstanceID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not rename the instance"})
return
}
if renamed.Slug == prevSlug {
// The cooldown exists because a rename moves the DNS host; a cosmetic
// edit that derives to the same slug moves nothing, so it should not
// spend one. The claim is already written by this point — releasing it
// is how that is expressed now the check is atomic.
releaseClaim("a rename that did not move the host")
}
s := auth.Current(c)
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
audit.Write(auCtx, models.AuditEntry{
Actor: s.Email, Action: "instance.renamed", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: prevSlug + " -> " + renamed.Slug, IP: c.ClientIP()})
auCancel()
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),
})
}
// deliver sends a freshly issued licence where it needs to go. Cloud instances
// are injected; self-hosted customers are emailed and can download.
//
+6
View File
@@ -75,6 +75,11 @@ func Routes(cfg config.Config) http.Handler {
cust.POST("/instances/:id/claim-free",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimFree)
// 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)
cust.GET("/instances/:id/entitlement", getEntitlement)
cust.GET("/checkout/options", checkoutOptions)
// Paid self-hosted: links (or reuses) the customer's real install UUID so
@@ -118,6 +123,7 @@ func Routes(cfg config.Config) http.Handler {
staff.GET("/subscriptions", staffListSubscriptions)
staff.POST("/instances/:id/issue", staffIssue)
staff.POST("/instances/:id/relink", staffRelink)
staff.PUT("/instances/:id/name", staffRenameInstance)
staff.GET("/licenses", staffListLicenses)
staff.GET("/plans", staffListPlans)
// Plans are keyed on the pair now, so the path is too. A single :tier
+108
View File
@@ -1,18 +1,23 @@
package api
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/cloudprov"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
sharedmodels "gitea.hostxtra.co.uk/mrhid6/vantage/shared/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/provision"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
@@ -618,3 +623,106 @@ func staffCreateAccountUser(c *gin.Context) {
Actor: s.Email, Action: "customer_user.created", AccountID: accountID, Target: email})
c.JSON(http.StatusCreated, gin.H{"pending": true})
}
// 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.
//
// A cloud placeholder is refused outright rather than relabelled: it has no
// control-plane row yet, so a label-only rename here would be a name that the
// instance never gets when provisioning finally derives its slug from the
// checkout's name. The customer endpoint refuses it for the same reason.
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
}
if inst.Deployment == license.DeploymentCloud && inst.Placeholder {
c.JSON(http.StatusConflict, gin.H{"error": "this instance is not provisioned yet"})
return
}
// The unwind and the audit write must survive the request being cancelled:
// an unwind on a dead context leaves the two databases disagreeing, which is
// the failure the unwind exists for.
//
// Only the cancellation is detached here; each deadline is derived at its use
// site below. A deadline started before the forward work is a deadline the
// unwind may never get to use — a control plane slow enough to make the admin
// write fail is exactly the one that would have spent it already.
detached := context.WithoutCancel(ctx)
set := bson.M{"name": name}
slug := inst.Slug
cloud := inst.Deployment == license.DeploymentCloud
// The control plane's own previous values, not admin's copy: admin's may be
// stale, and its slug is omitempty, so unwinding from it can write an empty
// slug into instances.
prevName, prevSlug := inst.Name, inst.Slug
if cloud {
renamed, pName, pSlug, 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
}
prevName, prevSlug = pName, pSlug
slug = renamed.Slug
set["slug"] = renamed.Slug
}
// A matched count of zero is the same failure quietly: the control plane
// moved and admin's row did not.
res, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID}, bson.M{"$set": set})
if err == nil && res.MatchedCount == 0 {
err = errors.New("admin_instances row matched nothing")
}
if err != nil {
if cloud {
rbCtx, rbCancel := context.WithTimeout(detached, 5*time.Second)
if rbErr := cloudprov.RestoreInstanceIdentity(rbCtx, inst.InstanceID, prevName, prevSlug); rbErr != nil {
log.Printf("staffRenameInstance: rollback of %s failed: %v", inst.InstanceID, rbErr)
}
rbCancel()
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
auCtx, auCancel := context.WithTimeout(detached, 5*time.Second)
audit.Write(auCtx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.renamed", AccountID: inst.AccountID,
Target: inst.InstanceID, Detail: prevSlug + " -> " + slug, IP: c.ClientIP()})
auCancel()
c.JSON(http.StatusOK, gin.H{"instance_id": inst.InstanceID, "name": name, "slug": slug})
}
+20
View File
@@ -202,3 +202,23 @@ func ProjectedUsers(ctx context.Context, hqUserID string) ([]sharedmodels.User,
}
return users, nil
}
// 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.
//
// The previous name and slug come back with the result because they are what an
// unwind must restore — admin's own copy can be stale, or slugless.
func RenameInstance(ctx context.Context, instanceID, name string) (inst *sharedmodels.Instance, prevName, prevSlug string, err 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)
}
+15 -1
View File
@@ -111,6 +111,15 @@ const GracePeriod = 3 * 24 * time.Hour
// second mechanism.
const RenewWindow = 7 * 24 * time.Hour
// 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
type Account struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
AccountID string `bson:"account_id" json:"account_id"`
@@ -137,7 +146,12 @@ type Instance struct {
Status string `bson:"status" json:"status"`
CurrentLicense string `bson:"current_license,omitempty" json:"current_license,omitempty"`
RelinkCount int `bson:"relink_count" json:"relink_count"`
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
// 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"`
InjectFailedAt *time.Time `bson:"inject_failed_at,omitempty" json:"inject_failed_at,omitempty"`
// NoticesSent holds the lifecycle notice keys already emailed for the
// CURRENT term ("expiring", "expired", "delete_7", "delete_1"). Renewal
// clears it, so the next term starts the sequence again. It is what stops a
@@ -9,6 +9,7 @@ import { NotConnectedPanel } from "@/components/NotConnected";
import { LicenceDelivery } from "@/components/LicenceDelivery";
import { MembersPanel } from "@/components/MembersPanel";
import { RelinkPanel } from "@/components/RelinkPanel";
import { RenamePanel } from "@/components/RenamePanel";
import { StatePill } from "@/components/StatePill";
import { TermBar } from "@/components/TermBar";
import { EmptyState, Note, Panel } from "@/components/Panel";
@@ -17,6 +18,7 @@ import { PageHeader } from "@/components/PageHeader";
import { LinkButton } from "@/components/Button";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
import { FEATURE_LABEL, featureDesc, featureLabel } from "@/lib/features";
import { useSession } from "@/lib/session";
/** One key/value row. The key is the same keyed idiom as everywhere else. */
function Row({ label, value }: { label: string; value: React.ReactNode }) {
@@ -65,6 +67,10 @@ export default function InstancePage() {
const qc = useQueryClient();
const [relinkError, setRelinkError] = useState<string | undefined>();
// 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();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
const licence = useQuery({
queryKey: ["license", id],
@@ -95,6 +101,7 @@ export default function InstancePage() {
const lic = licence.data;
const state = licenceState(lic?.expires_at, Boolean(lic));
const cloud = instance.deployment === "cloud";
const mayRename = session?.account_role === "owner" || session?.account_role === "admin";
const maxRelinks = account.data?.max_relinks ?? 3;
const host = cloud && instance.slug ? `${instance.slug}.vantage.hostxtra.co.uk` : null;
@@ -194,6 +201,38 @@ export default function InstancePage() {
{cloud && <MembersPanel instanceId={instance.instance_id} />}
{/*
* 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 && (
<Panel title="Address" meta={host ?? undefined}>
<p className="text-[0.86rem] text-ink-2">
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.
</p>
{/*
* Keyed on the instance: this element stays mounted
* across a navigation between two instance pages, so
* without a key the success note and the typed name
* from one instance surface on the next.
*/}
<RenamePanel
key={instance.instance_id}
movesHost
currentName={instance.name}
currentSlug={instance.slug ?? ""}
onRename={async (name) => {
const res = await api.renameInstance(instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["account"] });
return res;
}}
/>
</Panel>
)}
{/*
* "Moves" rather than "Relinks": the count is rationed, so the
* headline is how many are left, and the panel explains what
@@ -13,6 +13,7 @@ import { Panel } from "@/components/Panel";
import { licenceState } from "@/lib/format";
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
import { IssuePanel } from "./IssuePanel";
import { RenamePanel } from "@/components/RenamePanel";
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
current: { label: "Control plane holds the current licence", tone: "text-valid" },
@@ -26,6 +27,7 @@ const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
export default function StaffInstancePage() {
const id = String(useParams().id);
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["staff-instance", id],
queryFn: () => api.staff.instance(id),
@@ -36,6 +38,12 @@ export default function StaffInstancePage() {
const inj = data.injection.state ? INJECTION[data.injection.state] : undefined;
const current = data.licenses.find((l) => !l.superseded_by);
// A cloud placeholder has no control-plane row yet, so there is no host to
// move and nothing to rename — the panel's wording and its control are both
// read from this one answer rather than from the deployment alone, which is
// how they came to contradict each other.
const movesHost = data.instance.deployment === "cloud" && !data.instance.placeholder;
const cloudPlaceholder = data.instance.deployment === "cloud" && data.instance.placeholder;
return (
<div className="grid gap-8">
@@ -79,6 +87,38 @@ export default function StaffInstancePage() {
<IssuePanel instanceId={data.instance.instance_id} />
</Panel>
{/*
* 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.
*/}
<Panel title="Name" meta={movesHost ? "Moves the address" : "Label only"}>
{cloudPlaceholder ? (
// The API refuses this with a 409, so offering the control
// would only be a form that cannot succeed.
<p className="text-[0.85rem] text-ink-3">
This instance is not provisioned yet. Its name is set when the checkout provisions it, and it can be renamed after that.
</p>
) : (
/*
* Keyed on the instance so a success note cannot follow staff
* from one instance page to the next — the element stays
* mounted across that navigation.
*/
<RenamePanel
key={data.instance.instance_id}
movesHost={movesHost}
currentName={data.instance.name}
currentSlug={data.instance.slug ?? ""}
onRename={async (name) => {
const res = await api.staff.renameInstance(data.instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
return res;
}}
/>
)}
</Panel>
<EntitlementSection instanceId={data.instance.instance_id} deployment={data.instance.deployment} />
</div>
);
+130
View File
@@ -0,0 +1,130 @@
"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.
*
* movesHost is what separates a rename that moves a DNS host from one that only
* changes a label. Self-hosted instances and unprovisioned cloud placeholders
* have no address, so every word about old links breaking and signing in again
* is false for them — and a preview host they will never live at is worse than
* no preview at all.
*/
export function RenamePanel({
currentName,
currentSlug,
movesHost,
onRename,
}: {
currentName: string;
currentSlug: string;
movesHost: boolean;
onRename: (name: string) => Promise<RenameResult>;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(currentName);
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
const [done, setDone] = useState<RenameResult | undefined>();
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);
// The input is prefilled with the current name, and the current name
// is now this one. Leaving the old text in would make the next open
// look like an edit already in progress.
setValue(res.name);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Rename failed. Try again.");
} finally {
setBusy(false);
}
}
// The note sits ABOVE the control rather than replacing it. A rename is not
// a one-shot action — a customer who mistypes the new name needs the panel
// back, and returning early here left them with a success message and no way
// to correct it short of a reload.
return (
<div className="grid gap-3">
{done &&
(movesHost ? (
<Note tone="warn">
<span className="grid gap-2">
<span>
This instance is now <strong>{done.name}</strong>, at{" "}
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and
your sign-in does not follow it you will need to sign in again there.
</span>
<a
href={done.login_url || `https://${hostFor(done.slug)}`}
className="justify-self-start font-mono text-[0.78rem] text-accent underline"
>
Open {hostFor(done.slug)} &rarr;
</a>
</span>
</Note>
) : (
<Note tone="warn">
This instance is now <strong>{done.name}</strong>.
</Note>
))}
{open && (
<Field
label="Instance name"
value={value}
onChange={(e) => setValue(e.target.value)}
error={error ?? (name ? invalid : undefined)}
hint={
movesHost && name && !invalid ? (
<>
Moves to <span className="font-mono">{hostFor(derived)}</span>
{derived === currentSlug && " — the address does not change"}
</>
) : (
"Letters and digits; everything else becomes a hyphen."
)
}
/>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="line"
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
onClick={() => (open ? submit() : setOpen(true))}
>
{busy ? "Renaming…" : "Rename instance"}
</Button>
{open && movesHost && (
<span className="text-[0.82rem] text-ink-3">
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
</span>
)}
</div>
</div>
);
}
+15
View File
@@ -129,6 +129,9 @@ export interface Instance {
status: InstanceStatus;
current_license?: string;
relink_count: number;
/** Cloud only, and only until the paid checkout provisions the real row. */
placeholder?: boolean;
renamed_at?: string;
inject_failed_at?: string | null;
notices_sent?: string[];
created_at: string;
@@ -288,6 +291,14 @@ export interface StaffInstanceResponse {
injection: { applicable: boolean; state?: InjectionState; failed_at?: string | null };
}
export interface RenameResult {
instance_id: string;
name: string;
slug: string;
/** Empty when APP_LOGIN_URL is unset on the server. */
login_url?: string;
}
// --- calls ---------------------------------------------------------------
export const api = {
@@ -306,6 +317,8 @@ export const api = {
post<Instance>("/api/instances/link", { instance_id, name }),
createInstance: (name: string) => post<Instance>("/api/instances", { name }),
renewInstance: (id: string) => post<License>(`/api/instances/${id}/renew`, {}),
renameInstance: (id: string, name: string) =>
put<RenameResult>(`/api/instances/${id}/name`, { name }),
// Self-hosted Free: issue the licence on an already-linked instance.
claimFree: (id: string) => post<License>(`/api/instances/${id}/claim-free`, {}),
relink: (id: string, instance_id: string) =>
@@ -367,6 +380,8 @@ export const api = {
post<License>(`/api/staff/instances/${id}/issue`, payload),
relink: (id: string, instance_id: string) =>
post<License>(`/api/staff/instances/${id}/relink`, { instance_id }),
renameInstance: (id: string, name: string) =>
put<RenameResult>(`/api/staff/instances/${id}/name`, { name }),
licenses: (params?: Record<string, string>) =>
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
plans: () => req<Plan[]>("/api/staff/plans"),
+56
View File
@@ -0,0 +1,56 @@
/*
* 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}`;
}
@@ -0,0 +1,938 @@
# 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.
**Note:** This repo has no automated test suite and the user has ruled out adding test files. Every task verifies by build, vet and (Task 8) manual exercise.
**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`
**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`
Behaviour `RenameSlug` must have, verified by reading rather than by test (this
repo has no Go test suite and the user has ruled out adding one):
| Input name | Current slug | Result |
|---|---|---|
| `Acme Ltd` | `acme` | `acme-ltd` |
| `ACME!` | `acme` | `acme` — still derives to the current slug, so not a move |
| `Acme` | `acme-2` | `acme` — a creation-time collision suffix derives from no name, so moving off it is a real move |
| `ab` | any | `ErrNameRejected` |
| `Admin` | any | `ErrNameRejected` (reserved) |
| `!!!` | any | `ErrNameRejected` |
| 50 `a`s | any | truncated to `MaxSlugLength`, exactly as `BaseSlug` truncates on create |
- [ ] **Step 1: 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 2: Build and vet**
Run: `cd /go-projects/vantage && go build ./shared/... && go vet ./shared/provision/`
Expected: clean.
- [ ] **Step 3: Commit**
```bash
git add shared/provision/instance.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<RenameResult>`, `api.staff.renameInstance(id, name): Promise<RenameResult>`
- `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<RenameResult>(`/api/instances/${id}/name`, { name }),
```
And to `api.staff`, after `relink`:
```ts
renameInstance: (id: string, name: string) =>
put<RenameResult>(`/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<RenameResult>`.
- [ ] **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<RenameResult>;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(currentName);
const [error, setError] = useState<string | undefined>();
const [busy, setBusy] = useState(false);
const [done, setDone] = useState<RenameResult | undefined>();
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 (
<Note tone="warn">
<span className="grid gap-2">
<span>
This instance is now <strong>{done.name}</strong>, at{" "}
<span className="font-mono">{hostFor(done.slug)}</span>. The old address has stopped working, and your
sign-in does not follow it you will need to sign in again there.
</span>
<a href={host} className="justify-self-start font-mono text-[0.78rem] text-accent underline">
Open {hostFor(done.slug)} &rarr;
</a>
</span>
</Note>
);
}
return (
<div className="grid gap-3">
{open && (
<Field
label="Instance name"
value={value}
onChange={(e) => setValue(e.target.value)}
error={error ?? (name ? invalid : undefined)}
hint={
name && !invalid ? (
<>
Moves to <span className="font-mono">{hostFor(derived)}</span>
{derived === currentSlug && " — the address does not change"}
</>
) : (
"Letters and digits; everything else becomes a hyphen."
)
}
/>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="line"
disabled={busy || (open && (!name || Boolean(invalid) || unchanged))}
onClick={() => (open ? submit() : setOpen(true))}
>
{busy ? "Renaming…" : "Rename instance"}
</Button>
{open && (
<span className="text-[0.82rem] text-ink-3">
Anyone signed in will need to sign in again at the new address, and links to the old one stop working.
</span>
)}
</div>
</div>
);
}
```
`Note` is `({ tone = "accent" | "warn" | "expired", children })` and renders a `<p>`, which is why the success state wraps its two lines in a `<span className="grid gap-2">` 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 && (
<Panel title="Address" meta={host ?? undefined}>
<p className="text-[0.86rem] text-ink-2">
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.
</p>
<RenamePanel
currentName={instance.name}
currentSlug={instance.slug ?? ""}
onRename={async (name) => {
const res = await api.renameInstance(instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["account"] });
return res;
}}
/>
</Panel>
)}
```
- [ ] **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.
*/}
<Panel title="Name" meta={data.instance.deployment === "cloud" ? "Moves the address" : "Label only"}>
<RenamePanel
currentName={data.instance.name}
currentSlug={data.instance.slug ?? ""}
onRename={async (name) => {
const res = await api.staff.renameInstance(data.instance.instance_id, name);
qc.invalidateQueries({ queryKey: ["staff-instance", id] });
return res;
}}
/>
</Panel>
```
- [ ] **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**
Run:
```bash
cd /go-projects/vantage && go build ./... && go vet ./admin/... ./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/<id>` 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 .
```
@@ -0,0 +1,236 @@
# Instance rename in Vantage HQ
**Date:** 2026-08-12
**Status:** approved, not yet implemented
## Problem
A cloud instance is named once, at creation, and never again. The name is
chosen in the first thirty seconds of a customer's relationship with the
product — before they have decided whether this is "Acme" or "Acme
Production" — and it is the name that becomes their DNS host, appears in every
sign-in link and heads every page of their control plane. Today the only way to
change it is to create a second instance and move, or to open a support ticket
that has no tooling behind it.
## What a rename is
One customer-initiated action on a **cloud** instance: a new name, from which a
new slug is derived, which moves the instance to a new DNS host.
Name and slug move together. The slug is re-derived through
`provision.BaseSlug`, so the rules that named the instance at creation are the
rules that rename it — the same reserved-label list, the same 340 character
bound, the same `Slugify` collapse of non-alphanumeric runs. There is no
separate slug field for the customer to edit, because two fields invite the
state where the name says one thing and the host says another, and that
divergence is exactly what a rename exists to fix.
A licence binds an instance **UUID**, not a slug. A rename therefore issues no
licence, calls Paddle not at all, and consumes no relink. This is the property
that makes the whole feature cheap, and it should be stated in any future change
that tempts someone to touch the licence from this path.
### What breaks, deliberately
- **The old host stops working.** The old slug is released the moment the rename
commits; another account may take it. Bookmarks, saved sign-in links and any
agent install one-liner that named the web host are stale. Agents themselves
are unaffected — they dial `GRPC_HOST`, which is not per-tenant.
- **The old host keeps working for up to 60 seconds.** `server/internal/auth/instancehost.go`
caches slug-to-instance lookups for 60s, and admin has no path to invalidate
another process's memory. The released slug can be claimed by another account
inside that window, so for up to a minute a replica still maps that host to the
previous tenant. No data is exposed — the host/session guard rejects a session
belonging to a different instance — but the new owner's users can briefly reach
the old tenant's instance on their own host, and see its login page rather than
theirs. Adding a cross-service invalidation channel for a 60-second window is
not worth the coupling.
- **The customer must sign in again.** `km_session` is set with no `Domain`
attribute, so it is host-only and does not follow the instance to its new
subdomain. The UI says so rather than letting the customer discover it.
## Scope
| | Customer (owner or admin) | Staff |
|---|---|---|
| Cloud instance | rename, 24h cooldown | rename, no cooldown |
| Self-hosted instance | refused, 400 | name only; there is no slug |
| Cloud placeholder | refused, 409 | refused, 409 |
Self-hosted is refused on the customer side for the same reason the member
endpoints refuse it: there is no control-plane row to write. The install is the
customer's, on their own database, and admin cannot reach it. Staff may still
correct the label on admin's own row, because that label is what staff search
by.
## Data flow
Two writes, in this order:
1. **Control plane `instances`**`{name, slug}`.
2. **Admin `admin_instances`**`{name, slug, renamed_at}`.
The control plane goes first because `instances.slug` carries the unique index,
and that index is what actually decides a race between two accounts reaching for
the same name. Deciding it anywhere else would be guessing.
If the second write fails, the first is rolled back best-effort — restoring the
previous name and slug — and the request answers 500. Leaving them divergent
would have HQ print a host that is not the host, which is worse than a failed
rename.
## Backend
### `shared/provision/instance.go`
```go
// ErrSlugTaken means the derived slug belongs to another instance.
var ErrSlugTaken = errors.New("slug taken")
// RenameInstance changes an instance's name and re-derives its slug.
func RenameInstance(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error)
```
It lives beside `CreateInstanceWithID` so slug derivation keeps one home, and it
behaves as that function's rules imply:
- `BaseSlug(name)` failures wrap `ErrNameRejected` — too short, too long,
reserved.
- The derived slug is compared against the instance's current one. If they are
equal, only the name is written; a cosmetic capitalisation change is not a
move, and must not fail on its own slug.
- **No `-2` suffix loop.** Creation appends a counter because the customer is
waiting on an instance and any free slug will do. A rename is a request for a
specific host, and silently landing the customer on `acme-2` is a worse answer
than refusing.
- A duplicate-key error on the update surfaces as `ErrSlugTaken`, exactly as the
create path treats it as "that slug is taken". The pre-check is a courtesy;
the index is the boundary.
### `admin/internal/cloudprov`
```go
func RenameInstance(ctx context.Context, instanceID, name string) (*sharedmodels.Instance, error)
```
A thin wrapper over `provision.RenameInstance` on `db.ControlDB()`. It writes
`instances` and nothing else, so admin's documented control-plane write boundary
`instances` and `users`, from `cloudprov` and `inject` only — is unchanged.
### `admin/internal/models`
`Instance` gains:
```go
// RenamedAt is when this instance last changed name, and backs the 24h
// customer cooldown. The cooldown is admin's policy, so it lives on admin's
// row rather than in the control plane, which has no opinion about how often
// a customer may move.
RenamedAt *time.Time `bson:"renamed_at,omitempty" json:"renamed_at,omitempty"`
```
A pointer because absent means "never renamed", and a zero `time.Time` would
read as 1 January year 1 — far enough in the past that the cooldown is inert,
but only by accident.
### `PUT /api/instances/:id/name` (customer)
Mounted in the `cust` group behind `auth.RequireAccountRole(owner, admin)`, and
resolving the instance through `ownedInstance` like every other instance route,
so another account's instance answers 404 rather than 403.
Body: `{"name": "..."}`, trimmed before use.
Refusals, in the order checked:
| Condition | Status | Body |
|---|---|---|
| `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 |
| `provision.ErrSlugTaken` | 409 | that name is already in use |
Success returns `{"instance_id", "name", "slug", "login_url"}` and writes an
audit entry `instance.renamed` with detail `<old-slug> -> <new-slug>`, so the
history of a host is answerable from the audit log alone.
`login_url` comes from the existing `loginURLFor(slug)`, which fills `{slug}`
into `APP_LOGIN_URL` — the same builder the licence emails already use, rather
than a second opinion about how a tenant host is spelled. It is empty when
`APP_LOGIN_URL` is unset, and the portal then falls back to the host string it
already composes from the slug in `InstanceRecord` and the instance page.
### `PUT /api/staff/instances/:id/name`
The same core, without the cooldown, actor recorded as the staff user. On a
self-hosted instance it updates `admin_instances.name` only and does not call
`cloudprov`.
## Frontend (`adminsite`)
### `lib/slug.ts`
A TypeScript mirror of `provision.Slugify` and the length/reserved checks, used
only to preview the resulting host while the customer types. It carries the same
warning as `web/lib/targets.ts`: it is a second implementation and must change in
the same commit as the Go one. The preview can disagree with the server — the
409 is the answer that counts.
### `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.
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"]`, 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
> sign in again there.
**No automatic redirect.** Sending the browser to the new host lands the customer
on a login screen with no explanation, having just lost the HQ page they were
standing on. The link is right there; they click it when they are ready.
## Verification
The repository has no Go test suite, so verification is build plus manual
exercise, matching existing practice:
- `go build ./...` in `shared` and `admin`; `npm run build` in `adminsite`.
- Rename a cloud instance; confirm `instances` and `admin_instances` agree on
name and slug.
- The new host serves a login page; the old host stops resolving to the instance
within ~60 seconds.
- A second rename within 24 hours answers 429.
- A rename onto an occupied slug answers 409 and changes nothing.
- A rename attempt on a self-hosted instance from the customer portal answers
400, the same status and constant the member endpoints already answer with.
- The audit log carries `instance.renamed` with both slugs.
## Out of scope
- Slug aliases or redirects from the old host. The control plane resolves one
slug per instance, and an alias table is a second identity to keep correct for
the sake of stale bookmarks.
- Renaming from inside the control plane's own `/settings`. HQ owns instance
identity, the same way it owns licences and `hq`-sourced users; a second
writer would need the same collision handling and the same cooldown.
- Any change to the licence, subscription or Paddle line items.
+93
View File
@@ -79,6 +79,99 @@ func CreateInstanceWithID(ctx context.Context, db *mongo.Database, instanceID, n
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
}
// 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.
//
// It returns the name and slug the control plane held BEFORE the write, and
// those are the only correct values to unwind with. The caller's own copy of the
// instance may be stale, and admin's copy stores slug with `omitempty`, so an
// unwind driven from there can write an empty slug — which either mis-restores
// the tenant host or trips the unique index against every other slugless row.
//
// 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) (inst *models.Instance, prevName, prevSlug string, err error) {
var cur models.Instance
if err := db.Collection("instances").FindOne(ctx,
bson.M{"instance_id": instanceID}).Decode(&cur); err != nil {
return nil, "", "", err
}
prevName, prevSlug = cur.Name, cur.Slug
slug, err := RenameSlug(name, cur.Slug)
if err != nil {
return nil, prevName, prevSlug, err
}
if slug != cur.Slug {
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{
"slug": slug,
"instance_id": bson.M{"$ne": instanceID},
})
if err != nil {
return nil, prevName, prevSlug, err
}
if n > 0 {
return nil, prevName, prevSlug, 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, prevName, prevSlug, fmt.Errorf("%w: %s", ErrSlugTaken, slug)
}
return nil, prevName, prevSlug, err
}
cur.Name = name
cur.Slug = slug
return &cur, prevName, prevSlug, 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
}
// RollbackInstance deletes an instance that has no users.
//
// It refuses an instance that has users. Rollback exists to clean up a