| 1 | // The app Settings tab's write half: rename, delete, and the account-move |
| 2 | // repair — the app-level siblings of ownership.go's account actions, holding |
| 3 | // the same gate order (session → CSRF → owner 404 → cheap refusals → confirm |
| 4 | // → requireFreshAuth LAST) and the same posture: the console mutates its own |
| 5 | // database and the bucket's markers, never the registry. |
| 6 | // |
| 7 | // What is deliberately NOT here, and why: |
| 8 | // |
| 9 | // - Renaming a LIVE app. The name is a literal key segment in every store |
| 10 | // this platform has — bucket prefixes, signed pointer payloads, registry |
| 11 | // rows, platform hostnames, even a host-role app's Unix user — so a live |
| 12 | // rename is a coordinated migration, not a settings write. The form |
| 13 | // refuses with that reason once anything outside this database knows the |
| 14 | // name. |
| 15 | // - Attach/detach ITSELF. Domains attach and detach through their own door |
| 16 | // now (appdomains.go, #82 Task 4) — the claim/record writes, their gate |
| 17 | // order and the audit trail all live there. What's here is read-only: |
| 18 | // settingsDomains below folds an app's still-routeless claims (every |
| 19 | // alias claim, and a first-route claim before the reconciler has |
| 20 | // materialized its route — neither ever gets a registry row of its |
| 21 | // own) into the Domains & TLS table this page already draws, so an |
| 22 | // attach shows up the moment it lands, not only once a route exists to |
| 23 | // join it to. |
| 24 | // - The full account move. `carlos accounts migrate` moves bucket objects |
| 25 | // and re-stamps routes on the box; what the console owns is its own |
| 26 | // record, so what Settings offers is the repair half — making this |
| 27 | // database agree with what the routes already say. |
| 28 | package console |
| 29 | |
| 30 | import ( |
| 31 | "context" |
| 32 | "crypto/subtle" |
| 33 | "errors" |
| 34 | "log" |
| 35 | "net/http" |
| 36 | "net/url" |
| 37 | "os" |
| 38 | "sort" |
| 39 | "strings" |
| 40 | "time" |
| 41 | |
| 42 | "github.com/carlosframework/platform/internal/appconfig" |
| 43 | "github.com/carlosframework/platform/internal/domains" |
| 44 | "github.com/carlosframework/platform/internal/gate" |
| 45 | "github.com/carlosframework/platform/internal/mail" |
| 46 | "github.com/carlosframework/platform/internal/objstore" |
| 47 | "github.com/carlosframework/platform/internal/provision" |
| 48 | "github.com/carlosframework/platform/internal/release" |
| 49 | ) |
| 50 | |
| 51 | // appSettingsSections is the closed set accepted after /settings/. Keeping |
| 52 | // this server-side means a typo gets the same 404 as any other unknown app |
| 53 | // page, rather than rendering an empty settings shell. |
| 54 | var appSettingsSections = map[string]bool{ |
| 55 | "general": true, |
| 56 | "domains": true, |
| 57 | "pipeline": true, |
| 58 | "environments": true, |
| 59 | "storage": true, |
| 60 | "email": true, |
| 61 | "features": true, |
| 62 | "advanced": true, |
| 63 | "delete": true, |
| 64 | } |
| 65 | |
| 66 | func appSettingsURL(acct, app, section string) string { |
| 67 | base := "/a/" + url.PathEscape(acct) + "/apps/" + url.PathEscape(app) + "/settings" |
| 68 | if section == "" || section == "general" { |
| 69 | return base |
| 70 | } |
| 71 | return base + "/" + section |
| 72 | } |
| 73 | |
| 74 | // SettingsView is what the Settings tab knows beyond the shared AppView. |
| 75 | type SettingsView struct { |
| 76 | // NoBucket distinguishes a console deliberately running without a |
| 77 | // deployment store from a transient read failure. |
| 78 | NoBucket bool |
| 79 | |
| 80 | // CanRename: nothing outside the console database knows this name yet. |
| 81 | // RenameBlocked carries the reason when it is false. |
| 82 | CanRename bool |
| 83 | RenameBlocked string |
| 84 | |
| 85 | // ControlPlane: this app IS the platform, so its destructive verbs are |
| 86 | // refused until the flag is cleared (controlplane.go). Read straight |
| 87 | // from the row rather than derived, and false when the row cannot be |
| 88 | // read — the PAGE showing no card is the right failure, since the |
| 89 | // handler re-decides everything anyway and the refusals do not depend |
| 90 | // on this being rendered. |
| 91 | ControlPlane bool |
| 92 | |
| 93 | // The account-move repair: MismatchTarget is the account the app's |
| 94 | // routes say it belongs to ("" when console and routes agree), |
| 95 | // TargetKnown whether this console has that account at all, and |
| 96 | // ViewerInTarget whether the signed-in owner is a member of it — the |
| 97 | // difference between offering the button and explaining the operator |
| 98 | // path. |
| 99 | MismatchTarget string |
| 100 | TargetKnown bool |
| 101 | ViewerInTarget bool |
| 102 | |
| 103 | // Store is the app's object store as this page sees it — nil when the |
| 104 | // record could not be read (the page says nothing rather than guessing), |
| 105 | // and a zero card when there is genuinely no store, which is what the |
| 106 | // "ask for one" line renders from. |
| 107 | Store *StoreCard |
| 108 | // StoreUnavailable distinguishes a failed read from a genuinely absent |
| 109 | // record, so the section remains present and does not claim there is no |
| 110 | // store during a transient failure. |
| 111 | StoreUnavailable bool |
| 112 | // Envs is one card per named config environment — the bundles themselves, |
| 113 | // which until now had no console surface at all: the ladder shows which |
| 114 | // environment a route is wired to, and nothing showed what the |
| 115 | // environments ARE. Read-only, and variable NAMES only: this page has |
| 116 | // never shown a config value and does not start here. |
| 117 | Envs []*EnvCard |
| 118 | // EnvsUnavailable distinguishes a failed listing from an app that |
| 119 | // genuinely has none, so the section does not claim "no environments" |
| 120 | // during a transient bucket failure — StoreUnavailable's rule. |
| 121 | EnvsUnavailable bool |
| 122 | |
| 123 | // StoreEnvs is one card per ENVIRONMENT store (2026-09-19), oldest name |
| 124 | // first, empty for the apps that have none — which is all of them until |
| 125 | // someone asks for one. Each carries its own bucket, its own credential |
| 126 | // and its own destroy form: this is the only screen where a store that |
| 127 | // no instance reads is visible, and teardown is explicit by ruling, so |
| 128 | // an unnoticed one keeps billing. |
| 129 | StoreEnvs []*StoreCard |
| 130 | |
| 131 | // Mail is the app's tenant email as this page sees it (issue #240) — |
| 132 | // object storage's sibling primitive, same declare -> provision -> |
| 133 | // deliver -> rotate shape, and the same error discipline as Store: nil |
| 134 | // on a read failure, a zero card when there is genuinely no record. |
| 135 | Mail *MailCard |
| 136 | MailUnavailable bool |
| 137 | |
| 138 | // Features is the app's feature flags as the platform serves them |
| 139 | // (#347, apifeatures.go) — read-only here; the write is the audited, |
| 140 | // admin-gated CLI door. Same error discipline as Store: nil on a read |
| 141 | // failure (the page says nothing rather than "none set" about a record |
| 142 | // it could not parse), a zero card when there genuinely are none. |
| 143 | Features *FeaturesCard |
| 144 | FeaturesUnavailable bool |
| 145 | |
| 146 | // Domains is this page's OWN Domains & TLS rows (settingsDomains): a |
| 147 | // copy of AppView.Domains with a placeholder row appended for any of |
| 148 | // this app's claims that have no registry row yet. Deliberately not |
| 149 | // written back onto AppView.Domains itself — that slice stays |
| 150 | // index-parallel with AppView.Routes for assembleOverview's account |
| 151 | // filter (view.go), and a claim-only row has no route to pair with. |
| 152 | Domains []DomainView |
| 153 | // OnlyPlatformDomain is true when Domains holds rows but none of them |
| 154 | // is a custom one — the state the attach form's empty-state line |
| 155 | // ("this app already answers at its platform domain…") is written for. |
| 156 | OnlyPlatformDomain bool |
| 157 | |
| 158 | // NeedsChannel is whether the attach form must show its channel field: |
| 159 | // true exactly when readyRouteHost (domainattach.go) would find no |
| 160 | // ready, non-Platform route to alias, which is the SAME condition the |
| 161 | // core uses to pick the first-route branch — attachChannel then only |
| 162 | // has a self channel or the app's sole shipped channel to fall back on, |
| 163 | // and refuses with "say which one" the moment there is more than one. |
| 164 | // This is deliberately NOT len(Detail.Routes) == 0: an app whose only |
| 165 | // route is its own platform mint has a non-empty Routes slice, but |
| 166 | // readyRouteHost skips Platform rows, so it still takes the |
| 167 | // first-route branch and still needs somewhere to type a channel. |
| 168 | NeedsChannel bool |
| 169 | |
| 170 | // Gates is the password-gate section (spec §5.5): one row per instance |
| 171 | // record, with whether a password is set. GatesUnavailable carries the |
| 172 | // reason when the records could not be read (no bucket, a store error) |
| 173 | // and the section says so instead of rendering an empty list as "no |
| 174 | // instances". |
| 175 | Gates []GateRow |
| 176 | GatesUnavailable string |
| 177 | } |
| 178 | |
| 179 | // GateRow is one instance on the Settings page's password-gate section. |
| 180 | type GateRow struct { |
| 181 | Host string |
| 182 | Gated bool |
| 183 | } |
| 184 | |
| 185 | // StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue |
| 186 | // #55): the three blocks of the record folded into what a person with no box |
| 187 | // access can read and act on. |
| 188 | // |
| 189 | // KeyAge is the age of Provisioned.At and is labelled as the KEY's age |
| 190 | // everywhere it appears, never as when the store was provisioned: that |
| 191 | // timestamp moves on every rotation AND on every grant repair (objstore walk.go |
| 192 | // step 6), so "provisioned 2 minutes ago" would be a sentence the page tells |
| 193 | // wrongly forever after the first repair. |
| 194 | // |
| 195 | // CanDestroy is the affordance, not the authority — the handler re-decides all |
| 196 | // of it (a page can be hours old, and StoreOperators/ownership are checked |
| 197 | // there). It is true when there is an estate AND a backend to tear it down |
| 198 | // with, or when the record is a declaration nobody has acted on, which needs no |
| 199 | // backend at all because nothing outside the bucket exists yet. |
| 200 | type StoreCard struct { |
| 201 | // Environment is the config environment this store belongs to, "" for |
| 202 | // the app's own. The destroy form posts it back, so the door tears down |
| 203 | // the store the card describes and no other. |
| 204 | Environment string |
| 205 | Declared bool |
| 206 | EnvPrefix string |
| 207 | Granted bool |
| 208 | Backend string |
| 209 | Bucket string |
| 210 | Provisioned bool |
| 211 | KeyID string |
| 212 | KeyAge string |
| 213 | PendingFinish bool |
| 214 | CanDestroy bool |
| 215 | } |
| 216 | |
| 217 | // EnvCard is one named config environment as the Settings page sees it. |
| 218 | // |
| 219 | // Variable NAMES only, never values. This page has never shown a config |
| 220 | // value — `carlos env list` is where values live, behind a bearer token — |
| 221 | // and an environment bundle is exactly where a per-tenant origin or a |
| 222 | // delivered credential name sits, so starting here would be the wrong place |
| 223 | // to start. |
| 224 | type EnvCard struct { |
| 225 | Name string |
| 226 | // Vars are the names this bundle SETS, sorted. A named bundle overlays |
| 227 | // the app's default per key, so this is what the environment overrides |
| 228 | // or adds — not the whole set an instance bound to it ends up reading. |
| 229 | Vars []EnvVarName |
| 230 | // Hosts are the instances wired to this bundle. Empty is the case worth |
| 231 | // looking at: a bundle nothing reads, which is where an abandoned store |
| 232 | // hides. |
| 233 | Hosts []string |
| 234 | // HasStore and StoreBucket: whether this environment has an object store |
| 235 | // of its own, and what it is called. An environment store bills whether |
| 236 | // or not any instance reads the bundle, and teardown is explicit, so the |
| 237 | // pairing of "no hosts" with "has a store" is the thing this section |
| 238 | // exists to make visible. |
| 239 | HasStore bool |
| 240 | StoreBucket string |
| 241 | } |
| 242 | |
| 243 | // EnvVarName is a variable's name and whether it is sealed — enough to see |
| 244 | // the shape of a bundle without seeing into it. |
| 245 | type EnvVarName struct { |
| 246 | Name string |
| 247 | Secret bool |
| 248 | } |
| 249 | |
| 250 | // MailCard is the Settings tab's view of the app sender, apps/<acct>/<app>/ |
| 251 | // email/senders/app.json with an email.json read fallback (issue #240, |
| 252 | // mail.Record): tenant email's Settings card, mirroring StoreCard verb |
| 253 | // for verb — same declare -> provision -> deliver -> rotate shape, same |
| 254 | // read-only presentation, same error discipline (mailCard's doc comment). |
| 255 | // |
| 256 | // Available is NOT about this app's record at all — it answers whether this |
| 257 | // DEPLOYMENT has email configured (s.cfg.MailProvisioner != nil), the same |
| 258 | // class of question StoreCard.CanDestroy asks of s.cfg.StoreProvisioner. |
| 259 | // Every email API door 404s when it is false (mailProvisionerGate, |
| 260 | // apimail.go), so it decides which empty-state sentence the zero card gets: |
| 261 | // "ask for one" only when the command being suggested could actually run. |
| 262 | // |
| 263 | // KeyAge carries StoreCard.KeyAge's exact rule: it is Provisioned.At's age, |
| 264 | // labelled as the KEY's age everywhere, never as "provisioned" — a rotation |
| 265 | // moves that timestamp forward without changing when email was first turned |
| 266 | // on for this app. |
| 267 | // |
| 268 | // Domains is deliberately Grant.Domains, not a second read of |
| 269 | // mail.ListDomains + VerifiedARNs. The email API's own enable door |
| 270 | // (apimail.go's handleAPIEmailEnable) computes exactly this app's granted |
| 271 | // domain set with mail.ApplyGrant BEFORE mail.Enable runs and stores it as |
| 272 | // Grant.Domains — so Grant.Domains already IS "the verified domains granted |
| 273 | // to this app", sitting on the record this function is reading anyway. |
| 274 | // Re-deriving it from ListDomains would cost a second bucket listing and a |
| 275 | // scan for Apps-contains-this-app to show a set no more true than the one |
| 276 | // already on the record. |
| 277 | type MailCard struct { |
| 278 | Available bool |
| 279 | |
| 280 | Declared bool |
| 281 | EnvPrefix string |
| 282 | From string |
| 283 | |
| 284 | Granted bool |
| 285 | Domains []string |
| 286 | DailyCap int |
| 287 | |
| 288 | Provisioned bool |
| 289 | KeyID string |
| 290 | KeyAge string |
| 291 | PendingFinish bool |
| 292 | Paused bool |
| 293 | PauseKind string |
| 294 | |
| 295 | Standalone []MailStandaloneView |
| 296 | } |
| 297 | |
| 298 | // MailStandaloneView is one displayed-credential row (mail.Standalone). |
| 299 | // Never carries a password — there is none on the record, and the card must |
| 300 | // not invent a place to show one. |
| 301 | type MailStandaloneView struct { |
| 302 | Name string |
| 303 | KeyID string |
| 304 | Age string |
| 305 | } |
| 306 | |
| 307 | // assembleSettings fills the Settings tab's view. Best-effort by design: a |
| 308 | // bucket hiccup here blocks the rename form (fail closed — an unverifiable |
| 309 | // emptiness claim must not enable an identity change) and never the page. |
| 310 | func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *SettingsView { |
| 311 | sv := &SettingsView{} |
| 312 | // Domains, its derived OnlyPlatformDomain, and NeedsChannel are all |
| 313 | // read-only against the registry/claims and owe nothing to the |
| 314 | // deployment-bucket check below — they must be set even on a console |
| 315 | // with no bucket configured (s.cfg.Releases == nil), which used to |
| 316 | // return before reaching them and show "No domains yet" under a live |
| 317 | // route. |
| 318 | if cp, _, err := s.store.AppFlags(acct.ID, p.AppName); err != nil { |
| 319 | log.Printf("console: settings control-plane flag %s/%s: %v", acct.Sqid, p.AppName, err) |
| 320 | } else { |
| 321 | sv.ControlPlane = cp |
| 322 | } |
| 323 | sv.Domains = s.settingsDomains(ctx, acct, p.AppName, p.Detail.Domains) |
| 324 | sv.OnlyPlatformDomain = len(sv.Domains) > 0 |
| 325 | for _, d := range sv.Domains { |
| 326 | // Prefix, not equality: domainView appends " · canary/<name>" AFTER |
| 327 | // the "platform"/"custom" word (view.go), so a platform canary |
| 328 | // follower's Type is "platform · canary/rc" — equality against the |
| 329 | // bare word would wrongly count it as a custom domain here. |
| 330 | if !strings.HasPrefix(d.Type, "platform") { |
| 331 | sv.OnlyPlatformDomain = false |
| 332 | break |
| 333 | } |
| 334 | } |
| 335 | // The same question attachDomain's first line of policy answers |
| 336 | // (domainattach.go's readyRouteHost): is there a ready, non-Platform |
| 337 | // route this app could alias? "" means no — the attach form's channel |
| 338 | // field must show, because the core is about to take the first-route |
| 339 | // branch and attachChannel only has a self channel or the app's sole |
| 340 | // shipped channel to infer from. The attaching host is unknown here |
| 341 | // (nothing has been typed yet), so "" is passed — it can never equal a |
| 342 | // real route host, so every ready non-Platform route is a candidate. |
| 343 | if route, _, err := s.readyRouteHost(acct.Sqid, p.AppName, ""); err != nil { |
| 344 | log.Printf("console: settings needs-channel %s/%s: %v", acct.Sqid, p.AppName, err) |
| 345 | sv.NeedsChannel = true // fail toward showing the field the core might need |
| 346 | } else { |
| 347 | sv.NeedsChannel = route == "" |
| 348 | } |
| 349 | sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName) |
| 350 | if s.cfg.Releases == nil { |
| 351 | sv.NoBucket = true |
| 352 | sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run." |
| 353 | return sv |
| 354 | } |
| 355 | switch empty, err := s.appDeployEmpty(ctx, acct.Sqid, p.AppName); { |
| 356 | case err != nil: |
| 357 | sv.RenameBlocked = "Could not check the deployment bucket, so renaming is off until it can be." |
| 358 | case len(p.Detail.Routes) > 0: |
| 359 | sv.RenameBlocked = "This app is wired to a host — its name is its address, in routes and bucket keys alike." |
| 360 | case !empty: |
| 361 | sv.RenameBlocked = "This app has shipped — its name is baked into its releases' bucket keys." |
| 362 | default: |
| 363 | sv.CanRename = true |
| 364 | } |
| 365 | sv.Envs, sv.EnvsUnavailable = s.envCards(ctx, acct, p.AppName, p.Detail.Routes) |
| 366 | sv.Store = s.storeCard(ctx, acct, p.AppName) |
| 367 | sv.StoreEnvs = s.storeEnvCards(ctx, acct, p.AppName) |
| 368 | sv.Mail = s.mailCard(ctx, acct, p.AppName) |
| 369 | sv.Features = s.featuresCard(ctx, acct, p.AppName) |
| 370 | sv.StoreUnavailable = sv.Store == nil |
| 371 | sv.MailUnavailable = sv.Mail == nil |
| 372 | sv.FeaturesUnavailable = sv.Features == nil |
| 373 | if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched { |
| 374 | sv.MismatchTarget = ra |
| 375 | if target, err := s.store.AccountBySqid(ra); err == nil && target != nil { |
| 376 | sv.TargetKnown = true |
| 377 | if role, err := s.store.Role(target.ID, p.Address); err == nil && role != "" { |
| 378 | sv.ViewerInTarget = true |
| 379 | } |
| 380 | } |
| 381 | } |
| 382 | return sv |
| 383 | } |
| 384 | |
| 385 | // settingsGates lists the app's instance records for the password-gate |
| 386 | // section. The hash never reaches the template: GateRow carries a bool. |
| 387 | func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ([]GateRow, string) { |
| 388 | if s.cfg.Releases == nil { |
| 389 | return nil, "No deployment bucket on this host, so instance records cannot be read." |
| 390 | } |
| 391 | recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax) |
| 392 | if err != nil { |
| 393 | log.Printf("console: settings gates %s/%s: %v", acct.Sqid, app, err) |
| 394 | return nil, "Instance records could not be read just now." |
| 395 | } |
| 396 | rows := make([]GateRow, 0, len(recs)) |
| 397 | for _, rec := range recs { |
| 398 | rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""}) |
| 399 | } |
| 400 | sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| 401 | return rows, "" |
| 402 | } |
| 403 | |
| 404 | // FeaturesCard is the Settings page's read of the app's feature flags. |
| 405 | type FeaturesCard struct { |
| 406 | Flags []FeatureFlag // key-sorted |
| 407 | SetBy string |
| 408 | SetAt string // "" when no record |
| 409 | } |
| 410 | |
| 411 | type FeatureFlag struct{ Key, Value string } |
| 412 | |
| 413 | // featuresCard folds the features record into its Settings card, storeCard's |
| 414 | // discipline: nil on a read failure, a zero card when there is no record. |
| 415 | func (s *Server) featuresCard(ctx context.Context, acct *Account, app string) *FeaturesCard { |
| 416 | rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct.Sqid, app) |
| 417 | if err != nil { |
| 418 | log.Printf("console: settings features read %s/%s: %v", acct.Sqid, app, err) |
| 419 | return nil |
| 420 | } |
| 421 | card := &FeaturesCard{} |
| 422 | if rec == nil { |
| 423 | return card |
| 424 | } |
| 425 | for k, v := range rec.Features { |
| 426 | card.Flags = append(card.Flags, FeatureFlag{k, v}) |
| 427 | } |
| 428 | sort.Slice(card.Flags, func(i, j int) bool { return card.Flags[i].Key < card.Flags[j].Key }) |
| 429 | card.SetBy = rec.SetBy |
| 430 | if !rec.SetAt.IsZero() { |
| 431 | card.SetAt = rec.SetAt.UTC().Format("2006-01-02 15:04 UTC") |
| 432 | } |
| 433 | return card |
| 434 | } |
| 435 | |
| 436 | // storeCard folds the store record into the Settings card. Best-effort like |
| 437 | // everything else this page assembles: a read failure returns nil and the |
| 438 | // section is simply absent, because "could not read the record" and "there is |
| 439 | // no store" must not look the same on a page whose destroy button acts on the |
| 440 | // difference. An absent record is a zero card — the state that renders the one |
| 441 | // line telling a member how to ask for a store. |
| 442 | func (s *Server) storeCard(ctx context.Context, acct *Account, app string) *StoreCard { |
| 443 | rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| 444 | if err != nil { |
| 445 | log.Printf("console: settings store read %s/%s: %v", acct.Sqid, app, err) |
| 446 | return nil |
| 447 | } |
| 448 | if rec == nil { |
| 449 | return &StoreCard{} |
| 450 | } |
| 451 | return s.storeCardFor(rec, "") |
| 452 | } |
| 453 | |
| 454 | // envCards is one card per named config environment, sorted by name, with |
| 455 | // the second return saying the listing itself failed — the difference |
| 456 | // between "this app has no environments" and "I could not find out", which |
| 457 | // the section must not collapse. |
| 458 | // |
| 459 | // A single unreadable bundle is skipped and logged rather than blanking the |
| 460 | // list: the other environments are real, their instances are really bound to |
| 461 | // them, and their stores are really billing. Same trade storeEnvCards makes, |
| 462 | // for the same reason. |
| 463 | func (s *Server) envCards(ctx context.Context, acct *Account, app string, routes []RouteView) ([]*EnvCard, bool) { |
| 464 | names, err := appconfig.ListEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) |
| 465 | if err != nil { |
| 466 | log.Printf("console: settings environments %s/%s: %v", acct.Sqid, app, err) |
| 467 | return nil, true |
| 468 | } |
| 469 | cards := make([]*EnvCard, 0, len(names)) |
| 470 | for _, name := range names { |
| 471 | m, merr := appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, name) |
| 472 | if merr != nil { |
| 473 | log.Printf("console: settings environment %s/%s/%s: %v", acct.Sqid, app, name, merr) |
| 474 | continue |
| 475 | } |
| 476 | card := &EnvCard{Name: name} |
| 477 | varNames := make([]string, 0, len(m.Vars)) |
| 478 | for k := range m.Vars { |
| 479 | varNames = append(varNames, k) |
| 480 | } |
| 481 | sort.Strings(varNames) |
| 482 | for _, k := range varNames { |
| 483 | card.Vars = append(card.Vars, EnvVarName{Name: k, Secret: m.Vars[k].Kind == "secret"}) |
| 484 | } |
| 485 | for _, rt := range routes { |
| 486 | if rt.Environment == name { |
| 487 | card.Hosts = append(card.Hosts, rt.Host) |
| 488 | } |
| 489 | } |
| 490 | // The store is read through the same door the storage section uses, |
| 491 | // so the two sections cannot disagree about whether one exists. |
| 492 | if rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, name); rerr != nil { |
| 493 | log.Printf("console: settings environment store %s/%s/%s: %v", acct.Sqid, app, name, rerr) |
| 494 | } else if rec != nil { |
| 495 | card.HasStore = true |
| 496 | switch { |
| 497 | case rec.Provisioned != nil && rec.Provisioned.Bucket != "": |
| 498 | card.StoreBucket = rec.Provisioned.Bucket |
| 499 | case rec.Grant != nil: |
| 500 | card.StoreBucket = rec.Grant.Bucket |
| 501 | } |
| 502 | } |
| 503 | cards = append(cards, card) |
| 504 | } |
| 505 | return cards, false |
| 506 | } |
| 507 | |
| 508 | // storeEnvCards is one card per environment store, in the order |
| 509 | // StoreEnvironments returns them (sorted). nil on a read failure, matching |
| 510 | // storeCard's own discipline: the page says nothing rather than claiming an |
| 511 | // app has no environment stores when it could not find out. |
| 512 | // |
| 513 | // A single unreadable record does NOT blank the whole list — the others are |
| 514 | // real and their buckets are real — so it is skipped, logged, and the rest |
| 515 | // render. That is the opposite trade to storeCard's, and deliberately: |
| 516 | // there, a nil is the difference between "no store" and "unknown"; here, |
| 517 | // dropping every card because one is malformed would hide estate that is |
| 518 | // perfectly legible. |
| 519 | func (s *Server) storeEnvCards(ctx context.Context, acct *Account, app string) []*StoreCard { |
| 520 | envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) |
| 521 | if err != nil { |
| 522 | log.Printf("console: settings store environments %s/%s: %v", acct.Sqid, app, err) |
| 523 | return nil |
| 524 | } |
| 525 | cards := make([]*StoreCard, 0, len(envs)) |
| 526 | for _, env := range envs { |
| 527 | rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) |
| 528 | if rerr != nil { |
| 529 | log.Printf("console: settings store read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), rerr) |
| 530 | continue |
| 531 | } |
| 532 | if rec == nil { |
| 533 | continue |
| 534 | } |
| 535 | cards = append(cards, s.storeCardFor(rec, env)) |
| 536 | } |
| 537 | return cards |
| 538 | } |
| 539 | |
| 540 | // storeCardFor maps one record to the card the settings page renders. Shared |
| 541 | // by both callers so the app's own store and an environment's can never |
| 542 | // disagree about what a grant, a provisioning or a half-finished rotation |
| 543 | // looks like. |
| 544 | func (s *Server) storeCardFor(rec *objstore.Record, env string) *StoreCard { |
| 545 | card := &StoreCard{Environment: env} |
| 546 | if rec.Declared != nil { |
| 547 | card.Declared = true |
| 548 | card.EnvPrefix = rec.Declared.EnvPrefix |
| 549 | } |
| 550 | if g := rec.Grant; g != nil { |
| 551 | card.Granted = true |
| 552 | card.Backend = g.Backend |
| 553 | card.Bucket = g.Bucket |
| 554 | } |
| 555 | if pr := rec.Provisioned; pr != nil { |
| 556 | card.Provisioned = true |
| 557 | if pr.Bucket != "" { |
| 558 | // The provisioned block wins: the bucket an app's data is in is |
| 559 | // whatever was recorded at provision time, which is also the word |
| 560 | // the destroy door will make an owner type. |
| 561 | card.Bucket = pr.Bucket |
| 562 | } |
| 563 | card.KeyID = pr.KeyID |
| 564 | if !pr.At.IsZero() { |
| 565 | card.KeyAge = humanAge(time.Since(pr.At)) + " ago" |
| 566 | } |
| 567 | card.PendingFinish = pr.PrevKeyID != "" |
| 568 | } |
| 569 | if card.Granted || card.Provisioned { |
| 570 | card.CanDestroy = s.cfg.StoreProvisioner != nil |
| 571 | } else { |
| 572 | card.CanDestroy = true // a declaration: nothing outside the bucket to tear down |
| 573 | } |
| 574 | return card |
| 575 | } |
| 576 | |
| 577 | // mailCard folds the tenant-email record into the Settings card. Wears |
| 578 | // storeCard's exact error discipline: a read failure returns nil — the page |
| 579 | // says nothing about email at all rather than guessing — and an absent |
| 580 | // record is a zero card, the shape the empty-state line renders from. |
| 581 | // |
| 582 | // Available is set FIRST, unconditionally, because it must be correct |
| 583 | // whether or not there turns out to be a record: it is what the template |
| 584 | // uses to choose between the two empty-state sentences the brief calls for |
| 585 | // (one names a command, the other says this deployment has no email at |
| 586 | // all), and a bucket read failure must not be allowed to hide which one |
| 587 | // applies — hence it is read straight off s.cfg.MailProvisioner before the |
| 588 | // record read even starts, and is the one field a nil return also throws |
| 589 | // away, same as every other field storeCard discards on its own read error. |
| 590 | func (s *Server) mailCard(ctx context.Context, acct *Account, app string) *MailCard { |
| 591 | available := s.cfg.MailProvisioner != nil |
| 592 | rec, _, err := mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| 593 | if err != nil { |
| 594 | log.Printf("console: settings mail read %s/%s: %v", acct.Sqid, app, err) |
| 595 | return nil |
| 596 | } |
| 597 | if rec == nil { |
| 598 | return &MailCard{Available: available} |
| 599 | } |
| 600 | card := &MailCard{Available: available} |
| 601 | if d := rec.Declared; d != nil { |
| 602 | card.Declared = true |
| 603 | card.EnvPrefix = d.EnvPrefix |
| 604 | card.From = d.From |
| 605 | } |
| 606 | if g := rec.Grant; g != nil { |
| 607 | card.Granted = true |
| 608 | card.Domains = g.Domains |
| 609 | card.DailyCap = g.DailyCap |
| 610 | } |
| 611 | if pr := rec.Provisioned; pr != nil { |
| 612 | card.Provisioned = true |
| 613 | card.KeyID = pr.KeyID |
| 614 | if !pr.At.IsZero() { |
| 615 | card.KeyAge = humanAge(time.Since(pr.At)) + " ago" |
| 616 | } |
| 617 | card.PendingFinish = pr.PrevKeyID != "" |
| 618 | // Active is the SENDING state, not the record's completeness — a |
| 619 | // provisioned-but-paused app has a credential, just one that must |
| 620 | // send nothing right now, which is exactly the sentence the page |
| 621 | // has to make impossible to miss. |
| 622 | card.Paused = !pr.Active |
| 623 | card.PauseKind = pr.PauseKind |
| 624 | } |
| 625 | for _, sa := range rec.Standalone { |
| 626 | row := MailStandaloneView{Name: sa.Name, KeyID: sa.KeyID} |
| 627 | if !sa.At.IsZero() { |
| 628 | row.Age = humanAge(time.Since(sa.At)) + " ago" |
| 629 | } |
| 630 | card.Standalone = append(card.Standalone, row) |
| 631 | } |
| 632 | return card |
| 633 | } |
| 634 | |
| 635 | // settingsDomains is the Settings page's own Domains & TLS rows: routed |
| 636 | // (the AppView's own DomainView slice) plus a placeholder row for any of |
| 637 | // this app's domain claims that routed has no entry for yet — and, for every |
| 638 | // claim-backed row, the derived lifecycle state. |
| 639 | // |
| 640 | // The state is the point. Until it was threaded here the Settings page was |
| 641 | // the last first-party surface still telling the pre-#299 story: it read |
| 642 | // THIS box's own registry copy of a route and printed a DNS word and a TLS |
| 643 | // word, while `carlos domains list` — reading the fleet-wide sweep — could |
| 644 | // be saying something else about the same hostname at the same moment. Both |
| 645 | // now come from domainState(), over the same inputs. |
| 646 | // |
| 647 | // Best-effort like the rest of this file. A claim-list read failure is |
| 648 | // logged and the page falls back to routed alone; a readings or warm-record |
| 649 | // failure leaves the state empty and the row renders exactly as it did |
| 650 | // before. Neither is ever a hard error — a settings page that 500s because a |
| 651 | // telemetry prefix was briefly unreadable would be a worse answer than one |
| 652 | // that shows a little less. |
| 653 | func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, routed []DomainView) []DomainView { |
| 654 | out := append([]DomainView(nil), routed...) |
| 655 | if s.cfg.Releases == nil { |
| 656 | return out |
| 657 | } |
| 658 | have := make(map[string]int, len(out)) |
| 659 | for i, d := range out { |
| 660 | have[strings.ToLower(d.Domain)] = i |
| 661 | } |
| 662 | claims, err := s.claims.List(ctx, s.cfg.Releases) |
| 663 | if err != nil { |
| 664 | log.Printf("console: settings claims %s/%s: %v", acct.Sqid, app, err) |
| 665 | return out |
| 666 | } |
| 667 | // Folded ONCE for the page, not once per row: readings is every live |
| 668 | // box's alias stamps, warm is one object per hostname currently failing |
| 669 | // to get a certificate (usually none at all). |
| 670 | now := time.Now() |
| 671 | readings := aliasReadings(ctx, s.cfg.Releases, now) |
| 672 | warm := warmRecords(ctx, s.cfg.Releases) |
| 673 | state := func(c domains.Claim) (st, reason, detail string) { |
| 674 | reading, seen := readings[c.Host] |
| 675 | return claimState(c, reading, seen, warm[c.Host], now) |
| 676 | } |
| 677 | claimed := make(map[string]bool, len(claims)) |
| 678 | for _, c := range claims { |
| 679 | claimed[c.Host] = true |
| 680 | } |
| 681 | for _, c := range claims { |
| 682 | if c.Account != acct.Sqid || c.App != app { |
| 683 | continue |
| 684 | } |
| 685 | if c.Instances { |
| 686 | out = append(out, s.catchallDomainViews(ctx, acct, app, c, claimed, readings, warm, now)...) |
| 687 | continue |
| 688 | } |
| 689 | if i, ok := have[strings.ToLower(c.Host)]; ok { |
| 690 | out[i].Claimed = true |
| 691 | out[i].State, out[i].Reason, out[i].Detail = state(c) |
| 692 | if c.Pending { |
| 693 | out[i].Guidance = verifyGuidance(s.domainChallenge(c.Host, c.Account)) |
| 694 | } |
| 695 | continue |
| 696 | } |
| 697 | dv := claimDomainView(c.Host, s.dnsTargets()) |
| 698 | dv.State, dv.Reason, dv.Detail = state(c) |
| 699 | if c.Pending { |
| 700 | // The proof comes first: until it is published nothing else on |
| 701 | // the row can take effect (#215). |
| 702 | dv.Guidance = verifyGuidance(s.domainChallenge(c.Host, c.Account)) + " " + dv.Guidance |
| 703 | } |
| 704 | out = append(out, dv) |
| 705 | } |
| 706 | return out |
| 707 | } |
| 708 | |
| 709 | // appDeployEmpty reports whether the bucket holds ANYTHING under the app's |
| 710 | // prefix — account-qualified or legacy-unqualified. One key is enough to |
| 711 | // refuse a rename; the caller never needs the listing itself. |
| 712 | func (s *Server) appDeployEmpty(ctx context.Context, acct, app string) (bool, error) { |
| 713 | for _, prefix := range []string{release.AppPrefix(acct, app), release.LegacyAppPrefix(app)} { |
| 714 | keys, err := s.cfg.Releases.List(ctx, prefix) |
| 715 | if err != nil { |
| 716 | return false, err |
| 717 | } |
| 718 | if len(keys) > 0 { |
| 719 | return false, nil |
| 720 | } |
| 721 | } |
| 722 | return true, nil |
| 723 | } |
| 724 | |
| 725 | // appHasRoutes reports whether ANY registry route wears the name — any |
| 726 | // account's. Deliberately wider than the tenant boundary: a rename must not |
| 727 | // free a name some other tenant's route still resolves by. |
| 728 | func (s *Server) appHasRoutes(app string) (bool, error) { |
| 729 | reg, err := openRegistry(s.cfg.Registry) |
| 730 | if err != nil { |
| 731 | return false, err |
| 732 | } |
| 733 | routes, err := reg.List() |
| 734 | if err != nil { |
| 735 | return false, err |
| 736 | } |
| 737 | for _, rt := range routes { |
| 738 | if rt.App == app { |
| 739 | return true, nil |
| 740 | } |
| 741 | } |
| 742 | return false, nil |
| 743 | } |
| 744 | |
| 745 | // handleAppRename renames a never-deployed app. Owner's, behind the confirm |
| 746 | // card, without the passkey: nothing outside this console's database changes, |
| 747 | // and the refusals above guarantee exactly that. |
| 748 | func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) { |
| 749 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 750 | if !ok { |
| 751 | return |
| 752 | } |
| 753 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 754 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 755 | return |
| 756 | } |
| 757 | if role != RoleOwner { |
| 758 | http.NotFound(w, r) |
| 759 | return |
| 760 | } |
| 761 | back := func(kind, msg string) { |
| 762 | s.setFlash(w, kind, msg) |
| 763 | http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther) |
| 764 | } |
| 765 | // The control plane keeps its name (controlplane.go): the fleet's release |
| 766 | // keys, its channels and the units that exec them are all written in it. |
| 767 | // Refused here, straight after the owner gate and before the confirm card |
| 768 | // is ever rendered — a card that cannot be confirmed is worse than no card. |
| 769 | if msg, _ := s.controlPlaneRefusal(acct, app, "renamed"); msg != "" { |
| 770 | back("err", msg) |
| 771 | return |
| 772 | } |
| 773 | // Host-claim consoles refuse every rename, recordless or not, before |
| 774 | // any lock, registration or write (claimRenameUnsupportedMsg). |
| 775 | if s.cfg.HostClaim { |
| 776 | s.renderMessage(w, r, http.StatusConflict, "Not available", claimRenameUnsupportedMsg) |
| 777 | return |
| 778 | } |
| 779 | defer s.lockApp(acct.Sqid, app)() |
| 780 | if msg, _ := s.temporaryRefusal(acct, app, "rename it"); msg != "" { |
| 781 | back("err", msg) |
| 782 | return |
| 783 | } |
| 784 | // Normalized exactly as the create doors normalize (normalizeAppName), |
| 785 | // and refused exactly where claimApp refuses (reservedAppName): "new" is |
| 786 | // the create form's own literal route, and an app renamed onto it would |
| 787 | // be unreachable — the mux prefers the literal, so no console page could |
| 788 | // ever show it; consoleMailApp in the ops account is the console's own |
| 789 | // mail referrer. |
| 790 | to := normalizeAppName(r.FormValue("to")) |
| 791 | if !release.ValidApp(to) { |
| 792 | s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", |
| 793 | "App names are lowercase letters, digits and dashes.", map[string]string{"rename": r.FormValue("to")}) |
| 794 | return |
| 795 | } |
| 796 | if reservedAppName(acct, to) { |
| 797 | s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", |
| 798 | "That name is reserved.", map[string]string{"rename": r.FormValue("to")}) |
| 799 | return |
| 800 | } |
| 801 | if release.IsTemporaryName(to) { |
| 802 | s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", |
| 803 | "Names starting with tmp- are kept for temporary sites. Choose another name.", |
| 804 | map[string]string{"rename": r.FormValue("to")}) |
| 805 | return |
| 806 | } |
| 807 | if to == app { |
| 808 | s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", |
| 809 | "That is already this app's name.", map[string]string{"rename": r.FormValue("to")}) |
| 810 | return |
| 811 | } |
| 812 | if s.cfg.Releases == nil { |
| 813 | back("err", "No deployment bucket on this host, so the never-shipped check cannot run — renaming is refused.") |
| 814 | return |
| 815 | } |
| 816 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 817 | defer cancel() |
| 818 | // The two wider stores that know app names, checked at action time — the |
| 819 | // Settings view's CanRename was advice, this is the refusal. Both fail |
| 820 | // CLOSED: "could not check" is not "empty". |
| 821 | switch empty, err := s.appDeployEmpty(ctx, acct.Sqid, app); { |
| 822 | case err != nil: |
| 823 | back("err", "Could not check the deployment bucket — nothing was renamed. Try again.") |
| 824 | return |
| 825 | case !empty: |
| 826 | 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.") |
| 827 | return |
| 828 | } |
| 829 | switch wired, err := s.appHasRoutes(app); { |
| 830 | case err != nil: |
| 831 | back("err", "Could not check the routes — nothing was renamed. Try again.") |
| 832 | return |
| 833 | case wired: |
| 834 | back("err", "A host still routes this name — remove its routes first.") |
| 835 | return |
| 836 | } |
| 837 | // The new name's platform apex must not already be another app's (apex.go). |
| 838 | switch hold, err := s.apexHolder(ctx, acct, to); { |
| 839 | case err != nil: |
| 840 | back("err", "Could not check "+to+"'s platform address — nothing was renamed. Try again.") |
| 841 | return |
| 842 | case hold != nil: |
| 843 | back("err", "Cannot rename to "+to+": "+apexHeldSentence(s.apexHost(acct.Sqid, to), hold)+".") |
| 844 | return |
| 845 | } |
| 846 | if !confirmed(r) { |
| 847 | cv := &ConfirmView{ |
| 848 | Title: "Rename " + app + " to " + to + "?", |
| 849 | Lines: []string{ |
| 850 | app + " has never shipped, so nothing outside this console knows the name yet.", |
| 851 | "After this, the app is " + to + " — commands, pages and its future addresses all say so.", |
| 852 | }, |
| 853 | Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rename", |
| 854 | Fields: [][2]string{ |
| 855 | {"csrf", raw}, {"to", to}, |
| 856 | }, |
| 857 | Button: "Rename", |
| 858 | CancelTab: "settings", |
| 859 | } |
| 860 | s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| 861 | return |
| 862 | } |
| 863 | if err := s.store.RenameAppIn(acct.ID, app, to); err != nil { |
| 864 | log.Printf("console: rename %s/%s -> %s: %v", acct.Sqid, app, to, err) |
| 865 | back("err", renameAdvice(err, to)) |
| 866 | return |
| 867 | } |
| 868 | // The emptiness checks above cannot join the database transaction — the |
| 869 | // bucket and the registry are other stores — so a `carlos ship` or |
| 870 | // `carlos add` racing this request can land under the old name between |
| 871 | // check and commit. Re-checking AFTER the commit closes most of that |
| 872 | // window: on detection the rename is undone and the human told, which |
| 873 | // converges toward the safe state (the console record naming what the |
| 874 | // bucket and routes name) instead of silently splitting the identity. |
| 875 | stale := false |
| 876 | if empty, err := s.appDeployEmpty(ctx, acct.Sqid, app); err == nil && !empty { |
| 877 | stale = true |
| 878 | } |
| 879 | if wired, err := s.appHasRoutes(app); err == nil && wired { |
| 880 | stale = true |
| 881 | } |
| 882 | // And the new name's apex, which an instance racing this rename can take |
| 883 | // between the check above and the commit (apex.go). |
| 884 | if hold, err := s.apexHolder(ctx, acct, to); err == nil && hold != nil { |
| 885 | stale = true |
| 886 | } |
| 887 | if stale { |
| 888 | if err := s.store.UndoRenameAppIn(acct.ID, to, app); err != nil { |
| 889 | 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) |
| 890 | 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.") |
| 891 | return |
| 892 | } |
| 893 | back("err", "A ship, a route, or an instance at "+to+"'s address landed while renaming — the rename was undone; the name stays.") |
| 894 | return |
| 895 | } |
| 896 | s.audit(acct, to, addr, "rename", "renamed "+app+" to "+to) |
| 897 | s.setFlash(w, "ok", "Renamed "+app+" to "+to+".") |
| 898 | http.Redirect(w, r, appSettingsURL(acct.Sqid, to, "general"), http.StatusSeeOther) |
| 899 | } |
| 900 | |
| 901 | // renameAdvice maps RenameAppIn's refusals onto the sentence a person can |
| 902 | // act on, without leaking storage details they cannot. |
| 903 | func renameAdvice(err error, to string) string { |
| 904 | msg := err.Error() |
| 905 | switch { |
| 906 | case errors.Is(err, ErrTemporaryApp): |
| 907 | return "This app is temporary, so it cannot be renamed. Make it permanent first with carlos apps keep." |
| 908 | case errors.Is(err, ErrTemporaryNameReserved): |
| 909 | return "Names starting with tmp- are kept for temporary sites. Choose another name." |
| 910 | case strings.Contains(msg, "already has an app"): |
| 911 | return "This account already has an app (or a trashed app) named " + to + "." |
| 912 | case strings.Contains(msg, "legacy owner grant"): |
| 913 | return "One of these names carries a legacy grant — renaming across it is an operator act." |
| 914 | case strings.Contains(msg, "app credentials"): |
| 915 | return to + " still has app credentials from a previous app of that name — revoke them on the tokens page first." |
| 916 | default: |
| 917 | return "Could not rename — nothing changed. Try again." |
| 918 | } |
| 919 | } |
| 920 | |
| 921 | // handleAppGate is the Settings page's password-gate form: set a password |
| 922 | // on one instance host, or turn it off. Member-gated, matching the API |
| 923 | // PATCH's door — the CLI already lets a member do this, and a form that |
| 924 | // refuses what the CLI allows is a rule in the wrong place. CSRF first, |
| 925 | // then the same read-mutate-validate-write-audit sequence as the PATCH, |
| 926 | // against one field. The password is never logged, audited or echoed. |
| 927 | func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) { |
| 928 | addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| 929 | if !ok { |
| 930 | return |
| 931 | } |
| 932 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 933 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 934 | return |
| 935 | } |
| 936 | back := func(kind, msg string) { |
| 937 | s.setFlash(w, kind, msg) |
| 938 | http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), |
| 939 | http.StatusSeeOther) |
| 940 | } |
| 941 | if s.cfg.Releases == nil { |
| 942 | back("err", "No deployment bucket on this host, so the gate cannot be changed here.") |
| 943 | return |
| 944 | } |
| 945 | host := strings.TrimSpace(r.FormValue("host")) |
| 946 | if !provision.ValidHostShape(host) { |
| 947 | http.NotFound(w, r) |
| 948 | return |
| 949 | } |
| 950 | var hash string |
| 951 | switch r.FormValue("action") { |
| 952 | case "clear": |
| 953 | case "set": |
| 954 | p := r.FormValue("password") |
| 955 | if err := gate.CheckPassword(p); err != nil { |
| 956 | back("err", err.Error()+".") |
| 957 | return |
| 958 | } |
| 959 | h, err := gate.Hash(p) |
| 960 | if err != nil { |
| 961 | log.Printf("console: gate hash %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 962 | back("err", "Could not set the password — try again.") |
| 963 | return |
| 964 | } |
| 965 | hash = h |
| 966 | default: |
| 967 | back("err", "Choose set or clear.") |
| 968 | return |
| 969 | } |
| 970 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 971 | defer cancel() |
| 972 | rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| 973 | if errors.Is(err, os.ErrNotExist) { |
| 974 | http.NotFound(w, r) |
| 975 | return |
| 976 | } |
| 977 | if err != nil { |
| 978 | log.Printf("console: gate read %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 979 | back("err", "Could not read the instance — try again.") |
| 980 | return |
| 981 | } |
| 982 | rec.Gate = hash |
| 983 | if err := provision.ValidateShape(*rec); err != nil { |
| 984 | back("err", err.Error()+".") |
| 985 | return |
| 986 | } |
| 987 | if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| 988 | if errors.Is(err, release.ErrCASConflict) { |
| 989 | back("err", "The instance changed while this request was in flight — try again.") |
| 990 | return |
| 991 | } |
| 992 | log.Printf("console: gate write %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 993 | back("err", "Could not update the instance — try again.") |
| 994 | return |
| 995 | } |
| 996 | if hash == "" { |
| 997 | s.audit(acct, app, addr, "gate", "cleared password gate on "+host+" (console)") |
| 998 | s.bumpSerial(ctx) |
| 999 | back("ok", "Password turned off on "+host+".") |
| 1000 | return |
| 1001 | } |
| 1002 | s.audit(acct, app, addr, "gate", "set password gate on "+host+" (console)") |
| 1003 | s.bumpSerial(ctx) |
| 1004 | back("ok", "Password set on "+host+".") |
| 1005 | } |
| 1006 | |
| 1007 | // handleAppDelete puts the app in the trash: the deletion-policy marker in |
| 1008 | // the bucket, then this console's row — the same order and the same clock |
| 1009 | // discipline as the API door (handleAPITrashApp), behind the account-owner |
| 1010 | // ceremony: type the name, then the passkey. Restorable from the Trash for |
| 1011 | // the cooloff; hosts drop the app's routes on their next reconcile pass. |
| 1012 | func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) { |
| 1013 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 1014 | if !ok { |
| 1015 | return |
| 1016 | } |
| 1017 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 1018 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 1019 | return |
| 1020 | } |
| 1021 | if role != RoleOwner { |
| 1022 | http.NotFound(w, r) |
| 1023 | return |
| 1024 | } |
| 1025 | back := func(kind, msg string) { |
| 1026 | s.setFlash(w, kind, msg) |
| 1027 | http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "delete"), http.StatusSeeOther) |
| 1028 | } |
| 1029 | // Outright, before the typed name and long before the passkey: the flag |
| 1030 | // is the guard, and no gesture answers it (controlplane.go). |
| 1031 | if msg, _ := s.controlPlaneRefusal(acct, app, "trashed"); msg != "" { |
| 1032 | back("err", msg) |
| 1033 | return |
| 1034 | } |
| 1035 | // Typing the name is a "did you mean this app?" control, not a security |
| 1036 | // one — checked before step-up so a slip does not cost a passkey touch. |
| 1037 | if strings.TrimSpace(r.FormValue("confirm")) != app { |
| 1038 | back("err", "Type the app's name exactly to delete it.") |
| 1039 | return |
| 1040 | } |
| 1041 | if s.cfg.Releases == nil { |
| 1042 | back("err", "No deployment bucket on this host — this console cannot delete an app.") |
| 1043 | return |
| 1044 | } |
| 1045 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 1046 | return |
| 1047 | } |
| 1048 | ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| 1049 | defer cancel() |
| 1050 | // Every transition takes the app's lock and settles first (spec §2, §5). |
| 1051 | // The checks above ran before the lock: ask again. An app already in the |
| 1052 | // Trash with its marker falls through to the idempotent path below. |
| 1053 | defer s.lockApp(acct.Sqid, app)() |
| 1054 | if _, ref := s.recheckUnderLock(ctx, acct, app, "trashed", false, time.Now()); ref != nil { |
| 1055 | switch ref.Kind { |
| 1056 | case lockedGone: |
| 1057 | back("err", app+" no longer exists.") |
| 1058 | case lockedControlPlane: |
| 1059 | back("err", ref.Msg) |
| 1060 | default: |
| 1061 | back("err", "Could not confirm the trash marker. Nothing was changed. Try again.") |
| 1062 | } |
| 1063 | return |
| 1064 | } |
| 1065 | // Production outranks deletion, and an unreadable flag IS production — |
| 1066 | // the deletion-policy reconciler will refuse the marker anyway, so |
| 1067 | // writing one would only manufacture disagreement. |
| 1068 | flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| 1069 | if err != nil { |
| 1070 | log.Printf("console: trash %s/%s: production flag: %v", acct.Sqid, app, err) |
| 1071 | 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.") |
| 1072 | return |
| 1073 | } |
| 1074 | if flag != nil && flag.Production { |
| 1075 | 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.") |
| 1076 | return |
| 1077 | } |
| 1078 | now := time.Now() |
| 1079 | if err := release.TrashApp(ctx, s.cfg.Releases, acct.Sqid, app, addr, now); err != nil { |
| 1080 | log.Printf("console: trash %s/%s: %v", acct.Sqid, app, err) |
| 1081 | back("err", "Could not write the trash marker — nothing was deleted. Try again.") |
| 1082 | return |
| 1083 | } |
| 1084 | // The marker's clock is the feature's one clock: TrashApp is create-only, |
| 1085 | // so a racing or repeated delete keeps the ORIGINAL DeletedAt, and the |
| 1086 | // row must carry that, not this request's time. A re-read that fails |
| 1087 | // therefore HARD-FAILS with nothing written to the row — the same rule as |
| 1088 | // the API door: a row stamped with this request's clock would promise a |
| 1089 | // restore deadline the purge pass does not honor, and it never self-heals |
| 1090 | // (materialize short-circuits on a trashed row). The marker is already |
| 1091 | // written, so a retry simply finishes the job. |
| 1092 | tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app) |
| 1093 | if err != nil || tomb == nil { |
| 1094 | log.Printf("console: trash %s/%s: re-read marker: %v", acct.Sqid, app, err) |
| 1095 | 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.") |
| 1096 | return |
| 1097 | } |
| 1098 | deletedAt := tomb.DeletedAt |
| 1099 | if err := s.store.TrashAppRowContext(r.Context(), acct.ID, app, deletedAt); err != nil { |
| 1100 | log.Printf("console: trash row %s/%s: %v", acct.Sqid, app, err) |
| 1101 | back("err", "The trash marker was written, but this console's record did not update — reload; if the app still shows, try deleting again.") |
| 1102 | return |
| 1103 | } |
| 1104 | s.audit(acct, app, addr, "trash", "moved "+app+" to the trash") |
| 1105 | // Box-consumed (carlos-deploy §3), symmetric with the web restore door |
| 1106 | // (ownership.go, handleRestoreApp): the trash marker is what stops |
| 1107 | // instance minting and drops the app's routes (provision/reconcile.go, |
| 1108 | // domains/reconcile.go). Without the bump a trash through this door sits |
| 1109 | // invisible to the fleet until the hourly floor comes round, while |
| 1110 | // restore already announces in seconds. |
| 1111 | s.bumpSerial(ctx) |
| 1112 | // One deadline, everywhere (spec §5): same computation as the API door. |
| 1113 | until := deletedAt.Add(release.TemporaryCooloff) |
| 1114 | if life, lerr := s.store.AppLife(acct.ID, app); lerr == nil && life != nil { |
| 1115 | until = effectivePurgeAt(life.DeletedAt, life.PurgeAfter) |
| 1116 | } else if lerr != nil { |
| 1117 | log.Printf("console: trash %s/%s: read back the deadline: %v", acct.Sqid, app, lerr) |
| 1118 | } |
| 1119 | s.setFlash(w, "ok", "Moved "+app+" to the trash. You can restore it there until "+ |
| 1120 | until.UTC().Format("2006-01-02 15:04 UTC")+". Hosts stop serving it on their next pass.") |
| 1121 | http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther) |
| 1122 | } |
| 1123 | |
| 1124 | // handleStoreDestroy dismantles an app's object store (issue #55, objstore spec |
| 1125 | // §9): the bucket's contents, the bucket, the IAM principal holding its key, |
| 1126 | // the delivered variables, and the record naming them — in that order, last |
| 1127 | // first, so a failure anywhere leaves the record still naming a standing |
| 1128 | // estate. |
| 1129 | // |
| 1130 | // It is the sibling of handlePurgeApp (ownership.go) and wears the same |
| 1131 | // ceremony, in the same order handleAppDelete above wears it — session, CSRF, |
| 1132 | // owner's byte-identical 404, the typed confirmation, and requireFreshAuth |
| 1133 | // LAST, so no redirect into Keymail ever happens for someone who should have |
| 1134 | // got a 404: |
| 1135 | // |
| 1136 | // owner — a member probing this gets a stranger's answer. |
| 1137 | // CSRF — constant-time, before any pending action can be parked. |
| 1138 | // control plane — refused outright, before the typed word and before the |
| 1139 | // passkey: this ceremony is exactly the one spec §6 rules |
| 1140 | // insufficient for the app that IS the platform. |
| 1141 | // type the BUCKET — not the app's name: the bucket is the thing that stops |
| 1142 | // existing, and its objects are what cannot be brought |
| 1143 | // back. A declared-only record has no bucket, so there the |
| 1144 | // word is the app's name — the only noun it has. |
| 1145 | // no backend — refused here, before the passkey, on an estate this |
| 1146 | // console has no authority over: no gesture could change |
| 1147 | // the answer, so asking for one would waste it. |
| 1148 | // requireFreshAuth — LAST of the auth gates. |
| 1149 | // |
| 1150 | // There is no trash for a store and no undo: the objects are gone, and the |
| 1151 | // credentials with them. Teardown itself re-reads the record immediately before |
| 1152 | // the final delete and refuses (wrapping release.ErrCASConflict) if it changed |
| 1153 | // underneath — a grant landing while this form sat open wins, exactly as a |
| 1154 | // restore wins over a purge. |
| 1155 | func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) { |
| 1156 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 1157 | if !ok { |
| 1158 | return |
| 1159 | } |
| 1160 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 1161 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 1162 | return |
| 1163 | } |
| 1164 | if role != RoleOwner { |
| 1165 | http.NotFound(w, r) |
| 1166 | return |
| 1167 | } |
| 1168 | // The local refusal: a flash and the settings tab, mirroring trashFailure's |
| 1169 | // shape for the Trash page. Not a 404 — everyone reaching it has passed the |
| 1170 | // ownership gate and is entitled to know what went wrong. |
| 1171 | fail := func(msg string) { |
| 1172 | s.setFlash(w, "err", msg) |
| 1173 | http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "storage"), http.StatusSeeOther) |
| 1174 | } |
| 1175 | // The sixth destructive door, and the one whose ceremony is closest to |
| 1176 | // enough without being it: owner, CSRF, the typed BUCKET name and a |
| 1177 | // passkey — exactly the ceremony spec §6 rules insufficient for the |
| 1178 | // control plane, over an estate whose own doc says there is no trash and |
| 1179 | // no undo. A guard the neighbouring door walks around reads as protection |
| 1180 | // without being it (controlplane.go). |
| 1181 | // |
| 1182 | // The refusal is on DESTROYING the store, not on having one: a |
| 1183 | // control-plane app may hold an object store like any other app, and |
| 1184 | // forbidding that would be an invented constraint the spec does not make. |
| 1185 | if msg, _ := s.controlPlaneRefusal(acct, app, "destroyed"); msg != "" { |
| 1186 | fail(msg) |
| 1187 | return |
| 1188 | } |
| 1189 | if s.cfg.Releases == nil { |
| 1190 | fail("No deployment bucket on this host, so this console cannot read the store record — nothing was destroyed.") |
| 1191 | return |
| 1192 | } |
| 1193 | // storeTeardownTimeout, not storeTimeout: the walk below is many round trips |
| 1194 | // (EmptyBucket is paginated for exactly the bucket too big to clear in one |
| 1195 | // call), and a 15s ceiling documented for a single round trip would cut a |
| 1196 | // large store's teardown off mid-walk and report it as an unreachable |
| 1197 | // backend. |
| 1198 | ctx, cancel := context.WithTimeout(r.Context(), storeTeardownTimeout) |
| 1199 | defer cancel() |
| 1200 | // Taken BEFORE the read, not just around the walk: the confirm word and |
| 1201 | // the estate check below are both derived from this record, and a walk |
| 1202 | // landing between reading it and tearing it down would make both stale — |
| 1203 | // the shape objstore's deleteRecordIfUnchanged is guarding against from |
| 1204 | // the other end (objstore/walk.go:560). |
| 1205 | // Which store: the app's own, or one belonging to a named config |
| 1206 | // environment. The form carries it; an unknown or malformed name reads |
| 1207 | // back as no record at all, which is the honest answer to "destroy the |
| 1208 | // store that isn't there" and the same one a tampered field gets. |
| 1209 | env := strings.TrimSpace(r.FormValue("environment")) |
| 1210 | defer s.storeWalks.lock(acct.Sqid, app, env)() |
| 1211 | rec, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) |
| 1212 | if err != nil { |
| 1213 | log.Printf("console: store destroy read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), err) |
| 1214 | fail("Could not read “" + app + "”'s object-store record — nothing was destroyed. Try again.") |
| 1215 | return |
| 1216 | } |
| 1217 | if rec == nil { |
| 1218 | if env != "" { |
| 1219 | fail("“" + app + "” has no object store for the " + env + " environment, so there is nothing to destroy.") |
| 1220 | return |
| 1221 | } |
| 1222 | fail("“" + app + "” has no object store, so there is nothing to destroy.") |
| 1223 | return |
| 1224 | } |
| 1225 | want := storeConfirmWord(rec, app) |
| 1226 | if strings.TrimSpace(r.FormValue("confirm")) != want { |
| 1227 | fail("Type “" + want + "” exactly to destroy this store. Nothing was destroyed.") |
| 1228 | return |
| 1229 | } |
| 1230 | // Grant OR Provisioned is an estate: a grant block on its own is a grant |
| 1231 | // that crashed after creating the bucket and the key but before recording |
| 1232 | // them (objstore.Teardown's own rule), and tearing that down is backend |
| 1233 | // work like any other. |
| 1234 | estate := rec.Grant != nil || rec.Provisioned != nil |
| 1235 | if estate && s.cfg.StoreProvisioner == nil { |
| 1236 | fail("This console has no storage backend configured (CARLOS_STORE_BACKEND), so it cannot " + |
| 1237 | "delete “" + app + "”'s bucket or its credentials — nothing was destroyed. An operator of " + |
| 1238 | "this deployment has to tear it down.") |
| 1239 | return |
| 1240 | } |
| 1241 | // The same shape for the scanner. objstore.Teardown refuses a store whose |
| 1242 | // scanning is enabled when it has no engine to release it with — without |
| 1243 | // this pre-check that refusal arrives as the generic "Could not destroy" |
| 1244 | // flash below, which tells the member nothing they can act on. |
| 1245 | if rec.Scanning != nil && rec.Scanning.Enabled != nil && s.cfg.StoreScanner == nil { |
| 1246 | fail("“" + app + "” has virus scanning enabled and this console has no scanner configured " + |
| 1247 | "(CARLOS_STORE_SCANNER), so it cannot release the scanning engine — nothing was destroyed. " + |
| 1248 | "An operator of this deployment has to tear it down.") |
| 1249 | return |
| 1250 | } |
| 1251 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 1252 | return |
| 1253 | } |
| 1254 | // R3 (review follow-up): this door tears down ONLY the object store. |
| 1255 | // It used to also tear down the app's email estate — the SES identity, |
| 1256 | // the IAM principal, delivered credentials, published DNS and, |
| 1257 | // irreversibly and globally, the domain claim — but consent for THAT |
| 1258 | // was never obtained here: the confirm word is the bucket name, the |
| 1259 | // button says "Destroy store", and the success flash below names only |
| 1260 | // the object store. Email teardown belongs to ownership.go's |
| 1261 | // handlePurgeApp, which destroys the whole app and says so. |
| 1262 | if err := s.claimAppMutation(ctx, acct, app, "store-destroy", func(ctx context.Context) error { |
| 1263 | return objstore.TeardownIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, acct.Sqid, app, env) |
| 1264 | }); err != nil { |
| 1265 | log.Printf("console: store destroy %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), err) |
| 1266 | if errors.Is(err, release.ErrCASConflict) { |
| 1267 | fail("The store changed while you confirmed — nothing was destroyed; reload and try again.") |
| 1268 | return |
| 1269 | } |
| 1270 | fail("Could not destroy “" + app + "”'s object store — it is still there; try again once the " + |
| 1271 | "backend is reachable.") |
| 1272 | return |
| 1273 | } |
| 1274 | what, flash := "the store declaration", "Withdrew “"+app+"”'s store declaration." |
| 1275 | if estate { |
| 1276 | what = "bucket " + want |
| 1277 | flash = "Destroyed “" + app + "”'s object store: bucket " + want + ", its credentials and the " + |
| 1278 | "variables they were delivered in are gone." |
| 1279 | } |
| 1280 | s.audit(acct, app, addr, "store-destroy", "destroyed the object store ("+what+")"+storeEnvSuffix(env)) |
| 1281 | // Box-consumed: the delivered variables were UNSET in the app's config |
| 1282 | // (objstore.Teardown), so every host still serving this app has an env file |
| 1283 | // naming a bucket and a key that authenticate against nothing. Without the |
| 1284 | // bump that stands until the hourly floor comes round. |
| 1285 | s.bumpSerial(ctx) |
| 1286 | s.setFlash(w, "ok", flash) |
| 1287 | http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "storage"), http.StatusSeeOther) |
| 1288 | } |
| 1289 | |
| 1290 | // storeConfirmWord is what a person must type to destroy a store: the BUCKET |
| 1291 | // when the record names one, because the bucket and its objects are what stop |
| 1292 | // existing, and the app's own name when it does not — a declaration, where the |
| 1293 | // app IS the whole subject. The fallback also closes the empty-string trap: a |
| 1294 | // grant block with no bucket cannot come from the walk, but it can come from a |
| 1295 | // hand-edited object, and an empty expected word would let an empty form field |
| 1296 | // confirm a destruction. |
| 1297 | func storeConfirmWord(rec *objstore.Record, app string) string { |
| 1298 | switch { |
| 1299 | case rec.Provisioned != nil && rec.Provisioned.Bucket != "": |
| 1300 | return rec.Provisioned.Bucket |
| 1301 | case rec.Grant != nil && rec.Grant.Bucket != "": |
| 1302 | return rec.Grant.Bucket |
| 1303 | } |
| 1304 | return app |
| 1305 | } |
| 1306 | |
| 1307 | // handleAppMoveAccount is the repair half of an account move: when the |
| 1308 | // app's routes already say another account owns it (AccountMismatch), this |
| 1309 | // makes the console's record agree — `console migrate-app`, from the |
| 1310 | // browser, for the owner standing in both accounts. The target comes from |
| 1311 | // the ROUTES, never the form: this door re-homes the record to where the |
| 1312 | // platform already deploys from, and nowhere else. |
| 1313 | func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) { |
| 1314 | addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| 1315 | if !ok { |
| 1316 | return |
| 1317 | } |
| 1318 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 1319 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 1320 | return |
| 1321 | } |
| 1322 | if role != RoleOwner { |
| 1323 | http.NotFound(w, r) |
| 1324 | return |
| 1325 | } |
| 1326 | back := func(kind, msg string) { |
| 1327 | s.setFlash(w, kind, msg) |
| 1328 | http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther) |
| 1329 | } |
| 1330 | // The control plane does not change accounts: its releases are read from |
| 1331 | // one account's prefix by every box in the fleet (controlplane.go). |
| 1332 | if msg, _ := s.controlPlaneRefusal(acct, app, "moved to another account"); msg != "" { |
| 1333 | back("err", msg) |
| 1334 | return |
| 1335 | } |
| 1336 | // Host-claim consoles refuse every account transfer, recordless or |
| 1337 | // not, before any lock, registration or write |
| 1338 | // (claimTransferUnsupportedMsg). |
| 1339 | if s.cfg.HostClaim { |
| 1340 | s.renderMessage(w, r, http.StatusConflict, "Not available", claimTransferUnsupportedMsg) |
| 1341 | return |
| 1342 | } |
| 1343 | defer s.lockApp(acct.Sqid, app)() |
| 1344 | if msg, _ := s.temporaryRefusal(acct, app, "move it to another account"); msg != "" { |
| 1345 | back("err", msg) |
| 1346 | return |
| 1347 | } |
| 1348 | target, agreed, err := s.routeAccountFor(addr, acct, app) |
| 1349 | if errors.Is(err, errMoveTargetUnavailable) { |
| 1350 | http.NotFound(w, r) |
| 1351 | return |
| 1352 | } |
| 1353 | if err != nil { |
| 1354 | back("err", "Could not read the routes — nothing was moved. Try again.") |
| 1355 | return |
| 1356 | } |
| 1357 | // The action-time equivalent of the card's AllMismatched gate. The card is |
| 1358 | // only offered when EVERY non-platform route disagrees with this account, |
| 1359 | // but a direct POST — or a tab opened before the routes changed — reaches |
| 1360 | // here regardless, and target is only the FIRST route's account. Under a |
| 1361 | // partial mismatch that first route can be the minority: confirming would |
| 1362 | // then move the whole record to where a minority of hosts deploy from, |
| 1363 | // turning a partial disagreement into a total one the other way. Refuse |
| 1364 | // before the card is rendered and before anything is written. |
| 1365 | if !agreed { |
| 1366 | back("err", "This app's routes disagree among themselves — nothing was moved; fix the routes first.") |
| 1367 | return |
| 1368 | } |
| 1369 | if target == "" || target == acct.Sqid { |
| 1370 | back("err", "This app's routes agree with this account — there is nothing to move.") |
| 1371 | return |
| 1372 | } |
| 1373 | dest, err := s.store.AccountBySqid(target) |
| 1374 | if err != nil || dest == nil { |
| 1375 | back("err", "The routes name account "+target+", which this console does not know — that is an operator repair.") |
| 1376 | return |
| 1377 | } |
| 1378 | // In the destination, this app's platform apex must not already be |
| 1379 | // another app's (apex.go). |
| 1380 | apexCtx, apexCancel := context.WithTimeout(r.Context(), 10*time.Second) |
| 1381 | hold, err := s.apexHolder(apexCtx, dest, app) |
| 1382 | apexCancel() |
| 1383 | switch { |
| 1384 | case err != nil: |
| 1385 | back("err", "Could not check "+app+"'s platform address in "+dest.Name+" — nothing was moved. Try again.") |
| 1386 | return |
| 1387 | case hold != nil: |
| 1388 | back("err", "Cannot move "+app+" to "+dest.Name+": "+apexHeldSentence(s.apexHost(dest.Sqid, app), hold)+".") |
| 1389 | return |
| 1390 | } |
| 1391 | if drole, err := s.store.Role(dest.ID, addr); err != nil || drole == "" { |
| 1392 | 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.") |
| 1393 | return |
| 1394 | } |
| 1395 | // The card PINS its target: the confirmed POST carries the sqid the card |
| 1396 | // named, and a mismatch with the freshly re-derived target refuses. The |
| 1397 | // window between card and confirmed POST includes a passkey round trip — |
| 1398 | // long enough for an operator to re-stamp the routes to some OTHER |
| 1399 | // account the viewer also belongs to, and the record must never move to |
| 1400 | // an account nobody saw on a card. |
| 1401 | if !confirmed(r) { |
| 1402 | cv := &ConfirmView{ |
| 1403 | Title: "Move " + app + " to " + dest.Name + "?", |
| 1404 | Lines: []string{ |
| 1405 | "This app's hosts already read releases from " + dest.Name + " (" + target + ") — the console's own record is what still says " + acct.Name + ".", |
| 1406 | "Moving makes them agree: the app's page, promotes and activity live under " + dest.Name + " from here on.", |
| 1407 | }, |
| 1408 | Note: "You'll confirm it's really you with your passkey.", |
| 1409 | Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/move", |
| 1410 | Fields: [][2]string{ |
| 1411 | {"csrf", raw}, {"to", dest.Sqid}, |
| 1412 | }, |
| 1413 | Button: "Move to " + dest.Name, |
| 1414 | CancelTab: "settings", |
| 1415 | } |
| 1416 | s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| 1417 | return |
| 1418 | } |
| 1419 | if pinned := strings.TrimSpace(r.FormValue("to")); pinned != "" && pinned != dest.Sqid { |
| 1420 | back("err", "This app's routes changed while you were confirming — review the move again.") |
| 1421 | return |
| 1422 | } |
| 1423 | if !s.requireFreshAuth(w, r, addr, raw) { |
| 1424 | return |
| 1425 | } |
| 1426 | if err := s.store.MoveApp(app, acct.ID, dest.ID); err != nil { |
| 1427 | log.Printf("console: move %s %s -> %s: %v", app, acct.Sqid, target, err) |
| 1428 | back("err", "Could not move the record — nothing changed. "+moveAdvice(err, dest.Name)) |
| 1429 | return |
| 1430 | } |
| 1431 | s.audit(acct, app, addr, "move", "moved "+app+" to account "+dest.Name+" ("+target+")") |
| 1432 | // MoveApp revoked the name's door keys on both sides; tell the boxes now. |
| 1433 | s.republishDoorKeys(r, acct, app) |
| 1434 | s.republishDoorKeys(r, dest, app) |
| 1435 | if err := s.store.RecordEventContext(r.Context(), dest.ID, app, addr, "move", "arrived from account "+acct.Name+" ("+acct.Sqid+")", time.Now()); err != nil { |
| 1436 | log.Printf("console: move event %s: %v", app, err) |
| 1437 | } |
| 1438 | s.setFlash(w, "ok", "Moved "+app+" to "+dest.Name+" — console and routes agree again.") |
| 1439 | http.Redirect(w, r, "/a/"+url.PathEscape(dest.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| 1440 | } |
| 1441 | |
| 1442 | // moveAdvice maps MoveApp's refusals to actionable text. |
| 1443 | func moveAdvice(err error, destName string) string { |
| 1444 | if errors.Is(err, ErrTemporaryApp) { |
| 1445 | return "This app is temporary, so it cannot move. Make it permanent first with carlos apps keep." |
| 1446 | } |
| 1447 | if strings.Contains(err.Error(), "already claims") { |
| 1448 | return destName + " already has a different app with this name." |
| 1449 | } |
| 1450 | return "Try again." |
| 1451 | } |
| 1452 | |
| 1453 | var errMoveTargetUnavailable = errors.New("move target unavailable to this viewer") |
| 1454 | |
| 1455 | // routeAccountFor recomputes the viewer-safe diagnostic at action time. It |
| 1456 | // grants a target only when every relevant route points to one accessible |
| 1457 | // account which does not already claim its own same-named app. |
| 1458 | func (s *Server) routeAccountFor(address string, source *Account, app string) (target string, agreed bool, err error) { |
| 1459 | review, all, err := s.appAccountReview(address, source, app) |
| 1460 | if err != nil { |
| 1461 | return "", false, err |
| 1462 | } |
| 1463 | if all { |
| 1464 | target := review.Groups[0].Account.Sqid |
| 1465 | reg, err := openRegistry(s.cfg.Registry) |
| 1466 | if err != nil { |
| 1467 | return "", false, err |
| 1468 | } |
| 1469 | routes, err := reg.List() |
| 1470 | if err != nil { |
| 1471 | return "", false, err |
| 1472 | } |
| 1473 | for _, rt := range routes { |
| 1474 | if rt.App == app && !rt.Platform && release.RouteAccount(rt) != target { |
| 1475 | return "", false, nil |
| 1476 | } |
| 1477 | } |
| 1478 | return target, true, nil |
| 1479 | } |
| 1480 | if review.blocked { |
| 1481 | return "", false, errMoveTargetUnavailable |
| 1482 | } |
| 1483 | if review.Count == 0 && !review.blocked { |
| 1484 | return source.Sqid, true, nil |
| 1485 | } |
| 1486 | return "", false, nil |
| 1487 | } |
| 1488 | |