carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
28package console
29
30import (
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.
54var 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
66func 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.
75type 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.
180type 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.
200type 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.
224type 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.
245type 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.
277type 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.
301type 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.
310func (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.
387func (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.
405type FeaturesCard struct {
406 Flags []FeatureFlag // key-sorted
407 SetBy string
408 SetAt string // "" when no record
409}
410
411type 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.
415func (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.
442func (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.
463func (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.
519func (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.
544func (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.
590func (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.
653func (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.
712func (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.
728func (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.
748func (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.
903func 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.
927func (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.
1012func (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.
1155func (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.
1297func 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.
1313func (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.
1443func 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
1453var 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.
1458func (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