carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 3ffa4bdb3a8a3364e3bf7968577dbf7ec9d032f5 parent 0fe8e88
8 files changed, +1404 −119
  • internal/console/apicli.go +92 −21
  • internal/console/apicli_test.go +42 −11
  • internal/console/apipipeline.go +41 −28
  • internal/console/apipipeline_test.go +32 −23
  • internal/console/authcli.go +6 −0
  • internal/console/pendingflow_test.go +503 −0
  • internal/console/pendinghandlers.go +553 −0
  • internal/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 {