// The app Settings tab's write half: rename, delete, and the account-move // repair — the app-level siblings of ownership.go's account actions, holding // the same gate order (session → CSRF → owner 404 → cheap refusals → confirm // → requireFreshAuth LAST) and the same posture: the console mutates its own // database and the bucket's markers, never the registry. // // What is deliberately NOT here, and why: // // - Renaming a LIVE app. The name is a literal key segment in every store // this platform has — bucket prefixes, signed pointer payloads, registry // rows, platform hostnames, even a host-role app's Unix user — so a live // rename is a coordinated migration, not a settings write. The form // refuses with that reason once anything outside this database knows the // name. // - Attach/detach ITSELF. Domains attach and detach through their own door // now (appdomains.go, #82 Task 4) — the claim/record writes, their gate // order and the audit trail all live there. What's here is read-only: // settingsDomains below folds an app's still-routeless claims (every // alias claim, and a first-route claim before the reconciler has // materialized its route — neither ever gets a registry row of its // own) into the Domains & TLS table this page already draws, so an // attach shows up the moment it lands, not only once a route exists to // join it to. // - The full account move. `carlos accounts migrate` moves bucket objects // and re-stamps routes on the box; what the console owns is its own // record, so what Settings offers is the repair half — making this // database agree with what the routes already say. package console import ( "context" "crypto/subtle" "errors" "log" "net/http" "net/url" "os" "sort" "strings" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/gate" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // appSettingsSections is the closed set accepted after /settings/. Keeping // this server-side means a typo gets the same 404 as any other unknown app // page, rather than rendering an empty settings shell. var appSettingsSections = map[string]bool{ "general": true, "domains": true, "pipeline": true, "environments": true, "storage": true, "email": true, "features": true, "advanced": true, "delete": true, } func appSettingsURL(acct, app, section string) string { base := "/a/" + url.PathEscape(acct) + "/apps/" + url.PathEscape(app) + "/settings" if section == "" || section == "general" { return base } return base + "/" + section } // SettingsView is what the Settings tab knows beyond the shared AppView. type SettingsView struct { // NoBucket distinguishes a console deliberately running without a // deployment store from a transient read failure. NoBucket bool // CanRename: nothing outside the console database knows this name yet. // RenameBlocked carries the reason when it is false. CanRename bool RenameBlocked string // ControlPlane: this app IS the platform, so its destructive verbs are // refused until the flag is cleared (controlplane.go). Read straight // from the row rather than derived, and false when the row cannot be // read — the PAGE showing no card is the right failure, since the // handler re-decides everything anyway and the refusals do not depend // on this being rendered. ControlPlane bool // The account-move repair: MismatchTarget is the account the app's // routes say it belongs to ("" when console and routes agree), // TargetKnown whether this console has that account at all, and // ViewerInTarget whether the signed-in owner is a member of it — the // difference between offering the button and explaining the operator // path. MismatchTarget string TargetKnown bool ViewerInTarget bool // Store is the app's object store as this page sees it — nil when the // record could not be read (the page says nothing rather than guessing), // and a zero card when there is genuinely no store, which is what the // "ask for one" line renders from. Store *StoreCard // StoreUnavailable distinguishes a failed read from a genuinely absent // record, so the section remains present and does not claim there is no // store during a transient failure. StoreUnavailable bool // Envs is one card per named config environment — the bundles themselves, // which until now had no console surface at all: the ladder shows which // environment a route is wired to, and nothing showed what the // environments ARE. Read-only, and variable NAMES only: this page has // never shown a config value and does not start here. Envs []*EnvCard // EnvsUnavailable distinguishes a failed listing from an app that // genuinely has none, so the section does not claim "no environments" // during a transient bucket failure — StoreUnavailable's rule. EnvsUnavailable bool // StoreEnvs is one card per ENVIRONMENT store (2026-09-19), oldest name // first, empty for the apps that have none — which is all of them until // someone asks for one. Each carries its own bucket, its own credential // and its own destroy form: this is the only screen where a store that // no instance reads is visible, and teardown is explicit by ruling, so // an unnoticed one keeps billing. StoreEnvs []*StoreCard // Mail is the app's tenant email as this page sees it (issue #240) — // object storage's sibling primitive, same declare -> provision -> // deliver -> rotate shape, and the same error discipline as Store: nil // on a read failure, a zero card when there is genuinely no record. Mail *MailCard MailUnavailable bool // Features is the app's feature flags as the platform serves them // (#347, apifeatures.go) — read-only here; the write is the audited, // admin-gated CLI door. Same error discipline as Store: nil on a read // failure (the page says nothing rather than "none set" about a record // it could not parse), a zero card when there genuinely are none. Features *FeaturesCard FeaturesUnavailable bool // Domains is this page's OWN Domains & TLS rows (settingsDomains): a // copy of AppView.Domains with a placeholder row appended for any of // this app's claims that have no registry row yet. Deliberately not // written back onto AppView.Domains itself — that slice stays // index-parallel with AppView.Routes for assembleOverview's account // filter (view.go), and a claim-only row has no route to pair with. Domains []DomainView // OnlyPlatformDomain is true when Domains holds rows but none of them // is a custom one — the state the attach form's empty-state line // ("this app already answers at its platform domain…") is written for. OnlyPlatformDomain bool // NeedsChannel is whether the attach form must show its channel field: // true exactly when readyRouteHost (domainattach.go) would find no // ready, non-Platform route to alias, which is the SAME condition the // core uses to pick the first-route branch — attachChannel then only // has a self channel or the app's sole shipped channel to fall back on, // and refuses with "say which one" the moment there is more than one. // This is deliberately NOT len(Detail.Routes) == 0: an app whose only // route is its own platform mint has a non-empty Routes slice, but // readyRouteHost skips Platform rows, so it still takes the // first-route branch and still needs somewhere to type a channel. NeedsChannel bool // Gates is the password-gate section (spec §5.5): one row per instance // record, with whether a password is set. GatesUnavailable carries the // reason when the records could not be read (no bucket, a store error) // and the section says so instead of rendering an empty list as "no // instances". Gates []GateRow GatesUnavailable string } // GateRow is one instance on the Settings page's password-gate section. type GateRow struct { Host string Gated bool } // StoreCard is the Settings tab's view of apps///store.json (issue // #55): the three blocks of the record folded into what a person with no box // access can read and act on. // // KeyAge is the age of Provisioned.At and is labelled as the KEY's age // everywhere it appears, never as when the store was provisioned: that // timestamp moves on every rotation AND on every grant repair (objstore walk.go // step 6), so "provisioned 2 minutes ago" would be a sentence the page tells // wrongly forever after the first repair. // // CanDestroy is the affordance, not the authority — the handler re-decides all // of it (a page can be hours old, and StoreOperators/ownership are checked // there). It is true when there is an estate AND a backend to tear it down // with, or when the record is a declaration nobody has acted on, which needs no // backend at all because nothing outside the bucket exists yet. type StoreCard struct { // Environment is the config environment this store belongs to, "" for // the app's own. The destroy form posts it back, so the door tears down // the store the card describes and no other. Environment string Declared bool EnvPrefix string Granted bool Backend string Bucket string Provisioned bool KeyID string KeyAge string PendingFinish bool CanDestroy bool } // EnvCard is one named config environment as the Settings page sees it. // // Variable NAMES only, never values. This page has never shown a config // value — `carlos env list` is where values live, behind a bearer token — // and an environment bundle is exactly where a per-tenant origin or a // delivered credential name sits, so starting here would be the wrong place // to start. type EnvCard struct { Name string // Vars are the names this bundle SETS, sorted. A named bundle overlays // the app's default per key, so this is what the environment overrides // or adds — not the whole set an instance bound to it ends up reading. Vars []EnvVarName // Hosts are the instances wired to this bundle. Empty is the case worth // looking at: a bundle nothing reads, which is where an abandoned store // hides. Hosts []string // HasStore and StoreBucket: whether this environment has an object store // of its own, and what it is called. An environment store bills whether // or not any instance reads the bundle, and teardown is explicit, so the // pairing of "no hosts" with "has a store" is the thing this section // exists to make visible. HasStore bool StoreBucket string } // EnvVarName is a variable's name and whether it is sealed — enough to see // the shape of a bundle without seeing into it. type EnvVarName struct { Name string Secret bool } // MailCard is the Settings tab's view of the app sender, apps/// // email/senders/app.json with an email.json read fallback (issue #240, // mail.Record): tenant email's Settings card, mirroring StoreCard verb // for verb — same declare -> provision -> deliver -> rotate shape, same // read-only presentation, same error discipline (mailCard's doc comment). // // Available is NOT about this app's record at all — it answers whether this // DEPLOYMENT has email configured (s.cfg.MailProvisioner != nil), the same // class of question StoreCard.CanDestroy asks of s.cfg.StoreProvisioner. // Every email API door 404s when it is false (mailProvisionerGate, // apimail.go), so it decides which empty-state sentence the zero card gets: // "ask for one" only when the command being suggested could actually run. // // KeyAge carries StoreCard.KeyAge's exact rule: it is Provisioned.At's age, // labelled as the KEY's age everywhere, never as "provisioned" — a rotation // moves that timestamp forward without changing when email was first turned // on for this app. // // Domains is deliberately Grant.Domains, not a second read of // mail.ListDomains + VerifiedARNs. The email API's own enable door // (apimail.go's handleAPIEmailEnable) computes exactly this app's granted // domain set with mail.ApplyGrant BEFORE mail.Enable runs and stores it as // Grant.Domains — so Grant.Domains already IS "the verified domains granted // to this app", sitting on the record this function is reading anyway. // Re-deriving it from ListDomains would cost a second bucket listing and a // scan for Apps-contains-this-app to show a set no more true than the one // already on the record. type MailCard struct { Available bool Declared bool EnvPrefix string From string Granted bool Domains []string DailyCap int Provisioned bool KeyID string KeyAge string PendingFinish bool Paused bool PauseKind string Standalone []MailStandaloneView } // MailStandaloneView is one displayed-credential row (mail.Standalone). // Never carries a password — there is none on the record, and the card must // not invent a place to show one. type MailStandaloneView struct { Name string KeyID string Age string } // assembleSettings fills the Settings tab's view. Best-effort by design: a // bucket hiccup here blocks the rename form (fail closed — an unverifiable // emptiness claim must not enable an identity change) and never the page. func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *SettingsView { sv := &SettingsView{} // Domains, its derived OnlyPlatformDomain, and NeedsChannel are all // read-only against the registry/claims and owe nothing to the // deployment-bucket check below — they must be set even on a console // with no bucket configured (s.cfg.Releases == nil), which used to // return before reaching them and show "No domains yet" under a live // route. if cp, _, err := s.store.AppFlags(acct.ID, p.AppName); err != nil { log.Printf("console: settings control-plane flag %s/%s: %v", acct.Sqid, p.AppName, err) } else { sv.ControlPlane = cp } sv.Domains = s.settingsDomains(ctx, acct, p.AppName, p.Detail.Domains) sv.OnlyPlatformDomain = len(sv.Domains) > 0 for _, d := range sv.Domains { // Prefix, not equality: domainView appends " · canary/" AFTER // the "platform"/"custom" word (view.go), so a platform canary // follower's Type is "platform · canary/rc" — equality against the // bare word would wrongly count it as a custom domain here. if !strings.HasPrefix(d.Type, "platform") { sv.OnlyPlatformDomain = false break } } // The same question attachDomain's first line of policy answers // (domainattach.go's readyRouteHost): is there a ready, non-Platform // route this app could alias? "" means no — the attach form's channel // field must show, because the core is about to take the first-route // branch and attachChannel only has a self channel or the app's sole // shipped channel to infer from. The attaching host is unknown here // (nothing has been typed yet), so "" is passed — it can never equal a // real route host, so every ready non-Platform route is a candidate. if route, _, err := s.readyRouteHost(acct.Sqid, p.AppName, ""); err != nil { log.Printf("console: settings needs-channel %s/%s: %v", acct.Sqid, p.AppName, err) sv.NeedsChannel = true // fail toward showing the field the core might need } else { sv.NeedsChannel = route == "" } sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName) if s.cfg.Releases == nil { sv.NoBucket = true sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run." return sv } switch empty, err := s.appDeployEmpty(ctx, acct.Sqid, p.AppName); { case err != nil: sv.RenameBlocked = "Could not check the deployment bucket, so renaming is off until it can be." case len(p.Detail.Routes) > 0: sv.RenameBlocked = "This app is wired to a host — its name is its address, in routes and bucket keys alike." case !empty: sv.RenameBlocked = "This app has shipped — its name is baked into its releases' bucket keys." default: sv.CanRename = true } sv.Envs, sv.EnvsUnavailable = s.envCards(ctx, acct, p.AppName, p.Detail.Routes) sv.Store = s.storeCard(ctx, acct, p.AppName) sv.StoreEnvs = s.storeEnvCards(ctx, acct, p.AppName) sv.Mail = s.mailCard(ctx, acct, p.AppName) sv.Features = s.featuresCard(ctx, acct, p.AppName) sv.StoreUnavailable = sv.Store == nil sv.MailUnavailable = sv.Mail == nil sv.FeaturesUnavailable = sv.Features == nil if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched { sv.MismatchTarget = ra if target, err := s.store.AccountBySqid(ra); err == nil && target != nil { sv.TargetKnown = true if role, err := s.store.Role(target.ID, p.Address); err == nil && role != "" { sv.ViewerInTarget = true } } } return sv } // settingsGates lists the app's instance records for the password-gate // section. The hash never reaches the template: GateRow carries a bool. func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ([]GateRow, string) { if s.cfg.Releases == nil { return nil, "No deployment bucket on this host, so instance records cannot be read." } recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax) if err != nil { log.Printf("console: settings gates %s/%s: %v", acct.Sqid, app, err) return nil, "Instance records could not be read just now." } rows := make([]GateRow, 0, len(recs)) for _, rec := range recs { rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""}) } sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) return rows, "" } // FeaturesCard is the Settings page's read of the app's feature flags. type FeaturesCard struct { Flags []FeatureFlag // key-sorted SetBy string SetAt string // "" when no record } type FeatureFlag struct{ Key, Value string } // featuresCard folds the features record into its Settings card, storeCard's // discipline: nil on a read failure, a zero card when there is no record. func (s *Server) featuresCard(ctx context.Context, acct *Account, app string) *FeaturesCard { rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: settings features read %s/%s: %v", acct.Sqid, app, err) return nil } card := &FeaturesCard{} if rec == nil { return card } for k, v := range rec.Features { card.Flags = append(card.Flags, FeatureFlag{k, v}) } sort.Slice(card.Flags, func(i, j int) bool { return card.Flags[i].Key < card.Flags[j].Key }) card.SetBy = rec.SetBy if !rec.SetAt.IsZero() { card.SetAt = rec.SetAt.UTC().Format("2006-01-02 15:04 UTC") } return card } // storeCard folds the store record into the Settings card. Best-effort like // everything else this page assembles: a read failure returns nil and the // section is simply absent, because "could not read the record" and "there is // no store" must not look the same on a page whose destroy button acts on the // difference. An absent record is a zero card — the state that renders the one // line telling a member how to ask for a store. func (s *Server) storeCard(ctx context.Context, acct *Account, app string) *StoreCard { rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: settings store read %s/%s: %v", acct.Sqid, app, err) return nil } if rec == nil { return &StoreCard{} } return s.storeCardFor(rec, "") } // envCards is one card per named config environment, sorted by name, with // the second return saying the listing itself failed — the difference // between "this app has no environments" and "I could not find out", which // the section must not collapse. // // A single unreadable bundle is skipped and logged rather than blanking the // list: the other environments are real, their instances are really bound to // them, and their stores are really billing. Same trade storeEnvCards makes, // for the same reason. func (s *Server) envCards(ctx context.Context, acct *Account, app string, routes []RouteView) ([]*EnvCard, bool) { names, err := appconfig.ListEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: settings environments %s/%s: %v", acct.Sqid, app, err) return nil, true } cards := make([]*EnvCard, 0, len(names)) for _, name := range names { m, merr := appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, name) if merr != nil { log.Printf("console: settings environment %s/%s/%s: %v", acct.Sqid, app, name, merr) continue } card := &EnvCard{Name: name} varNames := make([]string, 0, len(m.Vars)) for k := range m.Vars { varNames = append(varNames, k) } sort.Strings(varNames) for _, k := range varNames { card.Vars = append(card.Vars, EnvVarName{Name: k, Secret: m.Vars[k].Kind == "secret"}) } for _, rt := range routes { if rt.Environment == name { card.Hosts = append(card.Hosts, rt.Host) } } // The store is read through the same door the storage section uses, // so the two sections cannot disagree about whether one exists. if rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, name); rerr != nil { log.Printf("console: settings environment store %s/%s/%s: %v", acct.Sqid, app, name, rerr) } else if rec != nil { card.HasStore = true switch { case rec.Provisioned != nil && rec.Provisioned.Bucket != "": card.StoreBucket = rec.Provisioned.Bucket case rec.Grant != nil: card.StoreBucket = rec.Grant.Bucket } } cards = append(cards, card) } return cards, false } // storeEnvCards is one card per environment store, in the order // StoreEnvironments returns them (sorted). nil on a read failure, matching // storeCard's own discipline: the page says nothing rather than claiming an // app has no environment stores when it could not find out. // // A single unreadable record does NOT blank the whole list — the others are // real and their buckets are real — so it is skipped, logged, and the rest // render. That is the opposite trade to storeCard's, and deliberately: // there, a nil is the difference between "no store" and "unknown"; here, // dropping every card because one is malformed would hide estate that is // perfectly legible. func (s *Server) storeEnvCards(ctx context.Context, acct *Account, app string) []*StoreCard { envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: settings store environments %s/%s: %v", acct.Sqid, app, err) return nil } cards := make([]*StoreCard, 0, len(envs)) for _, env := range envs { rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) if rerr != nil { log.Printf("console: settings store read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), rerr) continue } if rec == nil { continue } cards = append(cards, s.storeCardFor(rec, env)) } return cards } // storeCardFor maps one record to the card the settings page renders. Shared // by both callers so the app's own store and an environment's can never // disagree about what a grant, a provisioning or a half-finished rotation // looks like. func (s *Server) storeCardFor(rec *objstore.Record, env string) *StoreCard { card := &StoreCard{Environment: env} if rec.Declared != nil { card.Declared = true card.EnvPrefix = rec.Declared.EnvPrefix } if g := rec.Grant; g != nil { card.Granted = true card.Backend = g.Backend card.Bucket = g.Bucket } if pr := rec.Provisioned; pr != nil { card.Provisioned = true if pr.Bucket != "" { // The provisioned block wins: the bucket an app's data is in is // whatever was recorded at provision time, which is also the word // the destroy door will make an owner type. card.Bucket = pr.Bucket } card.KeyID = pr.KeyID if !pr.At.IsZero() { card.KeyAge = humanAge(time.Since(pr.At)) + " ago" } card.PendingFinish = pr.PrevKeyID != "" } if card.Granted || card.Provisioned { card.CanDestroy = s.cfg.StoreProvisioner != nil } else { card.CanDestroy = true // a declaration: nothing outside the bucket to tear down } return card } // mailCard folds the tenant-email record into the Settings card. Wears // storeCard's exact error discipline: a read failure returns nil — the page // says nothing about email at all rather than guessing — and an absent // record is a zero card, the shape the empty-state line renders from. // // Available is set FIRST, unconditionally, because it must be correct // whether or not there turns out to be a record: it is what the template // uses to choose between the two empty-state sentences the brief calls for // (one names a command, the other says this deployment has no email at // all), and a bucket read failure must not be allowed to hide which one // applies — hence it is read straight off s.cfg.MailProvisioner before the // record read even starts, and is the one field a nil return also throws // away, same as every other field storeCard discards on its own read error. func (s *Server) mailCard(ctx context.Context, acct *Account, app string) *MailCard { available := s.cfg.MailProvisioner != nil rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: settings mail read %s/%s: %v", acct.Sqid, app, err) return nil } if rec == nil { return &MailCard{Available: available} } card := &MailCard{Available: available} if d := rec.Declared; d != nil { card.Declared = true card.EnvPrefix = d.EnvPrefix card.From = d.From } if g := rec.Grant; g != nil { card.Granted = true card.Domains = g.Domains card.DailyCap = g.DailyCap } if pr := rec.Provisioned; pr != nil { card.Provisioned = true card.KeyID = pr.KeyID if !pr.At.IsZero() { card.KeyAge = humanAge(time.Since(pr.At)) + " ago" } card.PendingFinish = pr.PrevKeyID != "" // Active is the SENDING state, not the record's completeness — a // provisioned-but-paused app has a credential, just one that must // send nothing right now, which is exactly the sentence the page // has to make impossible to miss. card.Paused = !pr.Active card.PauseKind = pr.PauseKind } for _, sa := range rec.Standalone { row := MailStandaloneView{Name: sa.Name, KeyID: sa.KeyID} if !sa.At.IsZero() { row.Age = humanAge(time.Since(sa.At)) + " ago" } card.Standalone = append(card.Standalone, row) } return card } // settingsDomains is the Settings page's own Domains & TLS rows: routed // (the AppView's own DomainView slice) plus a placeholder row for any of // this app's domain claims that routed has no entry for yet — and, for every // claim-backed row, the derived lifecycle state. // // The state is the point. Until it was threaded here the Settings page was // the last first-party surface still telling the pre-#299 story: it read // THIS box's own registry copy of a route and printed a DNS word and a TLS // word, while `carlos domains list` — reading the fleet-wide sweep — could // be saying something else about the same hostname at the same moment. Both // now come from domainState(), over the same inputs. // // Best-effort like the rest of this file. A claim-list read failure is // logged and the page falls back to routed alone; a readings or warm-record // failure leaves the state empty and the row renders exactly as it did // before. Neither is ever a hard error — a settings page that 500s because a // telemetry prefix was briefly unreadable would be a worse answer than one // that shows a little less. func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, routed []DomainView) []DomainView { out := append([]DomainView(nil), routed...) if s.cfg.Releases == nil { return out } have := make(map[string]int, len(out)) for i, d := range out { have[strings.ToLower(d.Domain)] = i } claims, err := s.claims.List(ctx, s.cfg.Releases) if err != nil { log.Printf("console: settings claims %s/%s: %v", acct.Sqid, app, err) return out } // Folded ONCE for the page, not once per row: readings is every live // box's alias stamps, warm is one object per hostname currently failing // to get a certificate (usually none at all). now := time.Now() readings := aliasReadings(ctx, s.cfg.Releases, now) warm := warmRecords(ctx, s.cfg.Releases) state := func(c domains.Claim) (st, reason, detail string) { reading, seen := readings[c.Host] return claimState(c, reading, seen, warm[c.Host], now) } claimed := make(map[string]bool, len(claims)) for _, c := range claims { claimed[c.Host] = true } for _, c := range claims { if c.Account != acct.Sqid || c.App != app { continue } if c.Instances { out = append(out, s.catchallDomainViews(ctx, acct, app, c, claimed, readings, warm, now)...) continue } if i, ok := have[strings.ToLower(c.Host)]; ok { out[i].Claimed = true out[i].State, out[i].Reason, out[i].Detail = state(c) if c.Pending { out[i].Guidance = verifyGuidance(s.domainChallenge(c.Host, c.Account)) } continue } dv := claimDomainView(c.Host, s.dnsTargets()) dv.State, dv.Reason, dv.Detail = state(c) if c.Pending { // The proof comes first: until it is published nothing else on // the row can take effect (#215). dv.Guidance = verifyGuidance(s.domainChallenge(c.Host, c.Account)) + " " + dv.Guidance } out = append(out, dv) } return out } // appDeployEmpty reports whether the bucket holds ANYTHING under the app's // prefix — account-qualified or legacy-unqualified. One key is enough to // refuse a rename; the caller never needs the listing itself. func (s *Server) appDeployEmpty(ctx context.Context, acct, app string) (bool, error) { for _, prefix := range []string{release.AppPrefix(acct, app), release.LegacyAppPrefix(app)} { keys, err := s.cfg.Releases.List(ctx, prefix) if err != nil { return false, err } if len(keys) > 0 { return false, nil } } return true, nil } // appHasRoutes reports whether ANY registry route wears the name — any // account's. Deliberately wider than the tenant boundary: a rename must not // free a name some other tenant's route still resolves by. func (s *Server) appHasRoutes(app string) (bool, error) { reg, err := openRegistry(s.cfg.Registry) if err != nil { return false, err } routes, err := reg.List() if err != nil { return false, err } for _, rt := range routes { if rt.App == app { return true, nil } } return false, nil } // handleAppRename renames a never-deployed app. Owner's, behind the confirm // card, without the passkey: nothing outside this console's database changes, // and the refusals above guarantee exactly that. func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther) } // The control plane keeps its name (controlplane.go): the fleet's release // keys, its channels and the units that exec them are all written in it. // Refused here, straight after the owner gate and before the confirm card // is ever rendered — a card that cannot be confirmed is worse than no card. if msg, _ := s.controlPlaneRefusal(acct, app, "renamed"); msg != "" { back("err", msg) return } // Host-claim consoles refuse every rename, recordless or not, before // any lock, registration or write (claimRenameUnsupportedMsg). if s.cfg.HostClaim { s.renderMessage(w, r, http.StatusConflict, "Not available", claimRenameUnsupportedMsg) return } defer s.lockApp(acct.Sqid, app)() if msg, _ := s.temporaryRefusal(acct, app, "rename it"); msg != "" { back("err", msg) return } // Normalized exactly as the create doors normalize (normalizeAppName), // and refused exactly where claimApp refuses (reservedAppName): "new" is // the create form's own literal route, and an app renamed onto it would // be unreachable — the mux prefers the literal, so no console page could // ever show it; consoleMailApp in the ops account is the console's own // mail referrer. to := normalizeAppName(r.FormValue("to")) if !release.ValidApp(to) { s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", "App names are lowercase letters, digits and dashes.", map[string]string{"rename": r.FormValue("to")}) return } if reservedAppName(acct, to) { s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", "That name is reserved.", map[string]string{"rename": r.FormValue("to")}) return } if release.IsTemporaryName(to) { s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", "Names starting with tmp- are kept for temporary sites. Choose another name.", map[string]string{"rename": r.FormValue("to")}) return } if to == app { s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", "That is already this app's name.", map[string]string{"rename": r.FormValue("to")}) return } if s.cfg.Releases == nil { back("err", "No deployment bucket on this host, so the never-shipped check cannot run — renaming is refused.") return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() // The two wider stores that know app names, checked at action time — the // Settings view's CanRename was advice, this is the refusal. Both fail // CLOSED: "could not check" is not "empty". switch empty, err := s.appDeployEmpty(ctx, acct.Sqid, app); { case err != nil: back("err", "Could not check the deployment bucket — nothing was renamed. Try again.") return case !empty: back("err", "This app has shipped — its name is baked into its releases' bucket keys, so renaming it is not supported. Create a new app and ship there instead.") return } switch wired, err := s.appHasRoutes(app); { case err != nil: back("err", "Could not check the routes — nothing was renamed. Try again.") return case wired: back("err", "A host still routes this name — remove its routes first.") return } // The new name's platform apex must not already be another app's (apex.go). switch hold, err := s.apexHolder(ctx, acct, to); { case err != nil: back("err", "Could not check "+to+"'s platform address — nothing was renamed. Try again.") return case hold != nil: back("err", "Cannot rename to "+to+": "+apexHeldSentence(s.apexHost(acct.Sqid, to), hold)+".") return } if !confirmed(r) { cv := &ConfirmView{ Title: "Rename " + app + " to " + to + "?", Lines: []string{ app + " has never shipped, so nothing outside this console knows the name yet.", "After this, the app is " + to + " — commands, pages and its future addresses all say so.", }, Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rename", Fields: [][2]string{ {"csrf", raw}, {"to", to}, }, Button: "Rename", CancelTab: "settings", } s.renderConfirm(w, r, addr, raw, acct, role, app, cv) return } if err := s.store.RenameAppIn(acct.ID, app, to); err != nil { log.Printf("console: rename %s/%s -> %s: %v", acct.Sqid, app, to, err) back("err", renameAdvice(err, to)) return } // The emptiness checks above cannot join the database transaction — the // bucket and the registry are other stores — so a `carlos ship` or // `carlos add` racing this request can land under the old name between // check and commit. Re-checking AFTER the commit closes most of that // window: on detection the rename is undone and the human told, which // converges toward the safe state (the console record naming what the // bucket and routes name) instead of silently splitting the identity. stale := false if empty, err := s.appDeployEmpty(ctx, acct.Sqid, app); err == nil && !empty { stale = true } if wired, err := s.appHasRoutes(app); err == nil && wired { stale = true } // And the new name's apex, which an instance racing this rename can take // between the check above and the commit (apex.go). if hold, err := s.apexHolder(ctx, acct, to); err == nil && hold != nil { stale = true } if stale { if err := s.store.UndoRenameAppIn(acct.ID, to, app); err != nil { log.Printf("console: rename %s/%s -> %s: mid-rename ship detected and UNDO FAILED: %v — console record and platform state now disagree", acct.Sqid, app, to, err) back("err", "Something landed under "+app+" or at "+to+"'s address while renaming, and undoing hit an error — the record may disagree with the platform. Re-check this page.") return } back("err", "A ship, a route, or an instance at "+to+"'s address landed while renaming — the rename was undone; the name stays.") return } s.audit(acct, to, addr, "rename", "renamed "+app+" to "+to) s.setFlash(w, "ok", "Renamed "+app+" to "+to+".") http.Redirect(w, r, appSettingsURL(acct.Sqid, to, "general"), http.StatusSeeOther) } // renameAdvice maps RenameAppIn's refusals onto the sentence a person can // act on, without leaking storage details they cannot. func renameAdvice(err error, to string) string { msg := err.Error() switch { case errors.Is(err, ErrTemporaryApp): return "This app is temporary, so it cannot be renamed. Make it permanent first with carlos apps keep." case errors.Is(err, ErrTemporaryNameReserved): return "Names starting with tmp- are kept for temporary sites. Choose another name." case strings.Contains(msg, "already has an app"): return "This account already has an app (or a trashed app) named " + to + "." case strings.Contains(msg, "legacy owner grant"): return "One of these names carries a legacy grant — renaming across it is an operator act." case strings.Contains(msg, "app credentials"): return to + " still has app credentials from a previous app of that name — revoke them on the tokens page first." default: return "Could not rename — nothing changed. Try again." } } // handleAppGate is the Settings page's password-gate form: set a password // on one instance host, or turn it off. Member-gated, matching the API // PATCH's door — the CLI already lets a member do this, and a form that // refuses what the CLI allows is a rule in the wrong place. CSRF first, // then the same read-mutate-validate-write-audit sequence as the PATCH, // against one field. The password is never logged, audited or echoed. func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) { addr, raw, acct, _, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther) } if s.cfg.Releases == nil { back("err", "No deployment bucket on this host, so the gate cannot be changed here.") return } host := strings.TrimSpace(r.FormValue("host")) if !provision.ValidHostShape(host) { http.NotFound(w, r) return } var hash string switch r.FormValue("action") { case "clear": case "set": p := r.FormValue("password") if err := gate.CheckPassword(p); err != nil { back("err", err.Error()+".") return } h, err := gate.Hash(p) if err != nil { log.Printf("console: gate hash %s/%s/%s: %v", acct.Sqid, app, host, err) back("err", "Could not set the password — try again.") return } hash = h default: back("err", "Choose set or clear.") return } ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) defer cancel() rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) if errors.Is(err, os.ErrNotExist) { http.NotFound(w, r) return } if err != nil { log.Printf("console: gate read %s/%s/%s: %v", acct.Sqid, app, host, err) back("err", "Could not read the instance — try again.") return } rec.Gate = hash if err := provision.ValidateShape(*rec); err != nil { back("err", err.Error()+".") return } if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { back("err", "The instance changed while this request was in flight — try again.") return } log.Printf("console: gate write %s/%s/%s: %v", acct.Sqid, app, host, err) back("err", "Could not update the instance — try again.") return } if hash == "" { s.audit(acct, app, addr, "gate", "cleared password gate on "+host+" (console)") s.bumpSerial(ctx) back("ok", "Password turned off on "+host+".") return } s.audit(acct, app, addr, "gate", "set password gate on "+host+" (console)") s.bumpSerial(ctx) back("ok", "Password set on "+host+".") } // handleAppDelete puts the app in the trash: the deletion-policy marker in // the bucket, then this console's row — the same order and the same clock // discipline as the API door (handleAPITrashApp), behind the account-owner // ceremony: type the name, then the passkey. Restorable from the Trash for // the cooloff; hosts drop the app's routes on their next reconcile pass. func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "delete"), http.StatusSeeOther) } // Outright, before the typed name and long before the passkey: the flag // is the guard, and no gesture answers it (controlplane.go). if msg, _ := s.controlPlaneRefusal(acct, app, "trashed"); msg != "" { back("err", msg) return } // Typing the name is a "did you mean this app?" control, not a security // one — checked before step-up so a slip does not cost a passkey touch. if strings.TrimSpace(r.FormValue("confirm")) != app { back("err", "Type the app's name exactly to delete it.") return } if s.cfg.Releases == nil { back("err", "No deployment bucket on this host — this console cannot delete an app.") return } if !s.requireFreshAuth(w, r, addr, raw) { return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() // Every transition takes the app's lock and settles first (spec §2, §5). // The checks above ran before the lock: ask again. An app already in the // Trash with its marker falls through to the idempotent path below. defer s.lockApp(acct.Sqid, app)() if _, ref := s.recheckUnderLock(ctx, acct, app, "trashed", false, time.Now()); ref != nil { switch ref.Kind { case lockedGone: back("err", app+" no longer exists.") case lockedControlPlane: back("err", ref.Msg) default: back("err", "Could not confirm the trash marker. Nothing was changed. Try again.") } return } // Production outranks deletion, and an unreadable flag IS production — // the deletion-policy reconciler will refuse the marker anyway, so // writing one would only manufacture disagreement. flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { log.Printf("console: trash %s/%s: production flag: %v", acct.Sqid, app, err) back("err", "Could not read this app's production setting — when in doubt it's treated as production, so deleting is refused. Set it again on the Releases page, then try again.") return } if flag != nil && flag.Production { back("err", app+" is marked as production, so deleting is refused. Turn the safety delay off first (owner + passkey, on the Releases page; it takes effect after ~72h), then try again.") return } now := time.Now() if err := release.TrashApp(ctx, s.cfg.Releases, acct.Sqid, app, addr, now); err != nil { log.Printf("console: trash %s/%s: %v", acct.Sqid, app, err) back("err", "Could not write the trash marker — nothing was deleted. Try again.") return } // The marker's clock is the feature's one clock: TrashApp is create-only, // so a racing or repeated delete keeps the ORIGINAL DeletedAt, and the // row must carry that, not this request's time. A re-read that fails // therefore HARD-FAILS with nothing written to the row — the same rule as // the API door: a row stamped with this request's clock would promise a // restore deadline the purge pass does not honor, and it never self-heals // (materialize short-circuits on a trashed row). The marker is already // written, so a retry simply finishes the job. tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil || tomb == nil { log.Printf("console: trash %s/%s: re-read marker: %v", acct.Sqid, app, err) back("err", "The trash marker was written but could not be read back, so this console's record was left alone — delete again to finish.") return } deletedAt := tomb.DeletedAt if err := s.store.TrashAppRowContext(r.Context(), acct.ID, app, deletedAt); err != nil { log.Printf("console: trash row %s/%s: %v", acct.Sqid, app, err) back("err", "The trash marker was written, but this console's record did not update — reload; if the app still shows, try deleting again.") return } s.audit(acct, app, addr, "trash", "moved "+app+" to the trash") // Box-consumed (carlos-deploy §3), symmetric with the web restore door // (ownership.go, handleRestoreApp): the trash marker is what stops // instance minting and drops the app's routes (provision/reconcile.go, // domains/reconcile.go). Without the bump a trash through this door sits // invisible to the fleet until the hourly floor comes round, while // restore already announces in seconds. s.bumpSerial(ctx) // One deadline, everywhere (spec §5): same computation as the API door. until := deletedAt.Add(release.TemporaryCooloff) if life, lerr := s.store.AppLife(acct.ID, app); lerr == nil && life != nil { until = effectivePurgeAt(life.DeletedAt, life.PurgeAfter) } else if lerr != nil { log.Printf("console: trash %s/%s: read back the deadline: %v", acct.Sqid, app, lerr) } s.setFlash(w, "ok", "Moved "+app+" to the trash. You can restore it there until "+ until.UTC().Format("2006-01-02 15:04 UTC")+". Hosts stop serving it on their next pass.") http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther) } // handleStoreDestroy dismantles an app's object store (issue #55, objstore spec // §9): the bucket's contents, the bucket, the IAM principal holding its key, // the delivered variables, and the record naming them — in that order, last // first, so a failure anywhere leaves the record still naming a standing // estate. // // It is the sibling of handlePurgeApp (ownership.go) and wears the same // ceremony, in the same order handleAppDelete above wears it — session, CSRF, // owner's byte-identical 404, the typed confirmation, and requireFreshAuth // LAST, so no redirect into Keymail ever happens for someone who should have // got a 404: // // owner — a member probing this gets a stranger's answer. // CSRF — constant-time, before any pending action can be parked. // control plane — refused outright, before the typed word and before the // passkey: this ceremony is exactly the one spec §6 rules // insufficient for the app that IS the platform. // type the BUCKET — not the app's name: the bucket is the thing that stops // existing, and its objects are what cannot be brought // back. A declared-only record has no bucket, so there the // word is the app's name — the only noun it has. // no backend — refused here, before the passkey, on an estate this // console has no authority over: no gesture could change // the answer, so asking for one would waste it. // requireFreshAuth — LAST of the auth gates. // // There is no trash for a store and no undo: the objects are gone, and the // credentials with them. Teardown itself re-reads the record immediately before // the final delete and refuses (wrapping release.ErrCASConflict) if it changed // underneath — a grant landing while this form sat open wins, exactly as a // restore wins over a purge. func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } // The local refusal: a flash and the settings tab, mirroring trashFailure's // shape for the Trash page. Not a 404 — everyone reaching it has passed the // ownership gate and is entitled to know what went wrong. fail := func(msg string) { s.setFlash(w, "err", msg) http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "storage"), http.StatusSeeOther) } // The sixth destructive door, and the one whose ceremony is closest to // enough without being it: owner, CSRF, the typed BUCKET name and a // passkey — exactly the ceremony spec §6 rules insufficient for the // control plane, over an estate whose own doc says there is no trash and // no undo. A guard the neighbouring door walks around reads as protection // without being it (controlplane.go). // // The refusal is on DESTROYING the store, not on having one: a // control-plane app may hold an object store like any other app, and // forbidding that would be an invented constraint the spec does not make. if msg, _ := s.controlPlaneRefusal(acct, app, "destroyed"); msg != "" { fail(msg) return } if s.cfg.Releases == nil { fail("No deployment bucket on this host, so this console cannot read the store record — nothing was destroyed.") return } // storeTeardownTimeout, not storeTimeout: the walk below is many round trips // (EmptyBucket is paginated for exactly the bucket too big to clear in one // call), and a 15s ceiling documented for a single round trip would cut a // large store's teardown off mid-walk and report it as an unreachable // backend. ctx, cancel := context.WithTimeout(r.Context(), storeTeardownTimeout) defer cancel() // Taken BEFORE the read, not just around the walk: the confirm word and // the estate check below are both derived from this record, and a walk // landing between reading it and tearing it down would make both stale — // the shape objstore's deleteRecordIfUnchanged is guarding against from // the other end (objstore/walk.go:560). // Which store: the app's own, or one belonging to a named config // environment. The form carries it; an unknown or malformed name reads // back as no record at all, which is the honest answer to "destroy the // store that isn't there" and the same one a tampered field gets. env := strings.TrimSpace(r.FormValue("environment")) defer s.storeWalks.lock(acct.Sqid, app, env)() rec, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) if err != nil { log.Printf("console: store destroy read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), err) fail("Could not read “" + app + "”'s object-store record — nothing was destroyed. Try again.") return } if rec == nil { if env != "" { fail("“" + app + "” has no object store for the " + env + " environment, so there is nothing to destroy.") return } fail("“" + app + "” has no object store, so there is nothing to destroy.") return } want := storeConfirmWord(rec, app) if strings.TrimSpace(r.FormValue("confirm")) != want { fail("Type “" + want + "” exactly to destroy this store. Nothing was destroyed.") return } // Grant OR Provisioned is an estate: a grant block on its own is a grant // that crashed after creating the bucket and the key but before recording // them (objstore.Teardown's own rule), and tearing that down is backend // work like any other. estate := rec.Grant != nil || rec.Provisioned != nil if estate && s.cfg.StoreProvisioner == nil { fail("This console has no storage backend configured (CARLOS_STORE_BACKEND), so it cannot " + "delete “" + app + "”'s bucket or its credentials — nothing was destroyed. An operator of " + "this deployment has to tear it down.") return } // The same shape for the scanner. objstore.Teardown refuses a store whose // scanning is enabled when it has no engine to release it with — without // this pre-check that refusal arrives as the generic "Could not destroy" // flash below, which tells the member nothing they can act on. if rec.Scanning != nil && rec.Scanning.Enabled != nil && s.cfg.StoreScanner == nil { fail("“" + app + "” has virus scanning enabled and this console has no scanner configured " + "(CARLOS_STORE_SCANNER), so it cannot release the scanning engine — nothing was destroyed. " + "An operator of this deployment has to tear it down.") return } if !s.requireFreshAuth(w, r, addr, raw) { return } // R3 (review follow-up): this door tears down ONLY the object store. // It used to also tear down the app's email estate — the SES identity, // the IAM principal, delivered credentials, published DNS and, // irreversibly and globally, the domain claim — but consent for THAT // was never obtained here: the confirm word is the bucket name, the // button says "Destroy store", and the success flash below names only // the object store. Email teardown belongs to ownership.go's // handlePurgeApp, which destroys the whole app and says so. if err := s.claimAppMutation(ctx, acct, app, "store-destroy", func(ctx context.Context) error { return objstore.TeardownIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, acct.Sqid, app, env) }); err != nil { log.Printf("console: store destroy %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), err) if errors.Is(err, release.ErrCASConflict) { fail("The store changed while you confirmed — nothing was destroyed; reload and try again.") return } fail("Could not destroy “" + app + "”'s object store — it is still there; try again once the " + "backend is reachable.") return } what, flash := "the store declaration", "Withdrew “"+app+"”'s store declaration." if estate { what = "bucket " + want flash = "Destroyed “" + app + "”'s object store: bucket " + want + ", its credentials and the " + "variables they were delivered in are gone." } s.audit(acct, app, addr, "store-destroy", "destroyed the object store ("+what+")"+storeEnvSuffix(env)) // Box-consumed: the delivered variables were UNSET in the app's config // (objstore.Teardown), so every host still serving this app has an env file // naming a bucket and a key that authenticate against nothing. Without the // bump that stands until the hourly floor comes round. s.bumpSerial(ctx) s.setFlash(w, "ok", flash) http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "storage"), http.StatusSeeOther) } // storeConfirmWord is what a person must type to destroy a store: the BUCKET // when the record names one, because the bucket and its objects are what stop // existing, and the app's own name when it does not — a declaration, where the // app IS the whole subject. The fallback also closes the empty-string trap: a // grant block with no bucket cannot come from the walk, but it can come from a // hand-edited object, and an empty expected word would let an empty form field // confirm a destruction. func storeConfirmWord(rec *objstore.Record, app string) string { switch { case rec.Provisioned != nil && rec.Provisioned.Bucket != "": return rec.Provisioned.Bucket case rec.Grant != nil && rec.Grant.Bucket != "": return rec.Grant.Bucket } return app } // handleAppMoveAccount is the repair half of an account move: when the // app's routes already say another account owns it (AccountMismatch), this // makes the console's record agree — `console migrate-app`, from the // browser, for the owner standing in both accounts. The target comes from // the ROUTES, never the form: this door re-homes the record to where the // platform already deploys from, and nowhere else. func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, app, ok := s.accountApp(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } if role != RoleOwner { http.NotFound(w, r) return } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther) } // The control plane does not change accounts: its releases are read from // one account's prefix by every box in the fleet (controlplane.go). if msg, _ := s.controlPlaneRefusal(acct, app, "moved to another account"); msg != "" { back("err", msg) return } // Host-claim consoles refuse every account transfer, recordless or // not, before any lock, registration or write // (claimTransferUnsupportedMsg). if s.cfg.HostClaim { s.renderMessage(w, r, http.StatusConflict, "Not available", claimTransferUnsupportedMsg) return } defer s.lockApp(acct.Sqid, app)() if msg, _ := s.temporaryRefusal(acct, app, "move it to another account"); msg != "" { back("err", msg) return } target, agreed, err := s.routeAccountFor(addr, acct, app) if errors.Is(err, errMoveTargetUnavailable) { http.NotFound(w, r) return } if err != nil { back("err", "Could not read the routes — nothing was moved. Try again.") return } // The action-time equivalent of the card's AllMismatched gate. The card is // only offered when EVERY non-platform route disagrees with this account, // but a direct POST — or a tab opened before the routes changed — reaches // here regardless, and target is only the FIRST route's account. Under a // partial mismatch that first route can be the minority: confirming would // then move the whole record to where a minority of hosts deploy from, // turning a partial disagreement into a total one the other way. Refuse // before the card is rendered and before anything is written. if !agreed { back("err", "This app's routes disagree among themselves — nothing was moved; fix the routes first.") return } if target == "" || target == acct.Sqid { back("err", "This app's routes agree with this account — there is nothing to move.") return } dest, err := s.store.AccountBySqid(target) if err != nil || dest == nil { back("err", "The routes name account "+target+", which this console does not know — that is an operator repair.") return } // In the destination, this app's platform apex must not already be // another app's (apex.go). apexCtx, apexCancel := context.WithTimeout(r.Context(), 10*time.Second) hold, err := s.apexHolder(apexCtx, dest, app) apexCancel() switch { case err != nil: back("err", "Could not check "+app+"'s platform address in "+dest.Name+" — nothing was moved. Try again.") return case hold != nil: back("err", "Cannot move "+app+" to "+dest.Name+": "+apexHeldSentence(s.apexHost(dest.Sqid, app), hold)+".") return } if drole, err := s.store.Role(dest.ID, addr); err != nil || drole == "" { back("err", "You are not a member of "+dest.Name+" ("+target+"), so moving the record there would strand the app for you. Ask an owner of that account, or an operator, to finish the move.") return } // The card PINS its target: the confirmed POST carries the sqid the card // named, and a mismatch with the freshly re-derived target refuses. The // window between card and confirmed POST includes a passkey round trip — // long enough for an operator to re-stamp the routes to some OTHER // account the viewer also belongs to, and the record must never move to // an account nobody saw on a card. if !confirmed(r) { cv := &ConfirmView{ Title: "Move " + app + " to " + dest.Name + "?", Lines: []string{ "This app's hosts already read releases from " + dest.Name + " (" + target + ") — the console's own record is what still says " + acct.Name + ".", "Moving makes them agree: the app's page, promotes and activity live under " + dest.Name + " from here on.", }, Note: "You'll confirm it's really you with your passkey.", Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/move", Fields: [][2]string{ {"csrf", raw}, {"to", dest.Sqid}, }, Button: "Move to " + dest.Name, CancelTab: "settings", } s.renderConfirm(w, r, addr, raw, acct, role, app, cv) return } if pinned := strings.TrimSpace(r.FormValue("to")); pinned != "" && pinned != dest.Sqid { back("err", "This app's routes changed while you were confirming — review the move again.") return } if !s.requireFreshAuth(w, r, addr, raw) { return } if err := s.store.MoveApp(app, acct.ID, dest.ID); err != nil { log.Printf("console: move %s %s -> %s: %v", app, acct.Sqid, target, err) back("err", "Could not move the record — nothing changed. "+moveAdvice(err, dest.Name)) return } s.audit(acct, app, addr, "move", "moved "+app+" to account "+dest.Name+" ("+target+")") // MoveApp revoked the name's door keys on both sides; tell the boxes now. s.republishDoorKeys(r, acct, app) s.republishDoorKeys(r, dest, app) if err := s.store.RecordEventContext(r.Context(), dest.ID, app, addr, "move", "arrived from account "+acct.Name+" ("+acct.Sqid+")", time.Now()); err != nil { log.Printf("console: move event %s: %v", app, err) } s.setFlash(w, "ok", "Moved "+app+" to "+dest.Name+" — console and routes agree again.") http.Redirect(w, r, "/a/"+url.PathEscape(dest.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) } // moveAdvice maps MoveApp's refusals to actionable text. func moveAdvice(err error, destName string) string { if errors.Is(err, ErrTemporaryApp) { return "This app is temporary, so it cannot move. Make it permanent first with carlos apps keep." } if strings.Contains(err.Error(), "already claims") { return destName + " already has a different app with this name." } return "Try again." } var errMoveTargetUnavailable = errors.New("move target unavailable to this viewer") // routeAccountFor recomputes the viewer-safe diagnostic at action time. It // grants a target only when every relevant route points to one accessible // account which does not already claim its own same-named app. func (s *Server) routeAccountFor(address string, source *Account, app string) (target string, agreed bool, err error) { review, all, err := s.appAccountReview(address, source, app) if err != nil { return "", false, err } if all { target := review.Groups[0].Account.Sqid reg, err := openRegistry(s.cfg.Registry) if err != nil { return "", false, err } routes, err := reg.List() if err != nil { return "", false, err } for _, rt := range routes { if rt.App == app && !rt.Platform && release.RouteAccount(rt) != target { return "", false, nil } } return target, true, nil } if review.blocked { return "", false, errMoveTargetUnavailable } if review.Count == 0 && !review.blocked { return source.Sqid, true, nil } return "", false, nil }