console: per-channel rules gate promote, fast-track and policy edits through pending changes
A channel's declared rules now decide what reaching it costs, at every door that can reach it (release pipelines v2 §4/§5): - handleAPIPromote reads the app's pipeline once. No pipeline: today's legacy stable refusal, byte for byte. Pipelined: promote_approvals turns the request into a 202 proposal at ZERO confirmations (a bearer token never counts toward a passkey quorum), passkey alone refuses with 403 and the console URL, and a channel with neither promotes as before. The body gains "hotfix", read strictly AFTER the rules decision — it bypasses order, never rules. - handlePromote/promoteGate apply the same decision in the browser, with requireFreshAuth still LAST; a proposal made behind the step-up counts its proposer, so a quorum of one executes inline. - handleFastTrack proposes when the channel carries change_approvals, and its zero-hold refusal moves to the pipeline-aware holdWindow. - The pipeline PUT's interim 403 on a guarded edit becomes a proposal pinning the current SetAt; execution refuses on a mismatch. - New: confirm/reject browser doors (member-gated, 404-scoped, step-up last; reject needs no step-up), GET /api/cli/.../pending, and executePending — the one place a gated write happens, always re-checking the pinned payload and never marking a failed execution executed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+1404
−119
internal/console/apicli.go+92 −21internal/console/apicli_test.go+42 −11internal/console/apipipeline.go+41 −28internal/console/apipipeline_test.go+32 −23internal/console/authcli.go+6 −0internal/console/pendingflow_test.go+503 −0internal/console/pendinghandlers.go+553 −0internal/console/server.go+135 −36
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 64afe9ab2..93f9ba32c 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -561,10 +561,16 @@ func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) { |
| // byte-identical: a token holder must not learn their own role, let alone |
| // anyone else's, from a door that isn't the one stable actually goes through. |
| // |
| -// The request struct below has no hotfix field, and nowhere in this handler |
| -// is PromoteOptions.Hotfix ever set — encoding/json ignores an unknown |
| -// "hotfix" key rather than erroring on it, so there is no way to bypass the |
| -// ladder from this door at all. |
| +// Which gate applies is the PIPELINE's answer (release pipelines v2 §5). An |
| +// app that has none meets the legacy stable refusal above, byte for byte, as |
| +// it always has. A pipelined app meets the target channel's own declared |
| +// rules instead: a passkey rule refuses here (there is no passkey at a bearer |
| +// token), an approvals rule turns the request into a PROPOSAL, and a channel |
| +// with neither promotes exactly as before. |
| +// |
| +// `hotfix` is read strictly AFTER that decision, never before it: -hotfix |
| +// bypasses ORDER only, never rules (spec §5), and reading the flag first is |
| +// precisely how a bypass of one quietly becomes a bypass of the other. |
| func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| addr, acct, app, ok := s.apiAccountApp(w, r) |
| if !ok { |
| @@ -603,6 +609,9 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| Version string `json:"version"` |
| Channel string `json:"channel"` |
| + // Hotfix is spec §5's order bypass, and it is read nowhere above the |
| + // rules decision below — see this handler's doc comment. |
| + Hotfix bool `json:"hotfix"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| @@ -623,21 +632,73 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusNotFound, "Version "+version+" is not a shipped release of "+app+".") |
| return |
| } |
| - // The rung's own gate, LAST — after auth, after the account/app 404, after |
| - // the key check, and after every refusal that costs nothing to answer. |
| - // Same load-bearing order handlePromote's comment explains: a non-member |
| - // meets the byte-identical 404 in apiAccountApp before stable is ever |
| - // mentioned. Below stable, membership (already proved) is the gate; |
| - // PromoteOnLadder enforces the ladder itself. |
| - if rung, _ := release.Rung(channel); rung == release.RungStable { |
| - writeJSON(w, http.StatusForbidden, map[string]string{ |
| - "error": "stable needs a passkey — finish in the console", |
| - "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app), |
| - }) |
| + // The pipeline is read ONCE, for the gate below and for the held answer |
| + // further down. An unreadable one refuses outright: GetPipeline's error |
| + // means "not safely nil", and treating it as "no pipeline" would drop |
| + // this door back to the legacy ladder under exactly the app that opted |
| + // out of it — the same line PromoteOnLadder itself takes. |
| + pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if perr != nil { |
| + log.Printf("console: api promote %s/%s: read pipeline: %v", acct.Sqid, app, perr) |
| + jsonError(w, http.StatusBadGateway, "could not read the pipeline for "+app) |
| return |
| } |
| + // The channel's own gate, LAST — after auth, after the account/app 404, |
| + // after the key check, and after every refusal that costs nothing to |
| + // answer. Same load-bearing order handlePromote's comment explains: a |
| + // non-member meets the byte-identical 404 in apiAccountApp before any of |
| + // this is mentioned. Below it, membership (already proved) is the gate; |
| + // PromoteOnLadder enforces the order itself. |
| + if pipe == nil { |
| + if rung, _ := release.Rung(channel); rung == release.RungStable { |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": "stable needs a passkey — finish in the console", |
| + "console_url": s.appConsoleURL(acct, app), |
| + }) |
| + return |
| + } |
| + } else { |
| + switch rules := channelRules(pipe, channel); { |
| + case rules.PromoteApprovals >= 1: |
| + // Order is checked HERE, before a proposal that could never |
| + // execute goes out for signatures — the same refusal, at the |
| + // same 409, PromoteOnLadder would give below. This is the first |
| + // line in the handler that reads the hotfix flag, and it is |
| + // strictly after the rules decision this switch is making. |
| + // pipe is non-nil in this arm by construction (channelRules |
| + // answers the zero rules for nil), which matters because |
| + // VersionStage dereferences the pipeline it is handed. |
| + if !req.Hotfix { |
| + stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, version, pipe) |
| + if serr != nil { |
| + log.Printf("console: api promote %s/%s: stage: %v", acct.Sqid, app, serr) |
| + jsonError(w, http.StatusBadGateway, "could not work out where this build sits in the pipeline") |
| + return |
| + } |
| + if aerr := release.PipelinePromotionAllowed(pipe, stage, channel); aerr != nil { |
| + jsonError(w, http.StatusConflict, aerr.Error()) |
| + return |
| + } |
| + } |
| + s.proposeOverAPI(w, acct, app, kindPromote, |
| + promotePayload{Version: version, Channel: channel, Hotfix: req.Hotfix}, |
| + rules.PromoteApprovals, addr) |
| + return |
| + case rules.Passkey: |
| + // A passkey rule with no approvals alongside it: there is nothing |
| + // to propose — one person's presence IS the whole gate — so this |
| + // door refuses and names the one that can ask for it. (An |
| + // approvals rule above subsumes this: every confirmation counted |
| + // toward a quorum is passkey-backed by construction.) |
| + writeJSON(w, http.StatusForbidden, map[string]string{ |
| + "error": channel + " needs a passkey — finish in the console", |
| + "console_url": s.appConsoleURL(acct, app), |
| + }) |
| + return |
| + } |
| + } |
| res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version, |
| - release.PromoteOptions{Actor: addr}) |
| + release.PromoteOptions{Actor: addr, Hotfix: req.Hotfix}) |
| if err != nil { |
| log.Printf("console: api promote %s %s -> %s: %v", app, version, channel, err) |
| // The ladder's own refusal text names the rungs — as useful over the |
| @@ -654,7 +715,7 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| // exactly the false failure this field exists to prevent. Nothing is |
| // announced here (no bump): the pointer did not move. |
| writeJSON(w, http.StatusOK, map[string]any{ |
| - "result": "noop", "held": s.channelHeld(ctx, acct.Sqid, app, channel), |
| + "result": "noop", "held": s.channelHeld(ctx, pipe, acct.Sqid, app, channel), |
| }) |
| return |
| } |
| @@ -662,7 +723,7 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| s.bumpSerial(ctx) |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "result": "promoted", "from": res.From, "to": res.To, "noop": false, |
| - "held": s.channelHeld(ctx, acct.Sqid, app, channel), |
| + "held": s.channelHeld(ctx, pipe, acct.Sqid, app, channel), |
| }) |
| } |
| @@ -673,15 +734,25 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| // and the APP has declared production — a non-production app's boxes adopt a |
| // held channel immediately, so a hold claim would be a lie. |
| // |
| -// Only beta can be both on the promote door: canary/* and edge have |
| +// The window comes from holdWindow, not HoldFor: a pipelined channel carries |
| +// its own, possibly for a name the legacy table has never heard of. And a |
| +// PIPELINED app never consults the production flag at all — its bake policy |
| +// is the pipeline's alone (spec §3), which is exactly what the boxes do |
| +// (adopt.go's bakeIgnore returns false for a verified pipeline), so consulting |
| +// the flag here would answer a question the fleet no longer asks. |
| +// |
| +// On the legacy path only beta can be both: canary/* and edge have |
| // zero-length windows, and stable never reaches it (the rung gate 403s it). |
| // An UNREADABLE flag reports held, matching what the boxes will actually do — |
| // the adopt path fails TOWARD baking (spec §4), so reporting "not held" there |
| // would be the one wrong answer. |
| -func (s *Server) channelHeld(ctx context.Context, acct, app, channel string) bool { |
| - if release.HoldFor(release.DefaultHolds, channel) == 0 { |
| +func (s *Server) channelHeld(ctx context.Context, p *release.Pipeline, acct, app, channel string) bool { |
| + if holdWindow(p, channel) == 0 { |
| return false |
| } |
| + if p != nil { |
| + return true |
| + } |
| flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct, app) |
| if err != nil { |
| log.Printf("console: api promote production flag %s: %v", app, err) |
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go| index c6e587981..a307fb541 100644 |
| --- a/internal/console/apicli_test.go |
| +++ b/internal/console/apicli_test.go |
| @@ -616,35 +616,66 @@ func TestAPIPromoteUnshippedVersion(t *testing.T) { |
| } |
| } |
| -// TestAPIPromoteNoHotfix: the request struct has no hotfix field at all — |
| -// encoding/json silently ignores the unknown key, and the ladder gate still |
| -// refuses a skip exactly as it would with no such key present. There is no |
| -// way, from this door, to bypass the ladder. |
| -func TestAPIPromoteNoHotfix(t *testing.T) { |
| +// TestAPIPromoteHotfixIsOrderOnly: `hotfix` became a real field on this door |
| +// with release pipelines v2 (§5, and the CLI's own client-side refusal of |
| +// -hotfix in API mode goes with it). It bypasses ORDER, and that is the whole |
| +// of what it does — the stable rule a pipeline-less app carries still refuses |
| +// it, byte for byte. |
| +// |
| +// It replaces TestAPIPromoteNoHotfix, which pinned the previous shape (the |
| +// request struct had no such field, so the key was silently ignored). The |
| +// change is deliberate: the flag is the sanctioned, recorded bypass of the |
| +// ladder's ORDER, and it was never the thing standing between a token and |
| +// stable — the rung gate is. |
| +func TestAPIPromoteHotfixIsOrderOnly(t *testing.T) { |
| fx := newConsoleFixture(t) |
| now := time.Now() |
| if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| t.Fatal(err) |
| } |
| shipVersion(t, fx, "titogo", "abc1234") |
| - token := cliToken(t, fx, "paul@keymail.dev", "code-promote-nohotfix") |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-promote-hotfix") |
| + promoteURL := fx.ts.URL + "/api/cli/apps/bab/titogo/promote" |
| // abc1234 is on no rung yet (nothing has promoted it there), so promoting |
| // straight to "beta" is the ladder-ORDERING refusal (beta is entered from |
| // edge only) — not the "not a rung" arm a nested non-canary segment like |
| - // "beta/x" would hit instead. hotfix:true rides along and must be inert. |
| - status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", |
| - map[string]any{"version": "abc1234", "channel": "beta", "hotfix": true}, token) |
| + // "beta/x" would hit instead. |
| + status, out := postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "beta"}, token) |
| if status != http.StatusConflict { |
| - t.Fatalf("ladder-skip promote with hotfix:true = %d %v, want 409 (hotfix must be inert)", status, out) |
| + t.Fatalf("ladder-skip promote = %d %v, want 409", status, out) |
| } |
| if !strings.Contains(str(t, out, "error"), "beta is entered from edge only") { |
| t.Fatalf("ladder-skip error = %q, want the ladder-ordering refusal text", str(t, out, "error")) |
| } |
| - // And the pointer must not exist — the promotion truly never happened. |
| if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "beta"); err == nil { |
| t.Fatalf("beta channel exists after a refused promotion, want it absent") |
| } |
| + |
| + // The same request with the flag lands, and the pointer records it. |
| + status, out = postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "beta", "hotfix": true}, token) |
| + if status != http.StatusOK || str(t, out, "result") != "promoted" { |
| + t.Fatalf("hotfixed ladder-skip = %d %v, want 200 promoted", status, out) |
| + } |
| + p := fx.channelVersion(t, "titogo", "beta") |
| + if p.Version != "abc1234" { |
| + t.Fatalf("beta serves %q, want abc1234", p.Version) |
| + } |
| + if len(p.Audit) == 0 || !p.Audit[0].Hotfix { |
| + t.Fatalf("beta's audit trail = %+v, want the bypass recorded on the pointer", p.Audit) |
| + } |
| + |
| + // And it buys nothing at stable, which is a RULE, not an order. |
| + status, out = postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("hotfixed stable promote = %d %v, want the 403 passkey refusal", status, out) |
| + } |
| + if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "stable"); err == nil { |
| + t.Fatalf("stable channel exists after a refused promotion, want it absent") |
| + } |
| } |
| // TestAPIRollbackHappy: two promotions to the same channel, then a rollback, |
diff --git a/internal/console/apipipeline.go b/internal/console/apipipeline.go| index 2079084bd..8f6ad4a3b 100644 |
| --- a/internal/console/apipipeline.go |
| +++ b/internal/console/apipipeline.go |
| @@ -12,16 +12,15 @@ |
| // day-to-day deploy work. A human member reaches it regardless of role: the |
| // role gate in cliRoute only ever narrows a SERVICE credential. |
| // |
| -// PUT is a stepping stone, not the finished gate. The real approval |
| -// machinery (spec §4's pending-change primitive: proposals, quorum, |
| -// passkey-confirmed counting) is Task 8. Until then, this door refuses |
| -// outright — 403, "finish in the console" — any edit that touches a channel |
| -// whose CURRENTLY STORED rules already demand change_approvals: a member |
| -// typing a bearer-token PUT must never be able to strip or reorder past a |
| -// rule that exists specifically to require more than one bearer token's |
| -// say-so. Tightening (raising a channel's own approvals, or adding a guard |
| -// where there was none) still goes straight through — only a channel the |
| -// stored pipeline ALREADY guards is closed off. |
| +// An edit that touches a channel whose CURRENTLY STORED rules already demand |
| +// change_approvals does not land from here: it becomes a PROPOSAL (202, |
| +// "finish in the console"), pinned to the pipeline it was composed against |
| +// and starting at zero confirmations, because a member typing a bearer-token |
| +// PUT must never be able to strip or reorder past a rule that exists |
| +// specifically to require more than one bearer token's say-so. Tightening |
| +// (raising a channel's own approvals, or adding a guard where there was none) |
| +// still goes straight through — only a channel the stored pipeline ALREADY |
| +// guards takes the quorum. |
| package console |
| import ( |
| @@ -30,7 +29,6 @@ import ( |
| "fmt" |
| "log" |
| "net/http" |
| - "net/url" |
| "strings" |
| "time" |
| @@ -144,12 +142,12 @@ func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) { |
| "approvals of %d exceed the account's %d member(s) — add members first", n, len(members))) |
| return |
| } |
| - if ch, n := guardedEdit(cur, in.Channels); n > 0 { |
| - writeJSON(w, http.StatusForbidden, map[string]string{ |
| - "error": fmt.Sprintf( |
| - "channel %s needs %d-member confirmation to change — finish in the console", ch, n), |
| - "console_url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app), |
| - }) |
| + if _, n := guardedEdit(cur, in.Channels); n > 0 { |
| + // The guard is met by a QUORUM, not refused outright (release |
| + // pipelines v2 §4): the edit becomes a proposal pinned to the |
| + // pipeline it was composed against, at zero confirmations, and the |
| + // members finish it in the console with their passkeys. |
| + s.proposeOverAPI(w, acct, app, kindPolicy, policyProposal(cur, in.Channels), n, addr) |
| return |
| } |
| set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, in.Channels, addr) |
| @@ -167,18 +165,20 @@ func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| s.ensureProductionForBakes(ctx, acct, app, set, addr) |
| - s.audit(acct, app, addr, "pipeline", pipelineAuditDetail(set)) |
| + s.audit(acct, app, addr, "pipeline", pipelineAuditDetail(set, "cli")) |
| // Box-consumed (carlos-deploy §3): boxes read pipeline.json on the same |
| // fast pass channel pointers and config already ride. |
| s.bumpSerial(ctx) |
| writeJSON(w, http.StatusOK, map[string]any{"pipeline": set}) |
| } |
| -// guardedEdit is the interim confirmation gate (this file's doc comment; |
| -// Task 8 replaces it with the real quorum flow). It reports the first |
| -// channel in cur whose CURRENTLY STORED rules demand change_approvals >= 1 |
| -// and whose entry next does not preserve, plus that channel's quorum — or |
| -// ("", 0) when nothing guarded was touched. |
| +// guardedEdit is the confirmation gate on a policy write. It reports the |
| +// channel in cur whose CURRENTLY STORED rules demand the HIGHEST |
| +// change_approvals among those this edit touches, and that quorum — or |
| +// ("", 0) when nothing guarded was touched. The whole edit then proposes at |
| +// that quorum (handleAPIPipelinePut): an edit touching two guarded channels |
| +// must meet the stricter of them, or the looser one would be the way past the |
| +// stricter in the same request. |
| // |
| // cur is nil for an app that has never had a pipeline (GetPipeline's |
| // (nil, nil) case): nothing is guarded yet, so nothing can be touched. |
| @@ -192,19 +192,29 @@ func (s *Server) handleAPIPipelinePut(w http.ResponseWriter, r *http.Request) { |
| // is the loophole around every rule it carries." Only a channel cur did NOT |
| // already guard is free to be added, removed or reordered without |
| // confirmation. |
| +// |
| +// One boundary this DELIBERATELY leaves open, named here so it reads as |
| +// chosen rather than missed: swapping a guarded channel's PREDECESSOR changes |
| +// what may be promoted into it, and is not itself an edit to its entry. Spec |
| +// §2 scopes the guard to "this channel's entry in the pipeline", and the |
| +// predecessor's own entry is the thing being edited — so a predecessor swap |
| +// passes unless that predecessor is itself guarded. The promotion into the |
| +// guarded channel still meets every rule the guarded channel carries, which |
| +// is where its protection actually lives. |
| func guardedEdit(cur *release.Pipeline, next []release.PipelineChannel) (string, int) { |
| if cur == nil { |
| return "", 0 |
| } |
| + name, quorum := "", 0 |
| for i, c := range cur.Channels { |
| - if c.Rules.ChangeApprovals < 1 { |
| + if c.Rules.ChangeApprovals < 1 || c.Rules.ChangeApprovals <= quorum { |
| continue |
| } |
| if i >= len(next) || next[i] != c { |
| - return c.Name, c.Rules.ChangeApprovals |
| + name, quorum = c.Name, c.Rules.ChangeApprovals |
| } |
| } |
| - return "", 0 |
| + return name, quorum |
| } |
| // ensureProductionForBakes is spec §3's old-binary mitigation: enabling a |
| @@ -297,7 +307,9 @@ func classifySetPipelineError(err error) (int, string) { |
| // tell a one-line typo fix from a whole new ladder without opening the |
| // bucket object themselves — and this row documents a POLICY rewrite, not a |
| // routine write like a config var. |
| -func pipelineAuditDetail(p *release.Pipeline) string { |
| +// via names the door the write came through — "cli" for a direct PUT, or |
| +// what a confirmed proposal was proposed by and how many members signed off. |
| +func pipelineAuditDetail(p *release.Pipeline, via string) string { |
| names := make([]string, len(p.Channels)) |
| for i, c := range p.Channels { |
| names[i] = c.Name |
| @@ -306,7 +318,8 @@ func pipelineAuditDetail(p *release.Pipeline) string { |
| if len(p.Channels) == 1 { |
| suffix = "" |
| } |
| - return fmt.Sprintf("set the pipeline (%s; %d channel%s, cli)", strings.Join(names, " → "), len(p.Channels), suffix) |
| + return fmt.Sprintf("set the pipeline (%s; %d channel%s, %s)", |
| + strings.Join(names, " → "), len(p.Channels), suffix, via) |
| } |
| // holdWindow is the pipeline-aware form of release.HoldFor (bake.go), for |
diff --git a/internal/console/apipipeline_test.go b/internal/console/apipipeline_test.go| index 186c4be4c..74cdc8824 100644 |
| --- a/internal/console/apipipeline_test.go |
| +++ b/internal/console/apipipeline_test.go |
| @@ -138,14 +138,18 @@ func TestAPIPipelineGetUnreadableIsNot502(t *testing.T) { |
| } |
| } |
| -// TestAPIPipelinePutGuardedChannelInterimRefusal: until Task 8 builds the |
| -// real quorum flow, a PUT that edits, removes or reorders a channel whose |
| -// CURRENT stored rules already carry change_approvals >= 1 is refused (403) |
| -// rather than silently applied — the exact loophole spec §2 calls out |
| -// ("removing the channel or moving its position ... counts as an edit to |
| -// its entry"). Adding a guard where there was none is a tightening edit |
| -// against an UNGUARDED channel and goes straight through. |
| -func TestAPIPipelinePutGuardedChannelInterimRefusal(t *testing.T) { |
| +// TestAPIPipelinePutGuardedChannelProposes: a PUT that edits, removes or |
| +// reorders a channel whose CURRENT stored rules already carry |
| +// change_approvals >= 1 does not land — it becomes a pending change (202) |
| +// for the members to confirm in the console, which is the exact loophole |
| +// spec §2 calls out ("removing the channel or moving its position ... counts |
| +// as an edit to its entry"). Adding a guard where there was none is a |
| +// tightening edit against an UNGUARDED channel and goes straight through. |
| +// |
| +// The refusal this replaces was Task 6's interim 403, deliberately temporary |
| +// until the pending-change flow existed; what is pinned here — that no |
| +// guarded edit ever lands from a bearer token alone — is unchanged. |
| +func TestAPIPipelinePutGuardedChannelProposes(t *testing.T) { |
| fx := newConsoleFixture(t) |
| // A second member so a change_approvals of 2 (the re-tightening case |
| // below) is not itself refused by the member-count ceiling — this test |
| @@ -173,51 +177,56 @@ func TestAPIPipelinePutGuardedChannelInterimRefusal(t *testing.T) { |
| } |
| // Editing the guarded channel's bake — leaving its own guard untouched — |
| - // is refused: 403, naming the quorum and pointing at the console. |
| + // proposes: 202, naming the quorum, at zero confirmations, pointing at |
| + // the console where the passkeys are. |
| status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| map[string]any{"channels": []map[string]any{ |
| {"name": "edge"}, |
| {"name": "production", "bake": "1h", "rules": map[string]any{"change_approvals": 1}}, |
| }}, token) |
| - if status != http.StatusForbidden { |
| - t.Fatalf("guarded edit = %d %v, want 403", status, out) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("guarded edit = %d %v, want 202", status, out) |
| + } |
| + if str(t, out, "result") != "proposed" || str(t, out, "pending_id") == "" { |
| + t.Fatalf("guarded edit = %v, want a proposal with a pending_id", out) |
| } |
| - if !strings.Contains(str(t, out, "error"), "1-member confirmation") { |
| - t.Fatalf("guarded edit error = %v, want it to name the quorum", out["error"]) |
| + if num(t, out, "needed") != 1 || num(t, out, "confirmed") != 0 { |
| + t.Fatalf("guarded edit = %v, want needed 1 / confirmed 0 — a token never counts", out) |
| } |
| if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") { |
| t.Fatalf("guarded edit console_url = %v, want the app page", out["console_url"]) |
| } |
| - // Removing the guarded channel is refused the same way. |
| + // Removing the guarded channel proposes the same way. |
| status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| map[string]any{"channels": []map[string]any{{"name": "edge"}}}, token) |
| - if status != http.StatusForbidden { |
| - t.Fatalf("removing the guarded channel = %d %v, want 403", status, out) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("removing the guarded channel = %d %v, want 202", status, out) |
| } |
| - // Reordering it is refused the same way. |
| + // Reordering it proposes the same way. |
| status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| map[string]any{"channels": []map[string]any{ |
| {"name": "production", "rules": map[string]any{"change_approvals": 1}}, |
| {"name": "edge"}, |
| }}, token) |
| - if status != http.StatusForbidden { |
| - t.Fatalf("reordering the guarded channel = %d %v, want 403", status, out) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("reordering the guarded channel = %d %v, want 202", status, out) |
| } |
| - // Re-tightening it further (raising its OWN approvals count) is refused |
| + // Re-tightening it further (raising its OWN approvals count) proposes |
| // too — the CURRENT stored rule is what gates, not the proposed one. |
| status, out = putJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", |
| map[string]any{"channels": []map[string]any{ |
| {"name": "edge"}, |
| {"name": "production", "rules": map[string]any{"change_approvals": 2}}, |
| }}, token) |
| - if status != http.StatusForbidden { |
| - t.Fatalf("re-tightening the guarded channel = %d %v, want 403", status, out) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("re-tightening the guarded channel = %d %v, want 202", status, out) |
| } |
| - // None of the refused attempts moved the stored pipeline. |
| + // None of the proposals moved the stored pipeline: a proposal is a row in |
| + // the console's database, never a write to the bucket. |
| status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pipeline", token) |
| if status != http.StatusOK { |
| t.Fatalf("pipeline get = %d %v", status, out) |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 1a4689944..7343e2964 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -450,6 +450,12 @@ func (s *Server) registerCLIRoutes() { |
| // the ledgers and store families. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Publish), s.handleAPIPipelineGet) |
| s.cliRoute("PUT /api/cli/apps/{account}/{app}/pipeline", svcrole.Allow(svcrole.Admin), s.handleAPIPipelinePut) |
| + // What the app is waiting on: the proposals a gated promote, fast-track |
| + // or policy edit turned into (pendinghandlers.go). Read-only, and Publish |
| + // like channels and releases — a proposal names no secret, and the CLI |
| + // needs to see one precisely because it can never confirm one itself |
| + // (spec §4: confirming takes a passkey, in the console). |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/pending", svcrole.Allow(svcrole.Publish), s.handleAPIPending) |
| // Trash and restore (apicli.go). Delete resolves through the LIVE-apps |
| // door and restore through the trashed-apps one, so each verb answers |
| // only for an app in the state it can act on — and both give a stranger |
diff --git a/internal/console/pendingflow_test.go b/internal/console/pendingflow_test.go| new file mode 100644 |
| index 000000000..7d9789c75 |
| --- /dev/null |
| +++ b/internal/console/pendingflow_test.go |
| @@ -0,0 +1,503 @@ |
| +package console |
| + |
| +// Per-channel rules at the console's gates, and the pending-change flow they |
| +// open onto — release pipelines v2 §4/§5. The properties, in the order they |
| +// would hurt if lost: |
| +// |
| +// 1. A channel's declared rules decide what a promotion into it costs: a |
| +// passkey rule refuses the bearer-token door outright, an approvals rule |
| +// turns it into a PROPOSAL that has moved nothing. |
| +// 2. Quorum, once met by real passkey-backed confirmations, is the ONE place |
| +// the gated write happens — and it happens exactly once. |
| +// 3. An app with NO pipeline is untouched, byte for byte, including the |
| +// legacy stable refusal every CLI in the field already parses. |
| +// 4. A policy proposal pins the pipeline it was proposed against; a pipeline |
| +// that moved underneath it invalidates it rather than silently landing a |
| +// stale channel list. |
| +// 5. -hotfix bypasses ORDER only. It never buys past a rule. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// setPipeline installs an app's pipeline through the real signed primitive — |
| +// fixture state, arranged the way the console will read it, not a promotion |
| +// or a policy edit under test. |
| +func setPipeline(t *testing.T, fx *consoleFixture, app string, channels ...release.PipelineChannel) *release.Pipeline { |
| + t.Helper() |
| + p, err := release.SetPipeline(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, app, channels, "fixture") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return p |
| +} |
| + |
| +// pipelinedApp is the world every test below needs: a claimed app, one |
| +// shipped release, that release sitting on the pipeline's entry channel, and |
| +// the pipeline itself. |
| +func pipelinedApp(t *testing.T, fx *consoleFixture, app, version string, channels ...release.PipelineChannel) { |
| + t.Helper() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, app, version) |
| + seedChannel(t, fx, app, "edge", version) |
| + setPipeline(t, fx, app, channels...) |
| +} |
| + |
| +// num reads a JSON number off a decoded body — every count on this wire |
| +// (needed, confirmed) arrives as a float64. |
| +func num(t *testing.T, m map[string]any, k string) int { |
| + t.Helper() |
| + v, _ := m[k].(float64) |
| + return int(v) |
| +} |
| + |
| +// channelAbsent asserts a channel pointer does not exist — the assertion that |
| +// makes a refusal true rather than merely spoken. |
| +func channelAbsent(t *testing.T, fx *consoleFixture, app, channel string) { |
| + t.Helper() |
| + if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, app, channel); err == nil { |
| + t.Fatalf("channel %s exists after a refused promotion, want it absent", channel) |
| + } |
| +} |
| + |
| +// TestPipelinePromoteRulesLadder: the target channel's OWN rules decide what |
| +// the CLI door does — refuse, propose, or promote — and neither refusal nor |
| +// proposal moves anything. |
| +func TestPipelinePromoteRulesLadder(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + // A second member, so promote_approvals: 2 is a quorum this account could |
| + // actually reach. |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + pipelinedApp(t, fx, "widget", "abc1234", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-rules-ladder") |
| + promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote" |
| + body := map[string]any{"version": "abc1234", "channel": "production"} |
| + |
| + // A passkey rule: there is no passkey at this door, so it refuses and |
| + // names the one that has one. |
| + status, out := postJSON(t, promoteURL, body, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("promote into a passkey channel = %d %v, want 403", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "production needs a passkey") { |
| + t.Fatalf("passkey refusal = %v, want it to name the channel and the passkey", out["error"]) |
| + } |
| + if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") { |
| + t.Fatalf("passkey refusal console_url = %v, want the app page", out["console_url"]) |
| + } |
| + channelAbsent(t, fx, "widget", "production") |
| + |
| + // An approvals rule: the door PROPOSES, at zero confirmations — a bearer |
| + // token never counts toward a passkey quorum (spec §4). |
| + setPipeline(t, fx, "widget", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| + before := serial(t, fx) |
| + status, out = postJSON(t, promoteURL, body, token) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("promote into an approvals channel = %d %v, want 202", status, out) |
| + } |
| + if str(t, out, "result") != "proposed" || str(t, out, "pending_id") == "" { |
| + t.Fatalf("proposal = %v, want result proposed with a pending_id", out) |
| + } |
| + if num(t, out, "needed") != 2 || num(t, out, "confirmed") != 0 { |
| + t.Fatalf("proposal = %v, want needed 2 / confirmed 0", out) |
| + } |
| + if !strings.Contains(str(t, out, "console_url"), "/a/bab/apps/widget") { |
| + t.Fatalf("proposal console_url = %v, want the app page", out["console_url"]) |
| + } |
| + channelAbsent(t, fx, "widget", "production") |
| + if got := serial(t, fx); got != before { |
| + t.Fatalf("serial after a proposal = %d, want it unmoved at %d — nothing was written for a box to read", got, before) |
| + } |
| + |
| + // And the CLI can see what it proposed. |
| + status, out = getJSON(t, fx.ts.URL+"/api/cli/apps/bab/widget/pending", token) |
| + if status != http.StatusOK { |
| + t.Fatalf("pending list = %d %v", status, out) |
| + } |
| + list, _ := out["pending"].([]any) |
| + if len(list) != 1 { |
| + t.Fatalf("pending list = %v, want exactly the one proposal", out["pending"]) |
| + } |
| + row, _ := list[0].(map[string]any) |
| + if str(t, row, "kind") != "promote" || num(t, row, "needed") != 2 || num(t, row, "confirmed") != 0 { |
| + t.Fatalf("pending row = %v, want a promote at 0/2", row) |
| + } |
| + if !strings.Contains(str(t, row, "summary"), "abc1234") || !strings.Contains(str(t, row, "summary"), "production") { |
| + t.Fatalf("pending summary = %q, want it to name the version and the channel", row["summary"]) |
| + } |
| + |
| + // An UNRULED channel of the same pipeline promotes as it always did. |
| + setPipeline(t, fx, "widget", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production"}) |
| + status, out = postJSON(t, promoteURL, body, token) |
| + if status != http.StatusOK || str(t, out, "result") != "promoted" { |
| + t.Fatalf("promote into an unruled channel = %d %v, want a plain 200 promotion", status, out) |
| + } |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { |
| + t.Fatalf("production serves %q, want abc1234", got.Version) |
| + } |
| +} |
| + |
| +// TestPipelineQuorumExecutesPromotion walks the whole two-member flow in the |
| +// browser: a passkey-backed proposal counts its proposer, the second member's |
| +// own passkey meets the quorum, and THAT is where the pointer moves. |
| +func TestPipelineQuorumExecutesPromotion(t *testing.T) { |
| + fx, c, csrf := memberFixture(t) // paul owns, kim is a member |
| + pipelinedApp(t, fx, "widget", "abc1234", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| + |
| + status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), |
| + url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("owner proposal = %d -> %q:\n%s", status, loc, body) |
| + } |
| + pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(pending) != 1 { |
| + t.Fatalf("%d pending changes after a proposal, want 1", len(pending)) |
| + } |
| + pc := pending[0] |
| + if pc.Kind != "promote" || pc.Quorum != 2 { |
| + t.Fatalf("pending change = %+v, want a promote needing 2", pc) |
| + } |
| + if len(pc.Confirms) != 1 || pc.Confirms[0] != "paul@keymail.dev" { |
| + t.Fatalf("confirmations = %v, want the passkey-backed proposer counted once", pc.Confirms) |
| + } |
| + channelAbsent(t, fx, "widget", "production") |
| + |
| + // The second member confirms with their OWN passkey. |
| + kim := newBrowser(t) |
| + fx.setUser("kim@keymail.dev") |
| + signUp(t, fx, kim, "code-kim-confirm") |
| + kimCSRF := sessionToken(t, fx, kim) |
| + before := serial(t, fx) |
| + confirmURL := fx.u("/apps/widget/pending/" + pc.ID + "/confirm") |
| + status, loc, body = postGated(t, fx, kim, confirmURL, url.Values{"csrf": {kimCSRF}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { |
| + t.Fatalf("production serves %q after quorum, want abc1234", got.Version) |
| + } |
| + if got := serial(t, fx); got != before+1 { |
| + t.Fatalf("serial after an executed promotion = %d, want %d", got, before+1) |
| + } |
| + left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(left) != 0 { |
| + t.Fatalf("%d pending changes after execution, want the executed one gone", len(left)) |
| + } |
| + |
| + // Confirming it again is the same 404 an id that never existed gets: an |
| + // executed change is not pending, and a second confirmation must not be |
| + // able to replay the write. |
| + status, loc, _ = postForm(t, kim, confirmURL, url.Values{"csrf": {kimCSRF}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("re-confirming an executed change = %d -> %q, want 404", status, loc) |
| + } |
| +} |
| + |
| +// TestPipelineLegacyAppsUntouched: an app with no pipeline meets exactly |
| +// today's doors. The stable refusal is the one every CLI in the field parses, |
| +// it stays byte-identical between a member and an owner, and a hotfix flag |
| +// buys nothing at it. |
| +func TestPipelineLegacyAppsUntouched(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "titogo", "abc1234") |
| + if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-legacy-owner") |
| + member := cliToken(t, fx, "alice@keymail.dev", "code-legacy-member") |
| + promoteURL := fx.ts.URL + "/api/cli/apps/bab/titogo/promote" |
| + |
| + body := map[string]any{"version": "abc1234", "channel": "stable"} |
| + statusO, outO := postRaw(t, promoteURL, body, owner) |
| + statusM, outM := postRaw(t, promoteURL, body, member) |
| + if statusO != http.StatusForbidden || statusM != http.StatusForbidden { |
| + t.Fatalf("legacy stable promote = owner %d, member %d, want both 403", statusO, statusM) |
| + } |
| + if outO != outM { |
| + t.Fatalf("legacy stable refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM) |
| + } |
| + if !strings.Contains(outO, "stable needs a passkey — finish in the console") { |
| + t.Fatalf("legacy stable refusal = %q, want today's exact sentence", outO) |
| + } |
| + channelAbsent(t, fx, "titogo", "stable") |
| + |
| + // The new hotfix field changes nothing about it: rules are read before |
| + // the flag ever is, and a pipeline-less app's stable rung is a rule. |
| + statusH, outH := postRaw(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "stable", "hotfix": true}, owner) |
| + if statusH != http.StatusForbidden || outH != outO { |
| + t.Fatalf("hotfixed legacy stable promote = %d %q, want the same 403 %q", statusH, outH, outO) |
| + } |
| + channelAbsent(t, fx, "titogo", "stable") |
| + |
| + // And below stable the legacy ladder still promotes, untouched. |
| + status, out := postJSON(t, promoteURL, map[string]any{"version": "abc1234", "channel": "edge"}, owner) |
| + if status != http.StatusOK || str(t, out, "result") != "promoted" { |
| + t.Fatalf("legacy edge promote = %d %v, want a plain 200 promotion", status, out) |
| + } |
| + if got := fx.channelVersion(t, "titogo", "edge"); got.Version != "abc1234" { |
| + t.Fatalf("edge serves %q, want abc1234", got.Version) |
| + } |
| +} |
| + |
| +// TestPolicyEditPinsSetAt: a policy proposal pins the pipeline it was read |
| +// against. A DIFFERENT write landing in between invalidates it — the channel |
| +// list it carries describes a world that no longer exists, and quietly |
| +// applying it would silently revert whatever landed meanwhile. |
| +func TestPolicyEditPinsSetAt(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + setPipeline(t, fx, "widget", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Bake: "24h", |
| + Rules: release.ChannelRules{ChangeApprovals: 1}}) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-policy-pin") |
| + pipelineURL := fx.ts.URL + "/api/cli/apps/bab/widget/pipeline" |
| + |
| + // A guarded edit proposes instead of landing. |
| + status, out := putJSON(t, pipelineURL, map[string]any{"channels": []map[string]any{ |
| + {"name": "edge"}, |
| + {"name": "production", "bake": "1h", "rules": map[string]any{"change_approvals": 1}}, |
| + }}, token) |
| + if status != http.StatusAccepted { |
| + t.Fatalf("guarded pipeline edit = %d %v, want 202", status, out) |
| + } |
| + id := str(t, out, "pending_id") |
| + if id == "" || num(t, out, "needed") != 1 || num(t, out, "confirmed") != 0 { |
| + t.Fatalf("policy proposal = %v, want a pending_id at 0/1", out) |
| + } |
| + |
| + // An UNGUARDED edit — production's own entry preserved exactly — lands |
| + // straight away, and moves the pipeline underneath the proposal. |
| + status, out = putJSON(t, pipelineURL, map[string]any{"channels": []map[string]any{ |
| + {"name": "edge", "bake": "30m"}, |
| + {"name": "production", "bake": "24h", "rules": map[string]any{"change_approvals": 1}}, |
| + }}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("unguarded pipeline edit = %d %v, want 200", status, out) |
| + } |
| + |
| + // Confirming the original to quorum: execution refuses. |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-policy-confirm") |
| + csrf := sessionToken(t, fx, c) |
| + status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/pending/"+id+"/confirm"), |
| + url.Values{"csrf": {csrf}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("confirming a stale policy proposal = %d -> %q:\n%s", status, loc, body) |
| + } |
| + p, err := release.GetPipeline(context.Background(), fx.rel, fx.pub, release.OpsAccount, "widget") |
| + if err != nil || p == nil { |
| + t.Fatalf("pipeline after the refused execution = %v, %v", p, err) |
| + } |
| + if len(p.Channels) != 2 || p.Channels[0].Bake != "30m" || p.Channels[1].Bake != "24h" { |
| + t.Fatalf("pipeline after the refused execution = %+v, want the unguarded edit intact", p.Channels) |
| + } |
| + // The change stays: a refused execution is retryable, not consumed. |
| + left, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(left) != 1 || left[0].ID != id { |
| + t.Fatalf("pending changes after a refused execution = %v, want the proposal still there", left) |
| + } |
| +} |
| + |
| +// TestFastTrackProposesWhenGuarded: change_approvals covers overriding a |
| +// bake as well as editing a channel's entry (spec §2), and the waiver it |
| +// gates is pinned to the build it was proposed for. |
| +func TestFastTrackProposesWhenGuarded(t *testing.T) { |
| + fx, c, csrf := memberFixture(t) // paul owns, kim is a member |
| + pipelinedApp(t, fx, "widget", "abc1234", |
| + release.PipelineChannel{Name: "edge", Bake: "24h", |
| + Rules: release.ChannelRules{ChangeApprovals: 2}}) |
| + |
| + status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/fasttrack"), |
| + url.Values{"csrf": {csrf}, "channel": {"edge"}, "confirmed": {"1"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("owner fast-track proposal = %d -> %q:\n%s", status, loc, body) |
| + } |
| + if p := fx.channelVersion(t, "widget", "edge"); p.FastTrack { |
| + t.Fatal("the waiver landed on one owner's say-so despite change_approvals: 2") |
| + } |
| + pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(pending) != 1 || pending[0].Kind != "fasttrack" || pending[0].Quorum != 2 { |
| + t.Fatalf("pending = %+v, want one fasttrack needing 2", pending) |
| + } |
| + if !strings.Contains(pendingSummary(pending[0]), "abc1234") { |
| + t.Fatalf("summary %q does not name the pinned build", pendingSummary(pending[0])) |
| + } |
| + |
| + kim := newBrowser(t) |
| + fx.setUser("kim@keymail.dev") |
| + signUp(t, fx, kim, "code-kim-fasttrack") |
| + kimCSRF := sessionToken(t, fx, kim) |
| + status, loc, body = postGated(t, fx, kim, |
| + fx.u("/apps/widget/pending/"+pending[0].ID+"/confirm"), url.Values{"csrf": {kimCSRF}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("second confirmation = %d -> %q:\n%s", status, loc, body) |
| + } |
| + p := fx.channelVersion(t, "widget", "edge") |
| + if !p.FastTrack { |
| + t.Fatal("edge does not wear the waiver after quorum") |
| + } |
| + if len(p.Audit) == 0 || !p.Audit[0].FastTrack { |
| + t.Fatalf("pointer audit = %+v, want the waiver recorded", p.Audit) |
| + } |
| +} |
| + |
| +// TestPendingDoorsAreNotAnOracle: a pending change is reachable only by a |
| +// member of the account that owns it, only under its own app, and only for |
| +// rejection by its proposer or an owner. Every other shape is the same 404 an |
| +// id that never existed gets — ConfirmChange itself takes no account id, so |
| +// these doors ARE the authorization boundary. |
| +func TestPendingDoorsAreNotAnOracle(t *testing.T) { |
| + fx, c, csrf := memberFixture(t) // paul owns bab, kim is a member of it |
| + pipelinedApp(t, fx, "widget", "abc1234", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "other", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A second account entirely, with a change of its own. |
| + if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + theirs, err := fx.st.ProposeChange(2, "widget", "promote", |
| + []byte(`{"version":"abc1234","channel":"production"}`), 2, "bob@keymail.dev", false, time.Now()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, loc, body := postGated(t, fx, c, fx.u("/apps/widget/promote"), |
| + url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}, "confirmed": {"1"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("proposal = %d -> %q:\n%s", status, loc, body) |
| + } |
| + pending, err := fx.st.PendingChangesFor(OpsAccountID, "widget", time.Now()) |
| + if err != nil || len(pending) != 1 { |
| + t.Fatalf("pending = %v, %v, want exactly one", pending, err) |
| + } |
| + id := pending[0].ID |
| + |
| + // An id that never existed sets the baseline refusal. |
| + _, _, unknown := postForm(t, c, fx.u("/apps/widget/pending/deadbeef/confirm"), url.Values{"csrf": {csrf}}) |
| + for name, u := range map[string]string{ |
| + "another account's change": fx.u("/apps/widget/pending/" + theirs.ID + "/confirm"), |
| + "the wrong app": fx.u("/apps/other/pending/" + id + "/confirm"), |
| + } { |
| + status, _, got := postForm(t, c, u, url.Values{"csrf": {csrf}}) |
| + if status != http.StatusNotFound || got != unknown { |
| + t.Fatalf("%s = %d, body %q, want the same 404 an unknown id gets", name, status, got) |
| + } |
| + } |
| + // None of that recorded a confirmation anywhere. |
| + if pc, _ := fx.st.PendingChange(theirs.ID, time.Now()); pc == nil || len(pc.Confirms) != 0 { |
| + t.Fatalf("the other account's change = %+v, want it untouched at zero confirmations", pc) |
| + } |
| + |
| + // A member who is neither proposer nor owner cannot withdraw it. |
| + kim := newBrowser(t) |
| + fx.setUser("kim@keymail.dev") |
| + signUp(t, fx, kim, "code-kim-reject") |
| + kimCSRF := sessionToken(t, fx, kim) |
| + status, _, got := postForm(t, kim, fx.u("/apps/widget/pending/"+id+"/reject"), url.Values{"csrf": {kimCSRF}}) |
| + if status != http.StatusNotFound || got != unknown { |
| + t.Fatalf("a bystander rejecting = %d, body %q, want the unknown-id 404", status, got) |
| + } |
| + if pc, _ := fx.st.PendingChange(id, time.Now()); pc == nil { |
| + t.Fatal("a bystander's rejection deleted the change") |
| + } |
| + |
| + // The proposer withdraws it — no passkey needed to REFUSE authority. |
| + status, _, body = postForm(t, c, fx.u("/apps/widget/pending/"+id+"/reject"), url.Values{"csrf": {csrf}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("proposer rejecting = %d:\n%s", status, body) |
| + } |
| + if pc, _ := fx.st.PendingChange(id, time.Now()); pc != nil { |
| + t.Fatalf("the change survived its proposer's rejection: %+v", pc) |
| + } |
| + channelAbsent(t, fx, "widget", "production") |
| +} |
| + |
| +// TestHotfixBypassesOrderNotRules is spec §5's exact sentence, twice over: |
| +// the flag never buys past a channel's rules, and it does bypass the order |
| +// on a channel that carries none. |
| +func TestHotfixBypassesOrderNotRules(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + pipelinedApp(t, fx, "widget", "abc1234", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}) |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-hotfix-rules") |
| + promoteURL := fx.ts.URL + "/api/cli/apps/bab/widget/promote" |
| + |
| + status, out := postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("hotfixed promote into a passkey channel = %d %v, want the 403 passkey refusal", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "passkey") { |
| + t.Fatalf("hotfixed refusal = %v, want it to name the passkey", out["error"]) |
| + } |
| + channelAbsent(t, fx, "widget", "production") |
| + |
| + // An out-of-order target with no rules at all: refused without the flag, |
| + // allowed with it. |
| + setPipeline(t, fx, "widget", |
| + release.PipelineChannel{Name: "edge"}, |
| + release.PipelineChannel{Name: "beta"}, |
| + release.PipelineChannel{Name: "production"}) |
| + status, out = postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "production"}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("out-of-order promote = %d %v, want 409", status, out) |
| + } |
| + if !strings.Contains(str(t, out, "error"), "beta") { |
| + t.Fatalf("order refusal = %v, want it to name the predecessor", out["error"]) |
| + } |
| + channelAbsent(t, fx, "widget", "production") |
| + |
| + status, out = postJSON(t, promoteURL, |
| + map[string]any{"version": "abc1234", "channel": "production", "hotfix": true}, token) |
| + if status != http.StatusOK || str(t, out, "result") != "promoted" { |
| + t.Fatalf("hotfixed out-of-order promote = %d %v, want 200 promoted", status, out) |
| + } |
| + if got := fx.channelVersion(t, "widget", "production"); got.Version != "abc1234" { |
| + t.Fatalf("production serves %q, want abc1234", got.Version) |
| + } |
| +} |
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go| new file mode 100644 |
| index 000000000..0c8bc9a4f |
| --- /dev/null |
| +++ b/internal/console/pendinghandlers.go |
| @@ -0,0 +1,553 @@ |
| +// The doors onto the pending-change primitive (pending.go): proposing a |
| +// gated action, confirming one until its quorum is met, rejecting one, and — |
| +// the only place any of it actually writes to the bucket — executing it. |
| +// Release pipelines v2 §4/§5 |
| +// (docs/superpowers/specs/2026-08-20-release-pipelines-v2-design.md). |
| +// |
| +// Three shapes live here, and the difference between them is the whole |
| +// security story: |
| +// |
| +// - proposeOverAPI is the bearer-token shape. It starts at ZERO |
| +// confirmations however many members the quorum names, because a token |
| +// has proved nobody is present (spec §4). Its answer is 202 and a console |
| +// URL: "finish where the passkeys are". |
| +// - proposeThenExecute is the browser shape, and is only ever called from |
| +// BEHIND requireFreshAuth. That step-up is what earns the proposer their |
| +// own first confirmation — and when the quorum is 1, the change is |
| +// already met on creation and is carried out in the same request, exactly |
| +// as a passkey rule would have been. |
| +// - executePending is where a quorum-met change becomes a signed write. It |
| +// re-checks the world against the payload the proposal PINNED, so a |
| +// pipeline or a channel head that moved underneath a week-old proposal |
| +// invalidates it instead of landing something nobody approved. A failed |
| +// execution leaves the change unexecuted: it is retryable, never consumed. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/subtle" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The three kinds of gated action, spelled once. They are stored in the |
| +// database, so they are a wire shape in the sense that matters here: a row |
| +// written before a rename must still be executable after it. |
| +const ( |
| + kindPromote = "promote" |
| + kindPolicy = "policy" |
| + kindFastTrack = "fasttrack" |
| +) |
| + |
| +// pendingTimeout bounds the store round trips an execution makes — the same |
| +// budget the promote and pipeline doors give the bucket. |
| +const pendingTimeout = 15 * time.Second |
| + |
| +// promotePayload pins a promotion: this VERSION onto this CHANNEL, never |
| +// "whatever the head is when somebody gets around to confirming" (spec §4). |
| +// Hotfix rides along because it changes what execution is allowed to |
| +// re-check — order, and only order. |
| +type promotePayload struct { |
| + Version string `json:"version"` |
| + Channel string `json:"channel"` |
| + Hotfix bool `json:"hotfix,omitempty"` |
| +} |
| + |
| +// policyPayload pins a pipeline edit: the full proposed channel list, plus |
| +// the SetAt of the pipeline it was composed against. Execution refuses on a |
| +// mismatch — a stale channel list applied blind would silently revert |
| +// whatever landed in between. |
| +type policyPayload struct { |
| + Channels []release.PipelineChannel `json:"channels"` |
| + ObservedSetAt time.Time `json:"observed_set_at"` |
| +} |
| + |
| +// fastTrackPayload pins a waiver to the version it was proposed for. |
| +// release.FastTrack acts on the channel's CURRENT head by contract, so |
| +// execution compares that head with this pin first; without the comparison |
| +// the pin would be decoration and a confirmation would waive a build nobody |
| +// looked at. |
| +type fastTrackPayload struct { |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| +} |
| + |
| +// appConsoleURL is where a refusal or a proposal points a CLI: the app page, |
| +// which is where the passkeys are. One spelling, because these strings are |
| +// compared byte-for-byte by tests that exist to prove a member and an owner |
| +// get the same refusal. |
| +func (s *Server) appConsoleURL(acct *Account, app string) string { |
| + return s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| +} |
| + |
| +// appPath is appConsoleURL's origin-relative form, for the redirects a |
| +// browser door ends on. |
| +func appPath(acct *Account, app string) string { |
| + return "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| +} |
| + |
| +// channelRules is the policy a target channel carries. An app with no |
| +// pipeline, and a channel outside a declared one (canary/*, a -hotfix |
| +// target), carry none — which is spec §2's default-permissive rule, not a |
| +// gap: the legacy ladder's own gates still apply to a pipeline-less app, and |
| +// they are enforced where they always were. |
| +func channelRules(p *release.Pipeline, channel string) release.ChannelRules { |
| + if p == nil { |
| + return release.ChannelRules{} |
| + } |
| + if c, ok := p.ChannelNamed(channel); ok { |
| + return c.Rules |
| + } |
| + return release.ChannelRules{} |
| +} |
| + |
| +// pendingSummary is one line naming what a change would do — for the CLI's |
| +// list, the console's card, and the audit trail. It reads the PINNED payload |
| +// and nothing else, so it describes what would land rather than what the |
| +// world currently looks like. |
| +func pendingSummary(pc PendingChange) string { |
| + switch pc.Kind { |
| + case kindPromote: |
| + var p promotePayload |
| + if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| + return "promote " + pc.App |
| + } |
| + out := "promote " + p.Version + " to " + p.Channel |
| + if p.Hotfix { |
| + out += " (hotfix — out of order)" |
| + } |
| + return out |
| + case kindPolicy: |
| + var p policyPayload |
| + if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| + return "change the pipeline of " + pc.App |
| + } |
| + names := make([]string, len(p.Channels)) |
| + for i, c := range p.Channels { |
| + names[i] = c.Name |
| + } |
| + return "change the pipeline to " + strings.Join(names, " → ") |
| + case kindFastTrack: |
| + var p fastTrackPayload |
| + if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| + return "skip the safety delay on " + pc.App |
| + } |
| + return "skip the safety delay for " + p.Version + " on " + p.Channel |
| + } |
| + return pc.Kind + " on " + pc.App |
| +} |
| + |
| +// ---- proposing --------------------------------------------------------------- |
| + |
| +// proposeOverAPI records a bearer-token proposal and answers 202. Zero |
| +// confirmations, always: spec §4's load-bearing rule is that a token never |
| +// counts toward a passkey quorum, including for a quorum of one and |
| +// including when the same person could have confirmed it in a browser. |
| +func (s *Server) proposeOverAPI(w http.ResponseWriter, acct *Account, app, kind string, payload any, quorum int, proposer string) { |
| + raw, err := json.Marshal(payload) |
| + if err != nil { |
| + log.Printf("console: propose %s %s/%s: marshal: %v", kind, acct.Sqid, app, err) |
| + jsonError(w, http.StatusInternalServerError, "could not record the proposal — try again") |
| + return |
| + } |
| + pc, err := s.store.ProposeChange(acct.ID, app, kind, raw, quorum, proposer, false, time.Now()) |
| + if err != nil { |
| + log.Printf("console: propose %s %s/%s: %v", kind, acct.Sqid, app, err) |
| + jsonError(w, http.StatusInternalServerError, "could not record the proposal — try again") |
| + return |
| + } |
| + s.audit(acct, app, proposer, "proposed", pendingSummary(*pc)+ |
| + fmt.Sprintf(" (needs %d confirmation(s) in the console)", quorum)) |
| + writeJSON(w, http.StatusAccepted, map[string]any{ |
| + "result": "proposed", |
| + "pending_id": pc.ID, |
| + "needed": pc.Quorum, |
| + "confirmed": len(pc.Confirms), |
| + "console_url": s.appConsoleURL(acct, app), |
| + }) |
| +} |
| + |
| +// proposeThenExecute is the browser half. CALL IT ONLY FROM BEHIND |
| +// requireFreshAuth: proposerConfirmed=true is a claim that this proposal is |
| +// passkey-backed, and it is the difference between a quorum of humans and a |
| +// quorum of one person's session cookie. |
| +// |
| +// A quorum of 1 is met the moment the row exists, and the change is carried |
| +// out in the same request — with the proposer's presence just proved, there |
| +// is nobody left to ask, and parking it for a second confirmation nobody owes |
| +// would leave a "pending" card that only its own author can clear. |
| +// |
| +// Returns the flash kind and message the caller redirects with. |
| +func (s *Server) proposeThenExecute(ctx context.Context, acct *Account, app, kind string, payload any, quorum int, proposer string) (string, string) { |
| + raw, err := json.Marshal(payload) |
| + if err != nil { |
| + log.Printf("console: propose %s %s/%s: marshal: %v", kind, acct.Sqid, app, err) |
| + return "err", "Could not record that proposal. Please try again." |
| + } |
| + pc, err := s.store.ProposeChange(acct.ID, app, kind, raw, quorum, proposer, true, time.Now()) |
| + if err != nil { |
| + log.Printf("console: propose %s %s/%s: %v", kind, acct.Sqid, app, err) |
| + return "err", "Could not record that proposal. Please try again." |
| + } |
| + if len(pc.Confirms) < pc.Quorum { |
| + s.audit(acct, app, proposer, "proposed", fmt.Sprintf("%s (%d of %d confirmations)", |
| + pendingSummary(*pc), len(pc.Confirms), pc.Quorum)) |
| + return "ok", fmt.Sprintf("Proposed: %s. %d of %d confirmations — it happens when the rest confirm it here with their passkeys.", |
| + pendingSummary(*pc), len(pc.Confirms), pc.Quorum) |
| + } |
| + msg, err := s.executePending(ctx, acct, app, proposer, pc) |
| + if err != nil { |
| + return "err", "Could not carry that out: " + err.Error() |
| + } |
| + return "ok", msg |
| +} |
| + |
| +// policyProposal builds a policy payload against the pipeline it was composed |
| +// from. cur is nil for an app that has never had one, whose zero SetAt is |
| +// itself a legitimate pin: "there was nothing here when I proposed this". |
| +func policyProposal(cur *release.Pipeline, next []release.PipelineChannel) policyPayload { |
| + p := policyPayload{Channels: next} |
| + if cur != nil { |
| + p.ObservedSetAt = cur.SetAt |
| + } |
| + return p |
| +} |
| + |
| +// ---- executing --------------------------------------------------------------- |
| + |
| +// executePending carries out a change whose quorum is met — the ONE place a |
| +// gated write happens. Per kind: re-check the pin, write, announce, audit, |
| +// and only then mark the change executed. |
| +// |
| +// An error return means nothing landed and the change is still pending: a |
| +// caller must not mark it executed, and the member who confirmed it is told |
| +// why rather than left believing it went through. |
| +func (s *Server) executePending(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { |
| + if s.cfg.Key == nil { |
| + return "", errors.New("no signing key on this host — the console is running read-only") |
| + } |
| + var ( |
| + msg string |
| + err error |
| + ) |
| + switch pc.Kind { |
| + case kindPromote: |
| + msg, err = s.executePendingPromote(ctx, acct, app, actor, pc) |
| + case kindPolicy: |
| + msg, err = s.executePendingPolicy(ctx, acct, app, actor, pc) |
| + case kindFastTrack: |
| + msg, err = s.executePendingFastTrack(ctx, acct, app, actor, pc) |
| + default: |
| + return "", fmt.Errorf("this console does not know how to carry out a %q change", pc.Kind) |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + if err := s.store.MarkExecuted(pc.ID, time.Now()); err != nil { |
| + // The write above already landed. Failing the caller here would say |
| + // "that didn't work" about something that did; the cost of the miss |
| + // is a change that stays listed, and every kind's own pin refuses a |
| + // second execution of the same payload. |
| + log.Printf("console: mark pending change %s executed: %v", pc.ID, err) |
| + } |
| + return msg, nil |
| +} |
| + |
| +// staleProposal is the one refusal every kind shares: the world the proposal |
| +// pinned is not the world any more. |
| +func staleProposal(detail string) error { |
| + if detail == "" { |
| + return errors.New("the pipeline moved underneath this proposal — re-propose") |
| + } |
| + return errors.New("the pipeline moved underneath this proposal — re-propose (" + detail + ")") |
| +} |
| + |
| +func (s *Server) executePendingPromote(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { |
| + var p promotePayload |
| + if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| + return "", errors.New("this proposal's payload cannot be read — reject it and propose again") |
| + } |
| + pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: execute promote %s/%s: read pipeline: %v", acct.Sqid, app, err) |
| + return "", errors.New("could not read the pipeline") |
| + } |
| + // The nil guard is not defensive tidiness: VersionStage dereferences the |
| + // pipeline it is handed, and an app whose pipeline was deleted while a |
| + // proposal waited has no order left for that proposal to be judged |
| + // against. Refuse, rather than fall back to a ladder nobody approved. |
| + if pipe == nil { |
| + return "", staleProposal("this app no longer has a pipeline") |
| + } |
| + if !p.Hotfix { |
| + stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, p.Version, pipe) |
| + if serr != nil { |
| + log.Printf("console: execute promote %s/%s: stage: %v", acct.Sqid, app, serr) |
| + return "", errors.New("could not work out where this build sits in the pipeline") |
| + } |
| + if aerr := release.PipelinePromotionAllowed(pipe, stage, p.Channel); aerr != nil { |
| + return "", staleProposal(aerr.Error()) |
| + } |
| + } |
| + res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channel, p.Version, |
| + release.PromoteOptions{Actor: actor, Hotfix: p.Hotfix}) |
| + if err != nil { |
| + log.Printf("console: execute promote %s %s -> %s: %v", app, p.Version, p.Channel, err) |
| + return "", err |
| + } |
| + if res.NoOp { |
| + // Nothing moved, so nothing is announced and nothing is audited — |
| + // the same silence the promote doors keep on a repeat. |
| + return p.Channel + " already serves " + p.Version + tagSuffix(res) + " — nothing to do.", nil |
| + } |
| + s.bumpSerial(ctx) |
| + s.audit(acct, app, actor, "promote", fmt.Sprintf("promoted %s to %s (proposed by %s, %d confirmations)", |
| + p.Version, p.Channel, pc.Proposer, len(pc.Confirms))) |
| + return "Promoted " + p.Version + " to " + p.Channel + tagSuffix(res) + ".", nil |
| +} |
| + |
| +func (s *Server) executePendingPolicy(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { |
| + var p policyPayload |
| + if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| + return "", errors.New("this proposal's payload cannot be read — reject it and propose again") |
| + } |
| + cur, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: execute policy %s/%s: read pipeline: %v", acct.Sqid, app, err) |
| + return "", errors.New("could not read the current pipeline") |
| + } |
| + var setAt time.Time |
| + if cur != nil { |
| + setAt = cur.SetAt |
| + } |
| + // The pin (spec §4). A policy edit is a whole-list replacement, so a |
| + // pipeline that moved since the proposal was composed would be silently |
| + // reverted by landing this list — including, potentially, a tightening |
| + // somebody else made in between. |
| + if !setAt.Equal(p.ObservedSetAt) { |
| + return "", staleProposal("it was proposed against an older pipeline") |
| + } |
| + set, err := release.SetPipeline(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channels, actor) |
| + if err != nil { |
| + status, msg := classifySetPipelineError(err) |
| + if status != http.StatusUnprocessableEntity { |
| + log.Printf("console: execute policy %s/%s: set: %v", acct.Sqid, app, err) |
| + } |
| + return "", errors.New(msg) |
| + } |
| + s.ensureProductionForBakes(ctx, acct, app, set, actor) |
| + s.bumpSerial(ctx) |
| + s.audit(acct, app, actor, "pipeline", pipelineAuditDetail(set, |
| + fmt.Sprintf("proposed by %s, %d confirmations", pc.Proposer, len(pc.Confirms)))) |
| + return "Pipeline updated.", nil |
| +} |
| + |
| +func (s *Server) executePendingFastTrack(ctx context.Context, acct *Account, app, actor string, pc *PendingChange) (string, error) { |
| + var p fastTrackPayload |
| + if err := json.Unmarshal(pc.Payload, &p); err != nil { |
| + return "", errors.New("this proposal's payload cannot be read — reject it and propose again") |
| + } |
| + cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, p.Channel) |
| + if err != nil { |
| + log.Printf("console: execute fast-track %s/%s: read channel: %v", acct.Sqid, app, err) |
| + return "", errors.New("could not read channel " + p.Channel) |
| + } |
| + // FastTrack waives the CURRENT head. What was approved is a specific |
| + // build, so a head that moved since invalidates the waiver rather than |
| + // transferring it to whatever arrived. |
| + if cur.Version != p.Version { |
| + return "", errors.New("channel " + p.Channel + " now serves " + cur.Version + |
| + ", not the " + p.Version + " this was proposed for — re-propose") |
| + } |
| + ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, p.Channel, actor) |
| + if err != nil { |
| + log.Printf("console: execute fast-track %s %s: %v", app, p.Channel, err) |
| + return "", err |
| + } |
| + s.bumpSerial(ctx) |
| + s.audit(acct, app, actor, "fast-track", fmt.Sprintf( |
| + "skipped the safety delay for %s on %s (proposed by %s, %d confirmations)", |
| + ver, p.Channel, pc.Proposer, len(pc.Confirms))) |
| + return "Skipped the delay for " + ver + " on " + p.Channel + " — it rolls out within a couple of minutes.", nil |
| +} |
| + |
| +// ---- the browser doors ------------------------------------------------------- |
| + |
| +// pendingScoped resolves the change this request names, and refuses with the |
| +// byte-identical 404 for anything that is not a live change of THIS account's |
| +// THIS app. That single refusal covers an id that never existed, one that |
| +// belongs to another account, one that expired and one that already ran — |
| +// Store.PendingChange treats the last two as absence, and the account/app |
| +// comparison here is the whole of the authorization scoping, because |
| +// ConfirmChange takes no account id and would happily confirm a stranger's |
| +// change for anyone who learned its id. |
| +func (s *Server) pendingScoped(w http.ResponseWriter, r *http.Request, acct *Account, app string) *PendingChange { |
| + id := r.PathValue("id") |
| + pc, err := s.store.PendingChange(id, time.Now()) |
| + if err != nil { |
| + log.Printf("console: pending change %s: %v", id, err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read that change. Please try again.") |
| + return nil |
| + } |
| + if pc == nil || pc.AccountID != acct.ID || pc.App != app { |
| + http.NotFound(w, r) |
| + return nil |
| + } |
| + return pc |
| +} |
| + |
| +// handlePendingConfirm records one member's passkey-backed confirmation and, |
| +// when that meets the quorum, carries the change out. |
| +// |
| +// Gate order, the mandated one: session + membership (accountApp, 404 for a |
| +// non-member) → CSRF → signing key → the change's own 404 → requireFreshAuth |
| +// LAST. Membership is the gate here rather than ownership: the quorum names |
| +// N MEMBERS, and demanding ownership on top would make an owner's absence a |
| +// way to freeze every gated action in the account. |
| +// |
| +// ConfirmChange's three refusals ("no such", "expired", "already executed") |
| +// are only reachable as a race with another request between the 404 above and |
| +// the write, so mapping them can leak nothing: by then the caller has been |
| +// proved a member of the account that owns the change. They map to 404 / 410 |
| +// / 409 respectively — an unknown id stays indistinguishable from another |
| +// account's, because both were already answered by the 404 above. |
| +func (s *Server) handlePendingConfirm(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Confirmations disabled", |
| + "No signing key on this host — the console is running read-only.") |
| + return |
| + } |
| + pc := s.pendingScoped(w, r, acct, app) |
| + if pc == nil { |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther) |
| + } |
| + pc, met, err := s.store.ConfirmChange(pc.ID, addr, time.Now()) |
| + if err != nil { |
| + s.refuseConfirm(w, r, err) |
| + return |
| + } |
| + if !met { |
| + s.audit(acct, app, addr, "confirmed", fmt.Sprintf("%s (%d of %d confirmations)", |
| + pendingSummary(*pc), len(pc.Confirms), pc.Quorum)) |
| + back("ok", fmt.Sprintf("Confirmed: %s. %d of %d confirmations.", |
| + pendingSummary(*pc), len(pc.Confirms), pc.Quorum)) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), pendingTimeout) |
| + defer cancel() |
| + msg, err := s.executePending(ctx, acct, app, addr, pc) |
| + if err != nil { |
| + back("err", "Could not carry that out: "+err.Error()) |
| + return |
| + } |
| + back("ok", msg) |
| +} |
| + |
| +// refuseConfirm maps ConfirmChange's own refusals onto the wire. See |
| +// handlePendingConfirm's doc comment for why this cannot be an oracle. |
| +func (s *Server) refuseConfirm(w http.ResponseWriter, r *http.Request, err error) { |
| + msg := err.Error() |
| + switch { |
| + case strings.Contains(msg, "no such pending change"): |
| + http.NotFound(w, r) |
| + case strings.Contains(msg, "has expired"): |
| + s.renderMessage(w, r, http.StatusGone, "Expired", |
| + "That change expired before it collected enough confirmations. Nothing has been changed — propose it again.") |
| + case strings.Contains(msg, "already been executed"): |
| + s.renderMessage(w, r, http.StatusConflict, "Already done", |
| + "That change was carried out while you were confirming it. Nothing else has been changed.") |
| + default: |
| + log.Printf("console: confirm pending change: %v", err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", |
| + "Could not record your confirmation. Please try again.") |
| + } |
| +} |
| + |
| +// handlePendingReject deletes a proposal outright. The proposer or an account |
| +// owner may do it, and NEITHER needs a passkey: refusing authority is not |
| +// exercising it, and a rejection that failed closed on an unreachable Keymail |
| +// would leave a change nobody could withdraw sitting one confirmation away |
| +// from landing. |
| +func (s *Server) handlePendingReject(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + pc := s.pendingScoped(w, r, acct, app) |
| + if pc == nil { |
| + return |
| + } |
| + // A member who is neither the proposer nor an owner meets the same 404 |
| + // the change's own scope check gives — they learn nothing about a change |
| + // they have no say over. |
| + if pc.Proposer != addr && role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err := s.store.DeleteChange(pc.ID); err != nil { |
| + log.Printf("console: reject pending change %s: %v", pc.ID, err) |
| + s.setFlash(w, "err", "Could not withdraw that change. Please try again.") |
| + http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther) |
| + return |
| + } |
| + s.audit(acct, app, addr, "rejected", pendingSummary(*pc)+" (proposed by "+pc.Proposer+")") |
| + s.setFlash(w, "ok", "Withdrawn: "+pendingSummary(*pc)+". Nothing was changed.") |
| + http.Redirect(w, r, appPath(acct, app), http.StatusSeeOther) |
| +} |
| + |
| +// ---- the CLI door ------------------------------------------------------------ |
| + |
| +// handleAPIPending answers `carlos pipeline`'s pending list: what this app is |
| +// waiting on, and how far along each one is. Read-only and Publish-reachable, |
| +// like channels and releases — a proposal names no secret, and the CLI needs |
| +// it precisely because the CLI cannot confirm anything itself (spec §4). |
| +func (s *Server) handleAPIPending(w http.ResponseWriter, r *http.Request) { |
| + _, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + list, err := s.store.PendingChangesFor(acct.ID, app, time.Now()) |
| + if err != nil { |
| + log.Printf("console: api pending %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusInternalServerError, "could not read pending changes") |
| + return |
| + } |
| + out := make([]map[string]any, 0, len(list)) |
| + for _, pc := range list { |
| + out = append(out, map[string]any{ |
| + "id": pc.ID, |
| + "kind": pc.Kind, |
| + "summary": pendingSummary(pc), |
| + "confirmed": len(pc.Confirms), |
| + "needed": pc.Quorum, |
| + }) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"pending": out}) |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 66ea737cf..b74281328 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -741,6 +741,12 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/production", s.handleProduction) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack) |
| + // The pending-change doors (pendinghandlers.go, release pipelines v2 §4): |
| + // confirm is member-gated with the passkey step-up LAST, reject is the |
| + // proposer's or an owner's and deliberately carries no step-up — refusing |
| + // authority is not exercising it. |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/pending/{id}/confirm", s.handlePendingConfirm) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/pending/{id}/reject", s.handlePendingReject) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary) |
| // The app Settings writes (appsettings.go): owner-only, and delete/move |
| @@ -2634,13 +2640,30 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| back("err", "Version "+version+" is not a shipped release of "+app+".") |
| return |
| } |
| + // Which gate this promotion meets is the pipeline's answer (release |
| + // pipelines v2 §5), so the pipeline is read before any of them. An |
| + // unreadable one refuses: "not safely nil" must never fall back to the |
| + // legacy ladder under the app that opted out of it. |
| + pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if perr != nil { |
| + log.Printf("console: promote %s/%s: read pipeline: %v", acct.Sqid, app, perr) |
| + back("err", "Could not read the pipeline for "+app+". Nothing has been changed.") |
| + return |
| + } |
| // The stable rung's owner gate fires BEFORE the confirm card: a member |
| // probing an owner-only promote must meet the byte-identical 404, never a |
| // card that concedes the action would otherwise proceed. promoteGate |
| // re-checks this for owners on the confirmed POST; the duplication is the |
| // point — the confirm step must sit strictly between owner 404 and |
| // requireFreshAuth without weakening either. |
| - if rung, _ := release.Rung(channel); rung == release.RungStable && role != RoleOwner { |
| + // |
| + // Legacy path only. A PIPELINED app's channel named "stable" is an |
| + // app-defined channel that happens to share a rung's name, and its gates |
| + // are the ones its pipeline declares (spec §7: the unconditional |
| + // stable-passkey becomes legacy-path-only) — reading the rung table for |
| + // it would impose an owner rule the account never asked for and cannot |
| + // remove. |
| + if rung, _ := release.Rung(channel); pipe == nil && rung == release.RungStable && role != RoleOwner { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -2648,12 +2671,38 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| s.confirmPromote(w, r, addr, raw, acct, role, app, channel, version) |
| return |
| } |
| - // The rung's own gate, LAST — after membership, after the app check, after |
| - // CSRF, and after every refusal that costs nothing. Order is load-bearing: |
| - // a non-member must meet the byte-identical 404 before step-up is ever |
| - // mentioned, or the redirect into Keymail would confirm what the 404 is |
| - // there to hide. |
| - if !s.promoteGate(w, r, addr, raw, role, channel) { |
| + // The channel's own gate, LAST — after membership, after the app check, |
| + // after CSRF, and after every refusal that costs nothing. Order is |
| + // load-bearing: a non-member must meet the byte-identical 404 before |
| + // step-up is ever mentioned, or the redirect into Keymail would confirm |
| + // what the 404 is there to hide. |
| + if !s.promoteGate(w, r, addr, raw, role, channel, pipe) { |
| + return |
| + } |
| + // An approvals rule turns the promotion into a proposal — but only now, |
| + // with the proposer's presence just proved by promoteGate's step-up, |
| + // which is what earns their own confirmation its place in the quorum |
| + // (spec §4). Order is checked first, so a proposal that could never |
| + // execute never goes out for signatures. |
| + // |
| + // `pipe != nil` is already implied by a nonzero rule — channelRules |
| + // answers the zero ChannelRules for a nil pipeline — and is written out |
| + // anyway, because VersionStage below DEREFERENCES the pipeline it is |
| + // handed and would panic on nil. |
| + if rules := channelRules(pipe, channel); pipe != nil && rules.PromoteApprovals >= 1 { |
| + stage, serr := release.VersionStage(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, version, pipe) |
| + if serr != nil { |
| + log.Printf("console: promote %s/%s: stage: %v", acct.Sqid, app, serr) |
| + back("err", "Could not work out where "+version+" sits in the pipeline. Nothing has been changed.") |
| + return |
| + } |
| + if aerr := release.PipelinePromotionAllowed(pipe, stage, channel); aerr != nil { |
| + back("err", "Promote refused: "+aerr.Error()) |
| + return |
| + } |
| + kind, msg := s.proposeThenExecute(ctx, acct, app, kindPromote, |
| + promotePayload{Version: version, Channel: channel}, rules.PromoteApprovals, addr) |
| + back(kind, msg) |
| return |
| } |
| // PromoteOnLadder, not Promote: the ladder is enforced here, not merely |
| @@ -2720,24 +2769,50 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| // of this handler's box-consumed writes — the pointer and, when the |
| // checkbox was ticked, the production flag — in a single bump. |
| s.bumpSerial(ctx) |
| - if baking && release.HoldFor(release.DefaultHolds, channel) > 0 { |
| + // Whether the boxes will actually hold this, said the same way the CLI |
| + // door's `held` field says it (channelHeld, apicli.go): the window comes |
| + // from the pipeline when there is one, and a PIPELINED app never consults |
| + // the production flag at all — its bake policy is the pipeline's alone |
| + // (spec §3), which is exactly what adopt.go does. |
| + if holdWindow(pipe, channel) > 0 && (pipe != nil || baking) { |
| msg += " It goes live everywhere after the safety delay — rollbacks skip it." |
| } |
| back("ok", msg) |
| } |
| -// promoteGate applies the gate the TARGET RUNG carries (pipelines spec §3's |
| -// gate table). Below stable, membership — which the caller has already |
| -// proved — is the gate, and this waves the promotion through. |
| +// promoteGate applies the gate the TARGET CHANNEL carries. Which table it |
| +// reads is the pipeline's answer, and the two are deliberately exclusive: |
| // |
| -// Stable is different in kind: it puts a build in front of every user of the |
| -// app, so it takes an account OWNER, and a live session is not sufficient |
| -// proof that the owner is the one asking. A member probing it gets the same |
| -// 404 a stranger gets, and only then, last of all, is the passkey demanded. |
| +// pipe == nil — the legacy ladder (pipelines spec §3's gate table). Below |
| +// stable, membership — which the caller has already proved — is the gate, |
| +// and this waves the promotion through. Stable is different in kind: it |
| +// puts a build in front of every user of the app, so it takes an account |
| +// OWNER, and a live session is not sufficient proof that the owner is the |
| +// one asking. |
| +// pipe != nil — the channel's own declared rules (release pipelines v2 §2). |
| +// A passkey rule, or an approvals rule (whose every confirmation is |
| +// passkey-backed, the proposer's included), demands the step-up; a channel |
| +// with neither — and every channel outside the pipeline, canary/* included |
| +// — is waved through on membership. There is no owner rule on this path: |
| +// the pipeline says what a channel costs, and ownership is not one of the |
| +// things it can say (spec §2). That places the weight on WHO MAY WRITE A |
| +// PIPELINE — any member of the account, or an admin-role service |
| +// credential (handleAPIPipelinePut) — so an account that wants a channel |
| +// only its owner can promote onto expresses that as a passkey plus |
| +// approvals, not by naming the channel after a rung. |
| // |
| -// It returns false when it has already written the response — either the 404 |
| -// or the step-up redirect — and the caller must simply return. |
| -func (s *Server) promoteGate(w http.ResponseWriter, r *http.Request, addr, raw, role, channel string) bool { |
| +// The ORDER is the same on both paths and is the load-bearing part: the |
| +// byte-identical 404 first, requireFreshAuth strictly last. It returns false |
| +// when it has already written the response — either the 404 or the step-up |
| +// redirect — and the caller must simply return. |
| +func (s *Server) promoteGate(w http.ResponseWriter, r *http.Request, addr, raw, role, channel string, pipe *release.Pipeline) bool { |
| + if pipe != nil { |
| + rules := channelRules(pipe, channel) |
| + if !rules.Passkey && rules.PromoteApprovals < 1 { |
| + return true |
| + } |
| + return s.requireFreshAuth(w, r, addr, raw) |
| + } |
| if rung, _ := release.Rung(channel); rung != release.RungStable { |
| return true |
| } |
| @@ -2926,32 +3001,56 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) { |
| // never bakes as "fast-tracked past the bake window", forever. |
| // |
| // Placed HERE, after both owner gates, and not with the cheap refusals |
| - // above: HoldFor is pure vocabulary and leaks nothing on its own, but a |
| + // above: the window is pure vocabulary and leaks nothing on its own, but a |
| // refusal that fires before the owner check would make this endpoint's |
| - // response vary with the channel name for a NON-OWNER, who must see the |
| - // byte-identical 404 and nothing else. The mandated order (session → CSRF |
| - // → owner 404 → requireFreshAuth LAST) then leaves only this spot. The |
| - // cost is a passkey tap spent on a refusal; the alternative is an oracle. |
| - if release.HoldFor(release.DefaultHolds, channel) == 0 { |
| + // response vary with the channel name — and now with the app's PIPELINE — |
| + // for a NON-OWNER, who must see the byte-identical 404 and nothing else. |
| + // The mandated order (session → CSRF → owner 404 → requireFreshAuth LAST) |
| + // then leaves only this spot. The cost is a passkey tap spent on a |
| + // refusal; the alternative is an oracle. |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + // holdWindow, not HoldFor: a pipelined channel's window is its own, for a |
| + // name the legacy table may never have heard of — and an app that |
| + // declared a 0s bake on a channel has nothing to waive there either. |
| + // An unreadable pipeline refuses rather than falling back to the legacy |
| + // table, which could offer a waiver on a channel the pipeline gives no |
| + // window at all. |
| + pipe, perr := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if perr != nil { |
| + log.Printf("console: fast-track %s/%s: read pipeline: %v", acct.Sqid, app, perr) |
| + back("err", "Could not read the pipeline for "+app+". Nothing has been changed.") |
| + return |
| + } |
| + if holdWindow(pipe, channel) == 0 { |
| back("err", "Channel "+channel+" has no safety delay running — there is nothing to skip.") |
| return |
| } |
| - ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| - defer cancel() |
| + // The head is read here rather than only when a pin is present, because a |
| + // proposal has to PIN the build it waives: what gets confirmed must be a |
| + // specific version, not "whatever this channel carries whenever somebody |
| + // gets round to it". |
| + cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) |
| + if err != nil { |
| + back("err", "Could not read channel "+channel+".") |
| + return |
| + } |
| // The card pinned the version whose window it offered to waive; if the |
| // channel has moved since, the waiver must not land on the newcomer. |
| // Pin-present-only, like the rollback pin: a pinless POST waives the |
| // current head, which is FastTrack's own contract. |
| - if pinned := strings.TrimSpace(r.FormValue("version")); pinned != "" { |
| - cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) |
| - if err != nil { |
| - back("err", "Could not read channel "+channel+".") |
| - return |
| - } |
| - if cur.Version != pinned { |
| - back("err", "Channel "+channel+" changed while you were confirming — review skipping the delay again.") |
| - return |
| - } |
| + if pinned := strings.TrimSpace(r.FormValue("version")); pinned != "" && cur.Version != pinned { |
| + back("err", "Channel "+channel+" changed while you were confirming — review skipping the delay again.") |
| + return |
| + } |
| + // change_approvals covers overriding a channel's bake as well as editing |
| + // its entry (spec §2: "one field covers both changing and overriding"). |
| + // The step-up above already ran, so this proposal counts its proposer. |
| + if rules := channelRules(pipe, channel); rules.ChangeApprovals >= 1 { |
| + kind, msg := s.proposeThenExecute(ctx, acct, app, kindFastTrack, |
| + fastTrackPayload{Channel: channel, Version: cur.Version}, rules.ChangeApprovals, addr) |
| + back(kind, msg) |
| + return |
| } |
| ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, addr) |
| if err != nil { |