// The console UI half of release pipelines v2 // (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md): the // pending-changes card every app page carries (PendingChangeView, // pendingChangeViews) and the pipeline settings page, GET/POST // /a/{sqid}/apps/{app}/pipeline (pipelinepage.go's own name). // // The settings POST is deliberately NOT a second copy of the API PUT's // guarded-write logic (apipipeline.go's handleAPIPipelinePut): both doors // share guardedEdit's decision (pending.go's rule for what counts as // touching a guarded channel) and writePipeline's write tail (SetPipeline, // the old-binary bake fallback, the audit row, the serial bump) below. What // differs, and stays separate, is how each door turns a guarded edit into a // pending change — proposeOverAPI's zero-confirmation bearer shape for the // CLI, proposeThenExecute's passkey-backed shape here, called only after // requireFreshAuth has actually run. package console import ( "context" "crypto/subtle" "fmt" "log" "net/http" "strconv" "strings" "github.com/carlosframework/platform/internal/release" ) // ---- the pending-changes card ------------------------------------------- // PendingChangeView is one row of the app page's pending-changes card: the // human-legible summary (pendingSummary, pendinghandlers.go) and quorum // progress a viewer needs to decide whether to confirm or reject it. It // carries nothing pendinghandlers.go's own doors don't already recompute // from the id on the confirm/reject POST — this is display only. type PendingChangeView struct { ID string Kind string Summary string Confirmed int Needed int } // pendingChangeViews folds a store listing into the card's rows, oldest // first — PendingChangesFor's own order, unchanged, so the card and the // CLI's `carlos pipeline` listing (Task 10) read the same queue the same way. // // The two numbers are the ones EXECUTION will use, not the ones the row was // written with (PR #258 review, I3): Needed is max(stored quorum, the target // channel's current rule) and Confirmed counts only confirmations from // current members. Showing the raw row instead let a change sit at "2 of 2" // with a live Confirm button and refuse every time it was pressed — a card // that says a change is ready when it is not is worse than no card. // // pipe is the app's pipeline, read ONCE by the caller for the whole listing // (nil is fine — a legacy app, or a read that failed: the tally then degrades // to the stored quorum, which is exactly what it was before this existed). func (s *Server) pendingChangeViews(pcs []PendingChange, pipe *release.Pipeline) []PendingChangeView { out := make([]PendingChangeView, len(pcs)) for i, pc := range pcs { confirmed, needed := s.pendingTally(pc, pipe) out[i] = PendingChangeView{ ID: pc.ID, Kind: pc.Kind, Summary: pendingSummary(pc), Confirmed: confirmed, Needed: needed, } } return out } // ---- the settings page's form rows --------------------------------------- // PipelineFormRow is one editable row of the pipeline settings form — a // draft of a release.PipelineChannel, indexed ch..name / ch..bake / // ch..passkey / ch..promote_approvals / ch..change_approvals on the // wire, matching the brief's field-naming exactly. type PipelineFormRow struct { Name string Bake string Passkey bool PromoteApprovals int ChangeApprovals int } // pipelineRowsFromChannels drafts the form from a real (or default) channel // list — GET's starting point, and what a save error re-renders on top of. func pipelineRowsFromChannels(channels []release.PipelineChannel) []PipelineFormRow { rows := make([]PipelineFormRow, len(channels)) for i, c := range channels { rows[i] = PipelineFormRow{ Name: c.Name, Bake: c.Bake, Passkey: c.Rules.Passkey, PromoteApprovals: c.Rules.PromoteApprovals, ChangeApprovals: c.Rules.ChangeApprovals, } } return rows } // pipelineChannelsFromRows is pipelineRowsFromChannels backwards, for the // POST that writes the draft. A row left with a blank name — exactly what // "Add channel" produces until it's named — is dropped silently rather than // refused: SetPipeline's own validation is the one place a genuinely bad // name gets a real error, so a still-blank scratch row never blocks it. func pipelineChannelsFromRows(rows []PipelineFormRow) []release.PipelineChannel { out := make([]release.PipelineChannel, 0, len(rows)) for _, r := range rows { name := strings.TrimSpace(r.Name) if name == "" { continue } out = append(out, release.PipelineChannel{ Name: name, Bake: strings.TrimSpace(r.Bake), Rules: release.ChannelRules{ Passkey: r.Passkey, PromoteApprovals: r.PromoteApprovals, ChangeApprovals: r.ChangeApprovals, }, }) } return out } // pipelineIsDefault reports whether rows is still exactly the untouched // one-channel default (release.DefaultPipelineChannels()) a fresh app is // created with — the state in which the two starter templates below are // worth offering at all; past it, they'd silently discard an edit. func pipelineIsDefault(rows []PipelineFormRow) bool { return len(rows) == 1 && rows[0] == PipelineFormRow{Name: "edge"} } // edgeProductionTemplate is spec §5's first starter: a single production // channel behind a passkey. The CONTROLLER RULING this brief carries: any // template that sets a rule on a channel also sets change_approvals >= 1 on // it — a passkey any member can strip in one PUT is decoration, and a solo // account still clears it (1 <= its own member count) with one console // confirmation of their own passkey. func edgeProductionTemplate() []PipelineFormRow { return []PipelineFormRow{ {Name: "edge"}, {Name: "production", Bake: "24h", Passkey: true, ChangeApprovals: 1}, } } // fullLadderTemplate is spec §5's second starter: today's edge/beta/stable // defaults, reproduced as a pipeline — beta bakes 24h with no rule of its // own, stable bakes 72h behind a passkey and the same change_approvals // ruling as the template above. func fullLadderTemplate() []PipelineFormRow { return []PipelineFormRow{ {Name: "edge"}, {Name: "beta", Bake: "24h"}, {Name: "stable", Bake: "72h", Passkey: true, ChangeApprovals: 1}, } } // parsePipelineRows reads the submitted ch..* fields back into rows, in // order, stopping at the first index whose name field is simply absent — // every row the settings template rendered posts a ch..name field (even // an empty one, for a still-unnamed scratch row), so absence is genuinely // "no more rows" rather than "this one happens to be blank". func parsePipelineRows(r *http.Request) []PipelineFormRow { r.ParseForm() var rows []PipelineFormRow for i := 0; ; i++ { prefix := "ch." + strconv.Itoa(i) + "." if _, ok := r.PostForm[prefix+"name"]; !ok { break } rows = append(rows, PipelineFormRow{ Name: r.PostFormValue(prefix + "name"), Bake: r.PostFormValue(prefix + "bake"), Passkey: r.PostFormValue(prefix+"passkey") != "", PromoteApprovals: nonNegativeAtoi(r.PostFormValue(prefix + "promote_approvals")), ChangeApprovals: nonNegativeAtoi(r.PostFormValue(prefix + "change_approvals")), }) } return rows } // nonNegativeAtoi parses a form field as a non-negative count, treating // anything unparsable or negative as 0 rather than refusing the request — // the same forgiving posture the rest of this form takes toward a // half-edited row; SetPipeline's own validation still refuses a genuinely // negative value if one somehow reaches it another way. func nonNegativeAtoi(s string) int { n, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil || n < 0 { return 0 } return n } // ---- the shared write tail ------------------------------------------------ // writePipeline lands an UNGUARDED edit — the one place either the API PUT // or this settings POST actually writes: SetPipeline, the old-binary bake // fallback (ensureProductionForBakes, apipipeline.go), the audit row and the // serial bump. via names the door for the audit row's own vocabulary // (pipelineAuditDetail), exactly as handleAPIPipelinePut's inline tail did // before this was extracted for both callers to share. Box-consumed // (carlos-deploy §3): boxes read pipeline.json on the same fast pass channel // pointers and config already ride, which is what the serial bump is for. func (s *Server) writePipeline(ctx context.Context, acct *Account, app string, channels []release.PipelineChannel, actor, via string) (*release.Pipeline, error) { set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channels, actor) if err != nil { return nil, err } s.ensureProductionForBakes(ctx, acct, app, set, actor) s.audit(acct, app, actor, "pipeline", pipelineAuditDetail(set, via)) s.bumpSerial(ctx) return set, nil } // ---- the settings page: GET -------------------------------------------- // handlePipelineSettings renders the current (or default) pipeline as an // editable form. Read-only, so membership is the whole gate — same as the // rest of the app's settings tab. func (s *Server) handlePipelineSettings(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.appPage(w, r, "settings") if !ok { return } s.handlePipelineSettingsPage(w, r, p, acct) } func (s *Server) handlePipelineSettingsPage(w http.ResponseWriter, r *http.Request, p page, acct *Account) { setAppSettingsSection(&p, "pipeline") ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout) defer cancel() pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, p.AppName) channels := release.DefaultPipelineChannels() if err != nil { log.Printf("console: pipeline settings %s/%s: %v", acct.Sqid, p.AppName, err) p.FlashErr = "Could not read the pipeline — showing the default. Try again to see the real one." } else if pipe != nil { channels = pipe.Channels } p.PipelineRows = pipelineRowsFromChannels(channels) p.PipelineIsDefault = pipelineIsDefault(p.PipelineRows) s.render(w, http.StatusOK, "pipeline_settings", p) } // renderPipelineSettings re-renders the settings form on top of a page // already built by appPage — used for the draft-only actions (add/remove // row, load a template) and for a save that failed, so the human's // in-progress edit is never silently thrown away. func (s *Server) renderPipelineSettings(w http.ResponseWriter, p page, rows []PipelineFormRow, errMsg string) { setAppSettingsSection(&p, "pipeline") p.PipelineRows = rows p.PipelineIsDefault = pipelineIsDefault(rows) if errMsg != "" { p.FlashErr = errMsg } s.render(w, http.StatusOK, "pipeline_settings", p) } // ---- the settings page: POST --------------------------------------------- // handlePipelineSettingsSave is the settings form's one door: draft actions // (add/remove a row, load a starter template) re-render the form with // nothing written; "save" carries the edit through the same guarded path // the API PUT takes (writePipeline directly when nothing is guarded, // proposeThenExecute's passkey-backed shape when a touched channel demands // a quorum), reusing guardedEdit's decision rather than a second copy of it. // // Gate order: session + membership (appPage → accountApp, byte-identical // 404 for a non-member) → CSRF → [the cheap, non-revealing refusals: the // draft actions, the signing-key check, the read-current-pipeline and // member-ceiling checks below] → requireFreshAuth LAST, and only for the // one branch that actually writes — a policy edit is account authority in // the same register as the API PUT (apipipeline.go's own doc comment), and // ANY member may reach it, matching the API door's human-any-role rule. func (s *Server) handlePipelineSettingsSave(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.appPage(w, r, "settings") if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(p.CSRF)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } addr, raw, app := p.Address, p.CSRF, p.AppName rows := parsePipelineRows(r) switch action := r.FormValue("action"); { case action == "add": rows = append(rows, PipelineFormRow{}) s.renderPipelineSettings(w, p, rows, "") return case strings.HasPrefix(action, "remove-"): if idx, err := strconv.Atoi(strings.TrimPrefix(action, "remove-")); err == nil && idx >= 0 && idx < len(rows) { rows = append(rows[:idx], rows[idx+1:]...) } s.renderPipelineSettings(w, p, rows, "") return case action == "template-edge-production": s.renderPipelineSettings(w, p, edgeProductionTemplate(), "") return case action == "template-full-ladder": s.renderPipelineSettings(w, p, fullLadderTemplate(), "") return } if s.cfg.Key == nil { s.renderMessage(w, r, http.StatusServiceUnavailable, "Pipeline edits disabled", "No signing key on this host — the console is running read-only.") return } channels := pipelineChannelsFromRows(rows) ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout) defer cancel() cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) if err != nil { log.Printf("console: pipeline settings save %s/%s: read current: %v", acct.Sqid, app, err) s.renderPipelineSettings(w, p, rows, "Could not read the current pipeline — nothing was changed. Try again.") return } members, err := s.store.Members(acct.ID) if err != nil { log.Printf("console: pipeline settings save %s/%s: members: %v", acct.Sqid, app, err) s.renderPipelineSettings(w, p, rows, "Could not check the account's members — nothing was changed. Try again.") return } proposed := &release.Pipeline{Schema: 1, Account: acct.Sqid, App: app, Channels: channels} if n := proposed.MaxApprovals(); n > len(members) { s.renderPipelineSettings(w, p, rows, fmt.Sprintf( "Approvals of %d exceed the account's %d member(s) — add members first.", n, len(members))) return } if !s.requireFreshAuth(w, r, addr, raw) { return } dest := appSettingsURL(acct.Sqid, app, "pipeline") if _, n := guardedEdit(cur, channels); n > 0 { kind, msg := s.proposeThenExecute(ctx, acct, app, kindPolicy, policyProposal(cur, channels), n, addr) s.setFlash(w, kind, msg) http.Redirect(w, r, dest, http.StatusSeeOther) return } if _, err := s.writePipeline(ctx, acct, app, channels, addr, "console"); err != nil { status, msg := classifySetPipelineError(err) if status != http.StatusUnprocessableEntity { log.Printf("console: pipeline settings save %s/%s: set: %v", acct.Sqid, app, err) } s.renderPipelineSettings(w, p, rows, msg) return } s.setFlash(w, "ok", "Pipeline updated.") http.Redirect(w, r, dest, http.StatusSeeOther) }