// The action confirm step: a server-rendered interstitial between clicking a // button that changes what serves and it happening. One page, one card: what // is about to happen in plain sentences, one button that does it, one link // that does not. // // This is a UI affordance, not a gate — the security gates (session, CSRF, // owner 404, requireFreshAuth) neither move nor weaken for it, and a // hand-written POST with confirmed=1 meets exactly the doors a button does. // Placement inside each handler is therefore load-bearing: // // - AFTER CSRF and input validation, so the card describes a request that // would otherwise proceed, never an invalid one. // - AFTER any owner-only 404, never before it: a member probing an // owner-only action must meet the byte-identical 404, not a confirm card // that concedes the action exists for somebody. // - BEFORE requireFreshAuth, so the passkey round trip replays the POST // confirmed — confirm asks "do you mean it?", step-up asks "is it you?", // in that order, each exactly once. package console import ( "context" "errors" "net/http" "net/url" "os" "strconv" "time" "github.com/carlosframework/platform/internal/release" ) // ConfirmView is everything the interstitial renders. type ConfirmView struct { Title string // Lines are the card's sentences: current state, then what changes. Lines []string // Warn is the one red line, for the part that must not be skimmed past. Warn string // Note is the quiet line after the button — what happens next (a bake // window, a passkey prompt), muted. Note string // Action is the POST target; Fields the hidden name/value pairs carried // through, csrf included. The confirmed=1 marker is the template's. Action string Fields [][2]string // Checkbox, optional: a real choice that belongs at confirm time (the // promote-to-stable bake opt-in). Rendered only when Name is non-empty. CheckboxName string CheckboxLabel string CheckboxChecked bool Button string Danger bool // CancelTab is which app tab the cancel link lands on ("" = Overview); // renderConfirm derives the URL from it, since only the assembled page // knows the account's link segment. CancelTab string CancelURL string } // confirmed reports whether this POST already carries the confirm step's // approval. The zero value of a form nobody edited is absence, so any other // value stays unconfirmed. func confirmed(r *http.Request) bool { return r.FormValue("confirmed") == "1" } // renderConfirm renders the interstitial with the account chrome, titled by // the action. 200, not a redirect: the card IS this POST's response, and the // resubmit it hosts is a fresh POST of the same fields plus confirmed=1. func (s *Server) renderConfirm(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app string, cv *ConfirmView) { p := s.accountPage(w, r, addr, raw, acct, role) p.Title = cv.Title p.AppName = app p.ActiveNav = "apps" if cv.CancelURL == "" { cv.CancelURL = p.Nav + "/apps/" + url.PathEscape(app) if cv.CancelTab != "" { cv.CancelURL += "/" + cv.CancelTab } } p.Confirm = cv s.render(w, http.StatusOK, "confirm_action", p) } // backTab maps the action forms' back field onto the tab a cancel link (and // nothing else) should land on. Only values the redirects themselves honor. func backTab(r *http.Request) string { if tab := releaseBackTab(r); tab != "" { return tab } return "" } // holdText renders a bake hold the way the card speaks: "~24h". func holdText(d time.Duration) string { return "~" + strconv.Itoa(int(d.Hours())) + "h" } // confirmPromote is the promote door's card. Everything here is best-effort // reading for the card's sentences — the confirmed POST re-validates from // scratch, so a read that fails here costs precision, never safety. // // pipe is the caller's own already-read copy (handlePromote reads it before // the confirmed check, to gate the owner-only rung correctly) — not read // again here, so the card and the write it precedes can never disagree about // which pipeline they saw. Its hold window, and whether a bake applies at // all, mirror handlePromote's post-confirm logic (holdWindow, and // `pipe != nil || baking`) exactly: a pipelined channel bakes on its OWN // window regardless of the legacy production flag (spec §3), and the // legacy enable_bake checkbox — which only ever flips that flag — has // nothing to offer a pipelined channel, so it stays hidden for one. func (s *Server) confirmPromote(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel, version string, pipe *release.Pipeline) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() cv := &ConfirmView{ Title: "Promote " + version + " to " + channel + "?", Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/promote", Fields: [][2]string{ {"csrf", raw}, {"channel", channel}, {"version", version}, {"back", r.FormValue("back")}, }, Button: "Promote " + version, CancelTab: backTab(r), } cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) switch { case errors.Is(err, os.ErrNotExist): cv.Lines = append(cv.Lines, "This creates channel "+channel+", serving "+version+" as soon as a host follows it.") case err != nil: cv.Lines = append(cv.Lines, "After this, "+channel+" serves "+version+".") case cur.Version == version: cv.Lines = append(cv.Lines, channel+" already serves "+version+" — confirming changes nothing.") default: cv.Lines = append(cv.Lines, channel+" serves "+cur.Version+" now.", "After this, it serves "+version+".") } rung, _ := release.Rung(channel) stable := rung == release.RungStable if stable { cv.Warn = "Stable is what every user of " + app + " gets — this puts " + version + " in front of everyone." } // Whether the promotion bakes, and whether to offer turning the bake on: // the same flag logic the confirmed POST applies, read here only to make // the card tell the truth about what happens next. flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) baking := ferr != nil || (flag != nil && flag.Production) if hold := holdWindow(pipe, channel); hold > 0 && (pipe != nil || baking) { cv.Lines = append(cv.Lines, "This app has the safety delay on: each copy waits "+holdText(hold)+" on "+channel+" before running "+version+".") } if stable && pipe == nil && ferr == nil && !baking { cv.CheckboxName = "enable_bake" cv.CheckboxChecked = true cv.CheckboxLabel = "This app is live for real users — keep new versions on the safety delay (recommended)" } // The legacy rung's name carries the passkey requirement implicitly // (promoteGate); a pipelined channel carries it explicitly, on ANY name // — "production" in the Edge→Production template needs the same warning // "stable" gets, or the passkey prompt that promoteGate still enforces // right after this card would land as a surprise. if stable || (pipe != nil && channelRules(pipe, channel).Passkey) { cv.Note = "You'll confirm it's really you with your passkey." } s.renderConfirm(w, r, addr, raw, acct, role, app, cv) } // confirmRollback: the card knows both versions because the handler read the // pointer before asking — a rollback card that cannot say where it is going // has no business asking anyone to confirm it. func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel, cur, target string) { cv := &ConfirmView{ Title: "Roll back " + channel + " to " + target + "?", Lines: []string{ channel + " serves " + cur + " now.", "Rolling back re-points it to " + target + ", the version before this one.", }, Note: "Rollbacks skip the safety delay — it rolls out within a couple of minutes.", Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback", // target pins what the card promised: the confirmed POST refuses if // the channel's history has moved on since this was rendered. Fields: [][2]string{ {"csrf", raw}, {"channel", channel}, {"target", target}, {"back", r.FormValue("back")}, }, Button: "Roll back to " + target, Danger: true, CancelTab: backTab(r), } s.renderConfirm(w, r, addr, raw, acct, role, app, cv) } // confirmFastTrack reads the channel best-effort for the card's sentences; // the confirmed POST re-validates (and still refuses a channel with no bake // window) from scratch. func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel string) { ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() cv := &ConfirmView{ Title: "Skip the safety delay on " + channel + "?", Warn: "The safety delay is the window where a new build proves itself — skipping it puts an unproven build in front of users.", Note: "You'll confirm it's really you with your passkey. The version keeps a permanent 'delay skipped' badge.", Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/fasttrack", Fields: [][2]string{ {"csrf", raw}, {"channel", channel}, }, Button: "Skip the delay", Danger: true, } if cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel); err == nil { cv.Title = "Skip the safety delay for " + cur.Version + " on " + channel + "?" cv.Lines = append(cv.Lines, channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.", "Skipping waives the rest of it: "+cur.Version+" rolls out within a couple of minutes.") cv.Button = "Skip the delay for " + cur.Version // Pin the version the card names — the waiver must not land on a // build promoted after this was rendered. cv.Fields = append(cv.Fields, [2]string{"version", cur.Version}) } else { cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: its current version rolls out within a couple of minutes.") } s.renderConfirm(w, r, addr, raw, acct, role, app, cv) } // confirmRetireCanary: the handler has already established the canary exists // and nothing follows it, so the card states exactly those two facts. func (s *Server) confirmRetireCanary(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, name string) { cv := &ConfirmView{ Title: "Retire canary " + name + "?", Lines: []string{ "Deletes channel canary/" + name + " — the pointer, not any shipped release.", "No host follows it, so nothing serving changes.", }, Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/canary/retire", Fields: [][2]string{ {"csrf", raw}, {"name", name}, {"back", r.FormValue("back")}, }, Button: "Retire " + name, Danger: true, CancelTab: backTab(r), } s.renderConfirm(w, r, addr, raw, acct, role, app, cv) } // ago mirrors the template helper of the same name for the card builders' // sentences: humanAge's coarse resolution, dashing out the zero time. func ago(t time.Time) string { if t.IsZero() { return "—" } return humanAge(time.Since(t)) + " ago" }