carlos / platform Public

console: CLI API — promote and rollback through the browser doors, stable refused

carlos promote and carlos rollback now speak to the console over
/api/cli/apps/{account}/{app}/promote and .../rollback, mirroring
handlePromote/handleRollback's door order minus CSRF (a JSON bearer request
has no session cookie to forge) and minus the passkey step-up (there is no
browser here to redirect into Keymail). Stable is refused outright with a
403 pointing at the console, and the refusal is byte-identical for a member
and an owner token so the door never leaks role. The request struct has no
hotfix field, so there is no way to bypass the ladder from the API.
Paul Campbell d5084cc84e2bfe08bc114b1228086bc1304b88c4 parent 1d99f8f
3 files changed, +409 −0
  • internal/console/apicli.go +131 −0
  • internal/console/apicli_test.go +276 −0
  • internal/console/authcli.go +2 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 9fcfc3ae4..97d5da69a 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -13,6 +13,7 @@ import (
"fmt"
"log"
"net/http"
+ "net/url"
"strings"
"time"
@@ -161,3 +162,133 @@ func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{"channels": out})
}
+
+// handleAPIPromote answers `carlos promote`: the CLI's write door onto the
+// SAME ladder the console's own promote button enforces (handlePromote,
+// server.go), doors in the same order minus two the browser needs and this
+// caller cannot present: CSRF (a bearer request carries no session cookie to
+// forge in the first place — readJSON's JSON-content-type insistence is
+// already this door's cross-site defence) and the passkey step-up (there is
+// no browser here to redirect into Keymail).
+//
+// Stable's gate does not soften into a step-up here — it simply refuses,
+// pointing at the console, which DOES have a passkey to ask for. Refusing
+// before ever consulting role is what keeps a member's 403 and an owner's 403
+// 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.
+func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if s.cfg.Key == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no signing key on this host — the console is running read-only")
+ return
+ }
+ var req struct {
+ Version string `json:"version"`
+ Channel string `json:"channel"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ channel := strings.TrimSpace(req.Channel)
+ if !release.ValidChannel(channel) {
+ jsonError(w, http.StatusBadRequest, "invalid channel name")
+ return
+ }
+ version := strings.TrimSpace(req.Version)
+ if !release.ValidVersion(version) {
+ jsonError(w, http.StatusBadRequest, "invalid version")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ if _, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version); err != nil {
+ 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),
+ })
+ return
+ }
+ res, err := release.PromoteOnLadder(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, version,
+ release.PromoteOptions{Actor: addr})
+ 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
+ // API as it is on the app page.
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ if res.NoOp {
+ writeJSON(w, http.StatusOK, map[string]any{"result": "noop"})
+ return
+ }
+ s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel+" (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{
+ "result": "promoted", "from": res.From, "to": res.To, "noop": false,
+ })
+}
+
+// handleAPIRollback answers `carlos rollback`: the CLI's write door onto the
+// same swap-to-previous-version primitive as the console's own rollback
+// button (handleRollback, server.go). No rung gate here, matching the
+// browser door — rolling back never climbs the ladder, so nothing about
+// maturity has a say over it.
+func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if s.cfg.Key == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no signing key on this host — the console is running read-only")
+ return
+ }
+ var req struct {
+ Channel string `json:"channel"`
+ }
+ if !readJSON(w, r, &req) {
+ return
+ }
+ channel := strings.TrimSpace(req.Channel)
+ if !release.ValidChannel(channel) {
+ jsonError(w, http.StatusBadRequest, "invalid channel name")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
+ if err != nil {
+ jsonError(w, http.StatusNotFound, "could not read channel "+channel)
+ return
+ }
+ if len(cur.History) == 0 {
+ jsonError(w, http.StatusConflict, "channel "+channel+" has no previous version to roll back to")
+ return
+ }
+ target := cur.History[0]
+ if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
+ log.Printf("console: api rollback %s %s: %v", app, channel, err)
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ }
+ s.audit(acct, app, addr, "rollback", "rolled back "+channel+" to "+target+" (cli)")
+ writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"})
+}
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index aa347a4e4..56baa749e 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -9,7 +9,10 @@ package console
// ERROR behaviour rather than failing the whole response.
import (
+ "bytes"
"context"
+ "encoding/json"
+ "io"
"net/http"
"strings"
"testing"
@@ -48,6 +51,35 @@ func getRaw(t *testing.T, u, bearer string) (int, string) {
return res.StatusCode, string(body)
}
+// postRaw is postJSON's byte-exact cousin: TestAPIPromoteStableRefused needs
+// the raw response body, not a decoded map, to assert two responses are
+// IDENTICAL rather than merely equal key-by-key.
+func postRaw(t *testing.T, u string, body any, bearer string) (int, string) {
+ t.Helper()
+ raw, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ out, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res.StatusCode, string(out)
+}
+
// TestAPIAppsListsMemberships: a token only ever learns about the accounts
// IT belongs to, and only that account's apps — a second account's OWN app
// (secretapp, claimed in bac) never leaks into bab's list.
@@ -325,3 +357,247 @@ func TestAPIAccountNameMatchIsCaseInsensitive(t *testing.T) {
t.Fatalf("case-insensitive name match = %d %v, want 200", status, out)
}
}
+
+// ---- promote + rollback (apicli.go) -----------------------------------------
+//
+// The CLI's write doors: `carlos promote` and `carlos rollback` through the
+// same browser doors as the console UI (handlePromote/handleRollback,
+// server.go), minus CSRF (a JSON bearer request has no session cookie to
+// forge) and minus the passkey step-up (there is no browser here to redirect
+// into Keymail) — stable is refused outright instead.
+
+// TestAPIPromoteHappy: a shipped version reaches a fresh channel, the pointer
+// reads back the new version, and the audit trail names it a CLI action.
+func TestAPIPromoteHappy(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-happy")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("promote = %d %v", status, out)
+ }
+ if str(t, out, "result") != "promoted" || str(t, out, "to") != "canary" {
+ t.Fatalf("promote body = %v", out)
+ }
+ if v, present := out["noop"]; !present || v != false {
+ t.Fatalf("promote noop = %v (present=%v), want false", v, present)
+ }
+ cp := fx.channelVersion(t, "titogo", "canary/x")
+ if cp.Version != "abc1234" {
+ t.Fatalf("channel after promote = %q, want abc1234", cp.Version)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, e := range events {
+ if e.Action == "promote" && strings.Contains(e.Detail, "(cli)") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("events = %v, want a promote row whose detail names the CLI", events)
+ }
+}
+
+// TestAPIPromoteStableRefused: stable cannot be reached over the API at all —
+// there is no passkey to step up to — and the refusal is byte-identical for a
+// member token and an owner token, so a token holder learns nothing about
+// their own role from this door (spec §3).
+func TestAPIPromoteStableRefused(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-stable-owner")
+ member := cliToken(t, fx, "alice@keymail.dev", "code-stable-member")
+
+ body := map[string]string{"version": "abc1234", "channel": "stable"}
+ statusO, outO := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", body, owner)
+ statusM, outM := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", body, member)
+ if statusO != http.StatusForbidden || statusM != http.StatusForbidden {
+ t.Fatalf("stable promote = owner %d, member %d, want both 403", statusO, statusM)
+ }
+ if outO != outM {
+ t.Fatalf("stable refusal not byte-identical:\nowner: %q\nmember: %q", outO, outM)
+ }
+ wantURL := fx.ts.URL + "/a/bab/apps/titogo"
+ if !strings.Contains(outO, wantURL) {
+ t.Fatalf("stable refusal = %q, want console_url %q", outO, wantURL)
+ }
+ if !strings.Contains(outO, "passkey") {
+ t.Fatalf("stable refusal = %q, want it to name the passkey requirement", outO)
+ }
+}
+
+// TestAPIPromoteNoKey: a read-only deployment (no signing key) refuses every
+// promotion with 503, the same as the browser door.
+func TestAPIPromoteNoKey(t *testing.T) {
+ fx := newConsoleFixtureKeyed(t, false)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-promote-nokey")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("promote with no key = %d %v, want 503", status, out)
+ }
+}
+
+// TestAPIPromoteBadVersion: a version that fails release.ValidVersion is a
+// 400 naming the problem, before anything touches the release store.
+func TestAPIPromoteBadVersion(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)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-promote-badversion")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "../etc/passwd", "channel": "canary/x"}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("bad version promote = %d %v, want 400", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "version") {
+ t.Fatalf("bad version error = %q, want it to name the version", str(t, out, "error"))
+ }
+}
+
+// TestAPIPromoteBadChannel: an invalid channel name is a 400 naming the
+// problem.
+func TestAPIPromoteBadChannel(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-badchannel")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "bad channel"}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("bad channel promote = %d %v, want 400", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "channel") {
+ t.Fatalf("bad channel error = %q, want it to name the channel", str(t, out, "error"))
+ }
+}
+
+// TestAPIPromoteUnshippedVersion: a well-formed version that was never
+// shipped is a 404 naming the app, the same sentence the browser door uses.
+func TestAPIPromoteUnshippedVersion(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)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-promote-unshipped")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "ghost999", "channel": "canary/x"}, token)
+ if status != http.StatusNotFound {
+ t.Fatalf("unshipped version promote = %d %v, want 404", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "not a shipped release") {
+ t.Fatalf("unshipped version error = %q, want the browser door's sentence", str(t, out, "error"))
+ }
+}
+
+// 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) {
+ 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")
+
+ // "beta/x" is on no rung yet (nothing has promoted it there), so this is
+ // the ladder's plain refusal, sent alongside a hotfix:true the handler
+ // must never act on.
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]any{"version": "abc1234", "channel": "beta/x", "hotfix": true}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("ladder-skip promote with hotfix:true = %d %v, want 409 (hotfix must be inert)", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "rung") {
+ t.Fatalf("ladder-skip error = %q, want the ladder's own 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/x"); err == nil {
+ t.Fatalf("beta/x channel exists after a refused promotion, want it absent")
+ }
+}
+
+// TestAPIRollbackHappy: two promotions to the same channel, then a rollback,
+// return the pointer to the first version.
+func TestAPIRollbackHappy(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")
+ shipVersion(t, fx, "titogo", "def5678")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-rollback-happy")
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "abc1234", "channel": "canary/x"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("first promote = %d %v", status, out)
+ }
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote",
+ map[string]string{"version": "def5678", "channel": "canary/x"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("second promote = %d %v", status, out)
+ }
+ if cp := fx.channelVersion(t, "titogo", "canary/x"); cp.Version != "def5678" {
+ t.Fatalf("channel before rollback = %q, want def5678", cp.Version)
+ }
+
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback",
+ map[string]string{"channel": "canary/x"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("rollback = %d %v", status, out)
+ }
+ if str(t, out, "result") != "rolled back" {
+ t.Fatalf("rollback body = %v", out)
+ }
+ if cp := fx.channelVersion(t, "titogo", "canary/x"); cp.Version != "abc1234" {
+ t.Fatalf("channel after rollback = %q, want abc1234 (back to the first version)", cp.Version)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, e := range events {
+ if e.Action == "rollback" && strings.Contains(e.Detail, "(cli)") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("events = %v, want a rollback row whose detail names the CLI", events)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 9c7e96633..8aa9b9fd8 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -159,6 +159,8 @@ func (s *Server) registerCLIRoutes() {
// move now that CLIs depend on them.
s.mux.HandleFunc("GET /api/cli/apps", s.handleAPIApps)
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/channels", s.handleAPIChannels)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback)
}
// clientIP is who the rate limiter counts against.