carlos / platform Public

merge: CLI through the console API — carlos ship/promote without seeing the infrastructure (#25)

CLI through the console API — carlos ship/promote without seeing the infrastructure
Paul Campbell 7eb397eb4002468403ce3a595d2df4fa412df293 parent a8b4ad4parent a1bd7c6
14 files changed, +4651 −28
  • README.md +21 −0
  • apimode.go +438 −0
  • apimode_test.go +1243 −0
  • appconfig_cli.go +8 −8
  • auth.go +66 −0
  • auth_e2e_test.go +298 −0
  • cli.go +213 −19
  • docs/superpowers/plans/2026-08-03-cli-console-api.md +315 −0
  • docs/superpowers/specs/2026-08-03-cli-console-api-design.md +182 −0
  • docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md +8 −0
  • internal/console/apicli.go +679 −0
  • internal/console/apicli_test.go +1134 −0
  • internal/console/authcli.go +25 −1
  • internal/console/server.go +21 −0
diff --git a/README.md b/README.md
index fabd0c7b9..e1aae8928 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,27 @@ origin), expires in 90 days, and is listed with a revoke button at
carlos auth whoami # who this terminal is, per the console
carlos auth logout # revokes the token, then forgets it
+**Shipping from a logged-in laptop.** Once you're logged in, `ship`,
+`promote`, `rollback` and `channels` speak through the console instead of the
+bucket directly — no `CARLOS_DEPLOYMENT_BUCKET`/`_DIR` on a developer's
+machine at all:
+
+ carlos ship -app blog -version $(git rev-parse --short HEAD) -target linux-arm64 blog-linux-arm64
+ carlos promote -app blog abc1234 canary/my-branch
+ carlos channels -app blog
+
+Canary and edge promote exactly as before; **promoting onto `stable` is
+refused** — the console names itself in the refusal, because moving `stable`
+needs a passkey step-up only a browser can do. Finish it at the URL the
+refusal prints.
+
+CI and operators keep the existing path unchanged: set
+`CARLOS_DEPLOYMENT_BUCKET` (or `_DIR`) and every verb goes straight at the
+bucket, exactly as before — logging in is for a human at a laptop, never a box
+or a pipeline. Note the modes carry different authority: a scoped
+`CARLOS_RELEASE_KEY` constrains the direct path only — in API mode it is
+ignored and your console membership (plus the rung gates) is what decides.
+
**On a box, or in CI, don't log in.** There is no browser and no human, and a
long-lived token sitting on a box is a standing credential nobody is watching.
Name the owner outright instead — it needs no identity of its own and takes
diff --git a/apimode.go b/apimode.go
new file mode 100644
index 000000000..6145af533
--- /dev/null
+++ b/apimode.go
@@ -0,0 +1,438 @@
+// Which mode the CLI runs in, and the console API client for the mode that
+// doesn't touch the deployment bucket directly.
+//
+// Every verb that used to reach straight for CARLOS_DEPLOYMENT_BUCKET /
+// CARLOS_DEPLOYMENT_DIR now checks resolveMode first. Direct-mode wins
+// whenever either env var is set — that is unchanged behaviour, and it wins
+// unconditionally: a box configured for direct bucket access keeps working
+// exactly as it always has, whether or not the same machine also happens to
+// hold a console credential (`carlos auth login` is meant for a human's
+// laptop, not a box; see auth.go's HEADLESS AND BOX-SIDE USE note). Only when
+// neither env var is set does the CLI need a credential at all, and it is
+// credential-first from there: no local registry, no guessing an account by
+// name against a bucket the caller may not even be able to reach — the
+// console resolves the account (by sqid OR name) against the caller's own
+// memberships, which is also the authorization check.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+)
+
+// apiSession is the resolved API mode: which console, as whom. console and
+// address are carried for messages (Task 5's "logged in to X as Y" lines),
+// not used by the calls below.
+type apiSession struct {
+ c *consoleClient
+ console string // origin, for messages
+ address string
+}
+
+// resolveMode decides which mode the CLI is in. (nil, nil) means direct mode
+// — the store env is set, and the caller proceeds exactly as it does today,
+// straight against the bucket. A non-nil session means API mode: every call
+// goes through the console named in the credential loggedInCredential
+// resolved, as the address that credential belongs to.
+//
+// consoleFlag is the verb's -console value ("" meaning "the only one" —
+// pick's ordinary disambiguation rule, unchanged here).
+//
+// An explicit -console alongside either store env is refused outright
+// (design ruling, review round 2026-08-03) rather than silently winning as
+// direct mode: the spec's "the two modes never mix in one invocation" intent
+// (§1) would otherwise be violated invisibly — someone who typed -console
+// meaning it gets bucket access instead, with no signal that their flag was
+// ignored. A BARE env with no -console still wins silently, unchanged: that
+// half of §1 (operators, CI, box-side scripts) is deliberately untouched.
+func resolveMode(consoleFlag string) (*apiSession, error) {
+ bucketSet := os.Getenv("CARLOS_DEPLOYMENT_BUCKET") != ""
+ dirSet := os.Getenv("CARLOS_DEPLOYMENT_DIR") != ""
+ if consoleFlag != "" && (bucketSet || dirSet) {
+ return nil, errors.New("-console conflicts with CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR " +
+ "— unset the env to use the console, or drop -console")
+ }
+ if bucketSet || dirSet {
+ return nil, nil
+ }
+ cred, err := loggedInCredential(consoleFlag)
+ if err != nil {
+ // loggedInCredential's own errors already name their own fix (log in
+ // to the right console, re-log in, disambiguate with -console) —
+ // except the plain "there are no credentials at all" case (pick's
+ // exact, non-interpolated wording below), which never mentions
+ // direct mode because pick() has no idea direct mode exists. That's
+ // the one case worth widening here: a fresh checkout with neither a
+ // bucket/dir nor a login has TWO ways to fix it, and the naked "not
+ // logged in" message only offers one.
+ if err.Error() == noCredentialsMsg {
+ // But "not logged in" is also what pick() says when a credential
+ // EXISTS but every entry on disk has expired — its live-credential
+ // filter treats an expired entry as absent, so that case reaches
+ // this same string. Those are different problems for a person to
+ // fix ("configure a target" vs. "log back in"), and conflating
+ // them here would tell someone who has in fact logged in before
+ // that nothing is configured at all.
+ if expired := expiredCredentialFirst(); expired != nil {
+ return nil, fmt.Errorf("the credential for %s expired — run: carlos auth login -console %s",
+ expired.Console, expired.Console)
+ }
+ return nil, fmt.Errorf("no deployment target configured: " +
+ "set CARLOS_DEPLOYMENT_BUCKET (or CARLOS_DEPLOYMENT_DIR) for direct bucket access, " +
+ "or run carlos auth login -console <url> to work through a console")
+ }
+ return nil, err
+ }
+ return &apiSession{
+ c: newConsoleClient(cred.Console, cred.Token),
+ console: cred.Console,
+ address: cred.Address,
+ }, nil
+}
+
+// noCredentialsMsg is (*credentialFile).pick's exact wording (auth.go) for
+// "no -console given (and no CARLOS_CONSOLE either) and there is nothing
+// live to pick from" — the one pick() refusal that never mentions direct
+// mode, since pick has no idea direct mode exists. Matched by exact string
+// rather than a sentinel error because pick constructs a fresh error each
+// call; this is the one pick() message with no interpolated value, so an
+// exact match is precise, not fragile.
+//
+// It is ALSO the one case pick() reaches both when the credentials file is
+// genuinely empty and when it holds only expired entries (pick's "live"
+// filter excludes expired credentials before counting), which is why
+// resolveMode calls expiredCredentialFirst before treating it as "nothing
+// configured".
+const noCredentialsMsg = "not logged in — run: carlos auth login -console <url>"
+
+// expiredCredentialFirst reports whether the credentials file holds at
+// least one entry and every one of them is expired, returning one such
+// entry if so (and nil otherwise, including when the file is empty or
+// unreadable). Only ever consulted from the noCredentialsMsg branch above,
+// where consoleFlag and CARLOS_CONSOLE are already known to both be empty —
+// pick() would have taken its console!="" branch, and returned its OWN
+// (already specific) expired-credential message, otherwise.
+func expiredCredentialFirst() *credential {
+ f, err := loadCredentials()
+ if err != nil || len(f.Credentials) == 0 {
+ return nil
+ }
+ now := time.Now()
+ for i := range f.Credentials {
+ if !f.Credentials[i].Expired(now) {
+ return nil
+ }
+ }
+ return &f.Credentials[0]
+}
+
+// apiChannel is one row of a channel ladder read back through the console
+// API, mirroring the runChannels per-row ERROR behaviour: a channel whose
+// pointer failed verification is a row with Err set, not a failed call.
+type apiChannel struct {
+ Channel string
+ Version string
+ PromotedAt string
+ Rung string
+ Err string
+}
+
+// apiPromoteResult and apiShipResult carry the one fact Task 5 needs to
+// print a parity message: whether the console did nothing because the
+// request already matched what's live (a promote of a version already on
+// that channel; a re-ship of bytes already shipped under that version).
+// Every other field of the console's response (rung names, artifact
+// hashes) is deliberately not modelled here — the CLI already knows the
+// version/channel it asked for and prints those, never the server's echo of
+// them.
+type apiPromoteResult struct {
+ NoOp bool
+}
+
+type apiShipResult struct {
+ NoOp bool
+}
+
+// apiPath builds one /api/cli/apps/{account}/{app}/... path. account is
+// passed through verbatim (sqid or name, exactly what -account carried,
+// default "ops") — the console resolves it against the caller's own
+// memberships; this client must never resolve it against a local registry.
+func apiPath(account, app, tail string) string {
+ return "/api/cli/apps/" + url.PathEscape(account) + "/" + url.PathEscape(app) + tail
+}
+
+// notFoundErr is what every call here returns for a 404 whose body carries
+// no JSON "error" field. apiAccountApp (internal/console/apicli.go) answers
+// an unknown account, an unknown app, and someone else's account with the
+// SAME http.NotFound — a byte-identical text/plain body, not JSON, by design
+// (a token holder must not learn which of those three it was). This is the
+// one sane message for all three. A 404 that DOES carry an "error" field
+// (an unshipped version on promote, a channel with no history on rollback)
+// is a genuine server diagnosis, and apiErrorFromBody surfaces THAT text
+// instead — never rewritten into this one.
+func notFoundErr() error {
+ return errors.New("not found: check -account and -app, and that your token's address is a member")
+}
+
+// apiErrorFromBody turns a non-2xx status and its raw body into an error.
+// The JSON {"error": "..."} shape is tried FIRST, whatever the status code —
+// several doors (promote's unshipped-version 404, rollback's no-history 409)
+// answer a real diagnosis worth showing verbatim. Only when the body has no
+// such field at all — exactly what apiAccountApp's gate sends, a text/plain
+// "404 page not found" — does a 404 fall back to the generic membership
+// message; every other unreadable body just reports its status.
+func apiErrorFromBody(status int, body []byte) error {
+ var e struct {
+ Error string `json:"error"`
+ }
+ if json.Unmarshal(body, &e) == nil && e.Error != "" {
+ return errors.New(e.Error)
+ }
+ if status == http.StatusNotFound {
+ return notFoundErr()
+ }
+ return fmt.Errorf("unexpected response (status %d)", status)
+}
+
+// getJSON runs one JSON GET and hands back the status and raw body without
+// interpreting a non-200 as an error itself, for the same reason postJSON
+// doesn't: a caller here needs the FULL body (to try apiErrorFromBody's
+// json-first decode), not just a pre-picked message.
+func (s *apiSession) getJSON(ctx context.Context, path string) (int, []byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.c.base+path, nil)
+ if err != nil {
+ return 0, nil, err
+ }
+ if s.c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+s.c.token)
+ }
+ res, err := s.c.httpc.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return res.StatusCode, nil, err
+ }
+ return res.StatusCode, body, nil
+}
+
+// postJSON runs one JSON POST and hands back the status and raw body
+// without interpreting a non-200 as an error itself. Unlike
+// (*consoleClient).do, which keeps only a bare "error" string, promote and
+// rollback need the FULL body on a stable refusal — its console_url would
+// otherwise be discarded before the caller ever sees it.
+func (s *apiSession) postJSON(ctx context.Context, path string, in any) (int, []byte, error) {
+ raw, err := json.Marshal(in)
+ if err != nil {
+ return 0, nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.c.base+path, bytes.NewReader(raw))
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if s.c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+s.c.token)
+ }
+ res, err := s.c.httpc.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return res.StatusCode, nil, err
+ }
+ return res.StatusCode, body, nil
+}
+
+// stableRefusal decodes handleAPIPromote/handleAPIRollback's 403 shape
+// ({"error", "console_url"}) and, when it matches, returns the error the
+// caller should see: the console's own refusal text plus where to finish
+// the job — a passkey step-up has no bearer-token equivalent, so stable
+// promotion/rollback always ends here, never on this machine.
+func stableRefusal(status int, body []byte) (error, bool) {
+ if status != http.StatusForbidden {
+ return nil, false
+ }
+ var refusal struct {
+ Error string `json:"error"`
+ ConsoleURL string `json:"console_url"`
+ }
+ if json.Unmarshal(body, &refusal) != nil || refusal.Error == "" {
+ return nil, false
+ }
+ return fmt.Errorf("%s\n %s", refusal.Error, refusal.ConsoleURL), true
+}
+
+// promote asks the console to promote version onto channel. The response's
+// "result" is the one field read here — "noop" when the ladder already had
+// this version on this channel — because everything else the response
+// carries (RUNG names in From/To) is not what the caller prints: the CLI
+// already knows the version/channel it asked for and prints those, never
+// res.From/res.To.
+func (s *apiSession) promote(ctx context.Context, account, app, version, channel string) (apiPromoteResult, error) {
+ in := struct {
+ Version string `json:"version"`
+ Channel string `json:"channel"`
+ }{version, channel}
+ status, body, err := s.postJSON(ctx, apiPath(account, app, "/promote"), in)
+ if err != nil {
+ return apiPromoteResult{}, err
+ }
+ if status == http.StatusOK {
+ var res struct {
+ Result string `json:"result"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiPromoteResult{}, fmt.Errorf("promote: unreadable response: %w", err)
+ }
+ return apiPromoteResult{NoOp: res.Result == "noop"}, nil
+ }
+ if refErr, ok := stableRefusal(status, body); ok {
+ return apiPromoteResult{}, refErr
+ }
+ return apiPromoteResult{}, apiErrorFromBody(status, body)
+}
+
+// rollback asks the console to roll channel back to its previous version.
+// There is no noop case for a rollback — it either has a previous version to
+// land on (409 if not) or it doesn't — so this stays error-only.
+func (s *apiSession) rollback(ctx context.Context, account, app, channel string) error {
+ in := struct {
+ Channel string `json:"channel"`
+ }{channel}
+ status, body, err := s.postJSON(ctx, apiPath(account, app, "/rollback"), in)
+ if err != nil {
+ return err
+ }
+ if status == http.StatusOK {
+ return nil
+ }
+ if refErr, ok := stableRefusal(status, body); ok {
+ return refErr
+ }
+ return apiErrorFromBody(status, body)
+}
+
+// channels reads one app's channel ladder through the console.
+func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiChannel, error) {
+ status, body, err := s.getJSON(ctx, apiPath(account, app, "/channels"))
+ if err != nil {
+ return nil, err
+ }
+ if status != http.StatusOK {
+ return nil, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Channels []struct {
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ PromotedAt string `json:"promoted_at"`
+ Rung string `json:"rung"`
+ Error string `json:"error"`
+ } `json:"channels"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return nil, fmt.Errorf("channels: unreadable response: %w", err)
+ }
+ out := make([]apiChannel, len(res.Channels))
+ for i, ch := range res.Channels {
+ out[i] = apiChannel{
+ Channel: ch.Channel, Version: ch.Version,
+ PromotedAt: ch.PromotedAt, Rung: ch.Rung, Err: ch.Error,
+ }
+ }
+ return out, nil
+}
+
+// shipTimeout bounds ship's own context — generous on purpose. The console
+// gives itself up to 10 minutes to read the request body and 2 minutes to
+// store it (internal/console/apicli.go), well past the 30s absolute Timeout
+// the JSON verbs' shared client carries; ship must never inherit that 30s
+// cap (doMultipart, auth.go, uses its own client for exactly this reason),
+// but it still needs SOME bound of its own rather than running forever.
+const shipTimeout = 15 * time.Minute
+
+// ship uploads one release's artifacts as a multipart POST: the meta part
+// ({"version","kind"}) first, then one file part per entry in files (partName
+// -> local path), in map-key-sorted order — sorted so the wire bytes are
+// deterministic, since Go's map iteration order is not.
+func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) (apiShipResult, error) {
+ ctx, cancel := context.WithTimeout(ctx, shipTimeout)
+ defer cancel()
+
+ names := make([]string, 0, len(files))
+ for name := range files {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ status, body, err := s.c.doMultipart(ctx, apiPath(account, app, "/releases"), func(mw *multipart.Writer) error {
+ metaJSON, err := json.Marshal(struct {
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+ }{version, kind})
+ if err != nil {
+ return err
+ }
+ metaPart, err := mw.CreateFormField("meta")
+ if err != nil {
+ return err
+ }
+ if _, err := metaPart.Write(metaJSON); err != nil {
+ return err
+ }
+ for _, name := range names {
+ if err := writeFilePart(mw, name, files[name]); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return apiShipResult{}, err
+ }
+ // handleAPIShip (internal/console/apicli.go) sends "result": "shipped"
+ // on BOTH paths — status is the only signal that distinguishes them: 201
+ // for bytes actually written, 200 when the request matched an identical
+ // version already on record and nothing new was stored.
+ switch status {
+ case http.StatusCreated:
+ return apiShipResult{NoOp: false}, nil
+ case http.StatusOK:
+ return apiShipResult{NoOp: true}, nil
+ default:
+ return apiShipResult{}, apiErrorFromBody(status, body)
+ }
+}
+
+// writeFilePart streams one local file into a multipart part named name, so
+// a 30MiB artifact is read a chunk at a time rather than buffered whole
+// before it starts crossing the wire.
+func writeFilePart(mw *multipart.Writer, name, path string) error {
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ part, err := mw.CreateFormFile(name, filepath.Base(path))
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(part, f)
+ return err
+}
diff --git a/apimode_test.go b/apimode_test.go
new file mode 100644
index 000000000..88d5c7cb0
--- /dev/null
+++ b/apimode_test.go
@@ -0,0 +1,1243 @@
+// Tests for API mode resolution and the console API client the other CLI
+// verbs (Task 5) build on: which mode the CLI is in (direct bucket access or
+// through a console it holds a credential for), and the four calls a session
+// in API mode can make.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "flag"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/edge"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// captureStdout swaps the package-level stdout var (cli.go — the seam the
+// four API-aware verbs print through) for a buffer, restoring it on cleanup.
+// log.Fatal paths are untouched by this seam on purpose — those still exit
+// the process, exactly as before.
+func captureStdout(t *testing.T) *bytes.Buffer {
+ t.Helper()
+ var buf bytes.Buffer
+ orig := stdout
+ stdout = &buf
+ t.Cleanup(func() { stdout = orig })
+ return &buf
+}
+
+// clearDeploymentEnv ensures none of the three env vars resolveMode/pick
+// read leak in from the process running the test binary — resolveMode must
+// see a clean slate to make its own env check meaningful, and CARLOS_CONSOLE
+// left set would silently change which pick() branch a "" -console flag
+// takes.
+func clearDeploymentEnv(t *testing.T) {
+ t.Helper()
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "")
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", "")
+ t.Setenv("CARLOS_CONSOLE", "")
+}
+
+func TestResolveModeDirectWins(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "some-bucket")
+
+ // A credential is present too — direct mode must win regardless, since a
+ // box can be both logged in and configured for direct bucket access, and
+ // existing direct-mode behaviour must not change out from under it.
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ sess, err := resolveMode("")
+ if err != nil || sess != nil {
+ t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_BUCKET set = (%+v, %v), want (nil, nil)", sess, err)
+ }
+}
+
+func TestResolveModeDirWins(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", t.TempDir())
+
+ sess, err := resolveMode("")
+ if err != nil || sess != nil {
+ t.Fatalf("resolveMode with CARLOS_DEPLOYMENT_DIR set = (%+v, %v), want (nil, nil)", sess, err)
+ }
+}
+
+func TestResolveModeAPI(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ sess, err := resolveMode("")
+ if err != nil {
+ t.Fatalf("resolveMode: %v", err)
+ }
+ if sess == nil {
+ t.Fatal("resolveMode with one credential = nil session, want one")
+ }
+ if sess.console != "https://a.example" {
+ t.Fatalf("session console = %q, want https://a.example", sess.console)
+ }
+ if sess.address != "a@keymail.dev" {
+ t.Fatalf("session address = %q, want a@keymail.dev", sess.address)
+ }
+ if sess.c == nil {
+ t.Fatal("session has no console client")
+ }
+}
+
+func TestResolveModeNothing(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ sess, err := resolveMode("")
+ if sess != nil {
+ t.Fatalf("resolveMode with nothing configured = %+v, want nil session", sess)
+ }
+ if err == nil {
+ t.Fatal("resolveMode with nothing configured must error")
+ }
+ if !strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") {
+ t.Fatalf("error %q does not mention CARLOS_DEPLOYMENT_BUCKET", err)
+ }
+ if !strings.Contains(err.Error(), "carlos auth login") {
+ t.Fatalf("error %q does not mention carlos auth login", err)
+ }
+}
+
+// TestResolveModeExpiredCredentialOnly pins the minor fix: a credential that
+// EXISTS but has expired is a different situation from nothing configured at
+// all, and deserves a message that says so instead of offering to set up
+// direct mode from scratch.
+func TestResolveModeExpiredCredentialOnly(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ f, _ := loadCredentials()
+ f.put(credential{Console: "https://a.example", Address: "a@keymail.dev", Token: "t1", ExpiresAt: time.Now().Add(-time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ sess, err := resolveMode("")
+ if sess != nil {
+ t.Fatalf("resolveMode with only an expired credential = %+v, want nil session", sess)
+ }
+ if err == nil {
+ t.Fatal("resolveMode with only an expired credential must error")
+ }
+ if !strings.Contains(err.Error(), "https://a.example") {
+ t.Fatalf("error %q does not name the expired console", err)
+ }
+ if !strings.Contains(err.Error(), "expired") {
+ t.Fatalf("error %q does not say the credential expired", err)
+ }
+ if !strings.Contains(err.Error(), "carlos auth login") {
+ t.Fatalf("error %q does not name the fix", err)
+ }
+ if strings.Contains(err.Error(), "CARLOS_DEPLOYMENT_BUCKET") {
+ t.Fatalf("error %q wrongly offers direct mode for an expired (not absent) credential", err)
+ }
+}
+
+// TestResolveModeConsoleFlagRefusedWithStoreEnv pins the design ruling from
+// the review round (2026-08-03): an explicit -console alongside a store env
+// is refused outright, rather than silently winning as direct mode with
+// -console quietly ignored — the spec's "the two modes never mix in one
+// invocation" intent (§1) would otherwise be violated invisibly. Both store
+// env vars are checked; a BARE env with no -console still wins silently,
+// unchanged (already pinned by TestResolveModeDirectWins/TestResolveModeDirWins,
+// both of which call resolveMode("")).
+func TestResolveModeConsoleFlagRefusedWithStoreEnv(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ envVar string
+ }{
+ {"bucket", "CARLOS_DEPLOYMENT_BUCKET"},
+ {"dir", "CARLOS_DEPLOYMENT_DIR"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ t.Setenv(tc.envVar, "something")
+
+ sess, err := resolveMode("https://a.example")
+ if sess != nil {
+ t.Fatalf("resolveMode with -console AND %s set = %+v, want nil session", tc.envVar, sess)
+ }
+ if err == nil {
+ t.Fatalf("resolveMode with -console AND %s set must error", tc.envVar)
+ }
+ if !strings.Contains(err.Error(), "-console") {
+ t.Fatalf("error %q does not mention -console", err)
+ }
+ if !strings.Contains(err.Error(), tc.envVar) {
+ t.Fatalf("error %q does not mention %s", err, tc.envVar)
+ }
+ })
+ }
+}
+
+func newTestSession(base string) *apiSession {
+ return &apiSession{c: newConsoleClient(base, "cli-token"), console: base, address: "a@keymail.dev"}
+}
+
+// TestAPISessionShipMultipart pins the wire shape handleAPIShip
+// (internal/console/apicli.go) requires: bearer auth, the meta part first
+// with exact JSON, then artifact parts in map-key-sorted order (so the wire
+// bytes are deterministic instead of depending on map iteration order).
+func TestAPISessionShipMultipart(t *testing.T) {
+ var gotAuth string
+ var partNames []string
+ var metaBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Errorf("request was not multipart: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Errorf("reading part: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ b, err := io.ReadAll(part)
+ if err != nil {
+ t.Errorf("reading part body: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ if part.FormName() == "meta" {
+ metaBody = string(b)
+ }
+ partNames = append(partNames, part.FormName())
+ }
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{},
+ })
+ }))
+ defer ts.Close()
+
+ dir := t.TempDir()
+ fileA := filepath.Join(dir, "a.bin")
+ fileB := filepath.Join(dir, "b.bin")
+ if err := os.WriteFile(fileA, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(fileB, []byte("bbb"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ s := newTestSession(ts.URL)
+ res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{
+ "zeta-amd64": fileB,
+ "alpha-amd64": fileA,
+ })
+ if err != nil {
+ t.Fatalf("ship: %v", err)
+ }
+ if res.NoOp {
+ t.Fatal("a fresh (201) ship must not report NoOp")
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer cli-token")
+ }
+ if want := `{"version":"1.2.3","kind":"binary"}`; metaBody != want {
+ t.Fatalf("meta part = %q, want %q", metaBody, want)
+ }
+ wantOrder := []string{"meta", "alpha-amd64", "zeta-amd64"}
+ if len(partNames) != len(wantOrder) {
+ t.Fatalf("part names = %v, want %v", partNames, wantOrder)
+ }
+ for i := range wantOrder {
+ if partNames[i] != wantOrder[i] {
+ t.Fatalf("part order = %v, want %v", partNames, wantOrder)
+ }
+ }
+}
+
+// TestAPISessionShipNoOp pins handleAPIShip's identical-reship response
+// exactly as apicli.go sends it: still "result": "shipped" — 200 rather
+// than 201 is the ONLY signal that nothing new was written, so NoOp must be
+// keyed off status, not the result string.
+func TestAPISessionShipNoOp(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Errorf("request was not multipart: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ for {
+ _, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Errorf("reading part: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ }
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "shipped", "version": "1.2.3", "artifacts": map[string]any{},
+ })
+ }))
+ defer ts.Close()
+
+ dir := t.TempDir()
+ file := filepath.Join(dir, "a.bin")
+ if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s := newTestSession(ts.URL)
+ res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ if err != nil {
+ t.Fatalf("ship: %v", err)
+ }
+ if !res.NoOp {
+ t.Fatalf("ship result = %+v, want NoOp true for a 200 identical re-ship", res)
+ }
+}
+
+func TestAPISessionShipErrorSurfacesServerMessage(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "version 1.2.3 was already shipped with different content",
+ })
+ }))
+ defer ts.Close()
+
+ dir := t.TempDir()
+ file := filepath.Join(dir, "a.bin")
+ if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s := newTestSession(ts.URL)
+ _, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ if err == nil || !strings.Contains(err.Error(), "already shipped with different content") {
+ t.Fatalf("ship error = %v, want the server's conflict message", err)
+ }
+}
+
+// TestAPISessionShipSurvivesSlowServer is the CRITICAL fix's proof: a slow
+// server holds the connection open longer than an old-style absolute client
+// Timeout would tolerate, but well within ship's own generous context.
+// shipHTTPClientTimeout (auth.go) is the injectable seam that lets both
+// halves of the point run fast, without an actual multi-minute sleep:
+// shrunk to a few milliseconds, the request fails exactly the way the
+// pre-fix code (which reused the JSON verbs' 30s-Timeout client) would have
+// on a real large upload; restored to its production zero value, the same
+// slow server succeeds because only the context bounds the request.
+func TestAPISessionShipSurvivesSlowServer(t *testing.T) {
+ t.Run("an absolute client Timeout fails against a slow server", func(t *testing.T) {
+ release := make(chan struct{})
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ <-release
+ io.Copy(io.Discard, r.Body)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}})
+ }))
+ defer func() {
+ close(release)
+ ts.Close()
+ }()
+
+ dir := t.TempDir()
+ file := filepath.Join(dir, "a.bin")
+ if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s := newTestSession(ts.URL)
+
+ old := shipHTTPClientTimeout
+ shipHTTPClientTimeout = 30 * time.Millisecond
+ defer func() { shipHTTPClientTimeout = old }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _, err := s.ship(ctx, "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ if err == nil {
+ t.Fatal("ship with a tiny absolute client Timeout against a slow-to-answer server must fail")
+ }
+ })
+
+ t.Run("no absolute timeout survives the same slow server", func(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(150 * time.Millisecond) // longer than the tiny timeout above; well inside ship's context
+ io.Copy(io.Discard, r.Body)
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "1.2.3", "artifacts": map[string]any{}})
+ }))
+ defer ts.Close()
+
+ dir := t.TempDir()
+ file := filepath.Join(dir, "a.bin")
+ if err := os.WriteFile(file, []byte("aaa"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s := newTestSession(ts.URL)
+ // shipHTTPClientTimeout is back at its zero production default here
+ // (the sibling subtest above restored it) — only the context bounds
+ // this call.
+ res, err := s.ship(context.Background(), "acct", "app", "1.2.3", "binary", map[string]string{"alpha-amd64": file})
+ if err != nil {
+ t.Fatalf("ship against a slow-but-healthy server = %v, want success", err)
+ }
+ if res.NoOp {
+ t.Fatal("a fresh (201) ship must not report NoOp")
+ }
+ })
+}
+
+// TestAPISessionStableRefusal pins handleAPIPromote's 403 shape: the CLI
+// never resolves stable itself — it surfaces the console's own error AND the
+// console_url it hands back, because do() only keeps the bare "error"
+// string and a promote() caller needs the link too.
+func TestAPISessionStableRefusal(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "stable needs a passkey — finish in the console",
+ "console_url": "http://x/a/b",
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary")
+ if err == nil {
+ t.Fatal("promote against a stable refusal must error")
+ }
+ if !strings.Contains(err.Error(), "http://x/a/b") {
+ t.Fatalf("promote error %q does not contain the console_url", err)
+ }
+ if !strings.Contains(err.Error(), "passkey") {
+ t.Fatalf("promote error %q does not contain the console's refusal text", err)
+ }
+}
+
+func TestAPISessionRollbackStableRefusal(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "stable needs a passkey — finish in the console",
+ "console_url": "http://x/a/b",
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ err := s.rollback(context.Background(), "acct", "app", "stable")
+ if err == nil || !strings.Contains(err.Error(), "http://x/a/b") {
+ t.Fatalf("rollback error = %v, want it to contain the console_url", err)
+ }
+}
+
+func TestAPISessionPromoteSuccess(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Authorization") != "Bearer cli-token" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ var req struct{ Version, Channel string }
+ json.NewDecoder(r.Body).Decode(&req)
+ if req.Version != "1.2.3" || req.Channel != "canary/a" {
+ t.Errorf("promote body = %+v", req)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": "canary"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ res, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a")
+ if err != nil {
+ t.Fatalf("promote: %v", err)
+ }
+ if res.NoOp {
+ t.Fatal(`promote result = NoOp true, want false for a "promoted" result`)
+ }
+}
+
+// TestAPISessionPromoteNoOp pins the noop-parity fix: PromoteOnLadder
+// already found this version on this channel, and handleAPIPromote answers
+// {"result": "noop"} (still 200) — promote() must report that as NoOp true
+// rather than folding it into an ordinary success indistinguishable from an
+// actual promotion.
+func TestAPISessionPromoteNoOp(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"result": "noop"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ res, err := s.promote(context.Background(), "acct", "app", "1.2.3", "canary/a")
+ if err != nil {
+ t.Fatalf("promote: %v", err)
+ }
+ if !res.NoOp {
+ t.Fatalf(`promote result = %+v, want NoOp true for a "noop" result`, res)
+ }
+}
+
+// TestAPISessionPromoteUnshippedVersionSurfacesServerText pins the
+// IMPORTANT fix: a 404 with a real JSON diagnosis (an unshipped version,
+// handleAPIPromote's release.GetManifest check) must reach the caller
+// verbatim, not be swallowed into the generic membership message that only
+// belongs to apiAccountApp's gate.
+func TestAPISessionPromoteUnshippedVersionSurfacesServerText(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ json.NewEncoder(w).Encode(map[string]string{
+ "error": "Version 9.9.9 is not a shipped release of app.",
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.promote(context.Background(), "acct", "app", "9.9.9", "canary/a")
+ if err == nil || !strings.Contains(err.Error(), "not a shipped release") {
+ t.Fatalf("promote 404 with a JSON diagnosis = %v, want the server's own text", err)
+ }
+ if strings.Contains(err.Error(), "check -account and -app") {
+ t.Fatalf("promote 404 with a real diagnosis must not be replaced by the membership message: %v", err)
+ }
+}
+
+// TestAPISessionPromoteAccountGateNotFound is the companion case: the SAME
+// 404 status, but apiAccountApp's gate (an unknown account/app or one the
+// caller doesn't belong to) — a text/plain http.NotFound body with no
+// "error" field at all — must still get the generic membership message.
+func TestAPISessionPromoteAccountGateNotFound(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.promote(context.Background(), "nope", "app", "1.2.3", "canary/a")
+ if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
+ t.Fatalf("promote against the account/app gate 404 = %v, want the membership message", err)
+ }
+}
+
+// TestAPISessionChannelsNotFound pins the 404 case the parent task called
+// out specifically: apiAccountApp's gate answers http.NotFound, a
+// text/plain body, not JSON — the client must not choke on that and must
+// leave the caller with a sane, actionable message instead of the raw
+// "404 page not found" body.
+func TestAPISessionChannelsNotFound(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.channels(context.Background(), "nope", "app")
+ if err == nil {
+ t.Fatal("channels against a 404 must error")
+ }
+ if !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
+ t.Fatalf("channels 404 error = %q, want a message naming -account/-app", err)
+ }
+}
+
+func TestAPISessionChannels(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "channels": []map[string]any{
+ {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"},
+ {"channel": "broken", "error": "signature mismatch"},
+ },
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ chans, err := s.channels(context.Background(), "acct", "app")
+ if err != nil {
+ t.Fatalf("channels: %v", err)
+ }
+ if len(chans) != 2 {
+ t.Fatalf("channels = %+v, want 2 rows", chans)
+ }
+ if chans[0].Channel != "canary/a" || chans[0].Version != "1.2.3" || chans[0].Rung != "canary" {
+ t.Fatalf("row 0 = %+v", chans[0])
+ }
+ if chans[1].Channel != "broken" || chans[1].Err != "signature mismatch" {
+ t.Fatalf("row 1 = %+v", chans[1])
+ }
+}
+
+// ---- Task 5: the four verbs wired to resolveMode --------------------------
+
+// loginTo saves one live credential for origin/address and returns it, so a
+// verb-level test can log in without going through `carlos auth login`.
+func loginTo(t *testing.T, origin, address string) {
+ t.Helper()
+ f, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.put(credential{Console: origin, Address: address, Token: "cli-token", ExpiresAt: time.Now().Add(time.Hour)})
+ if err := f.save(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// apiTestAccount is the -account value every API-mode verb test below uses.
+// Deliberately NOT sqid-shaped and not "ops": every prior round of these
+// tests happened to pass an -account value ("acct") that resolveAccount
+// would have resolved WITHOUT error had it wrongly been called (release.
+// ValidAccount("acct") happens to be true, so a stray resolveAccount call
+// would have silently "worked" and gone unnoticed). "vickys-team" fails
+// that same check, so a regression that starts calling acctOf()/
+// resolveAccount in API mode again is loud (log.Fatal, or at minimum the
+// registry.db file requireNoRegistryTouch below watches for) rather than
+// silently passing.
+const apiTestAccount = "vickys-team"
+
+// requireNoRegistryTouch points CARLOS_DATA_DIR at a fresh, empty temp
+// directory and returns a func the caller runs AFTER the verb under test:
+// it fails the test if registry.db appeared there. accountFlags' resolver
+// (resolveAccount) is the only thing in this file that ever opens the
+// registry, and registry.Open creates the file on first open even if
+// resolution goes on to fail — so its presence is direct, mechanical proof
+// that API mode reached into the local registry, which it must never do
+// (there is no registry on a developer laptop to reach into).
+func requireNoRegistryTouch(t *testing.T) func() {
+ t.Helper()
+ dir := t.TempDir()
+ t.Setenv("CARLOS_DATA_DIR", dir)
+ return func() {
+ t.Helper()
+ path := filepath.Join(dir, "registry.db")
+ if _, err := os.Stat(path); err == nil {
+ t.Fatalf("%s was created — API mode must never open the local registry", path)
+ } else if !os.IsNotExist(err) {
+ t.Fatalf("stat %s: %v", path, err)
+ }
+ }
+}
+
+// newShipFlagSet builds the -account/-registry flags runShipAPI's callers
+// register in production (accountFlags), parses account plus the given
+// positional file/dir args exactly as fs.Parse(os.Args) would, and returns
+// the FlagSet and the raw -account pointer runShipAPI expects — letting a
+// test drive runShipAPI directly (bypassing runShip's own flag set and,
+// crucially, its log.Fatal on error) so a refusal can be asserted as a
+// returned error instead of a process exit.
+func newShipFlagSet(t *testing.T, account string, args ...string) (*flag.FlagSet, *string) {
+ t.Helper()
+ fs := flag.NewFlagSet("ship", flag.ContinueOnError)
+ acctRaw, _ := accountFlags(fs)
+ full := append([]string{"-account", account}, args...)
+ if err := fs.Parse(full); err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ return fs, acctRaw
+}
+
+// TestShipVerbAPIMode pins runShip's API-mode wiring end to end: with a live
+// credential and neither store env set, it must ship through the console
+// (not openStore/release.Ship) and print the first-use "using <origin> as
+// <address>" line ahead of the ordinary "shipped" line — no artifact count,
+// per the noop-parity amendment, since the API-mode message is deliberately
+// its own shape.
+func TestShipVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var reqCount int32
+ mux := http.NewServeMux()
+ mux.HandleFunc("POST /api/cli/apps/"+apiTestAccount+"/titogo/releases", func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Errorf("not multipart: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ for {
+ _, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Errorf("part: %v", err)
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ }
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "shipped", "version": "abc1234", "artifacts": map[string]any{},
+ })
+ })
+ ts := httptest.NewServer(mux)
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "carlos")
+ if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if !strings.Contains(out, "shipped titogo abc1234") {
+ t.Fatalf("stdout = %q, want the shipped line", out)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+ checkNoRegistry()
+}
+
+// TestShipVerbAPIModeNoOp pins the ship side of the noop-parity amendment: a
+// 200 (rather than 201) response is a re-ship of identical content, and the
+// verb must print the specified "already shipped ... nothing to do" line, not
+// the ordinary "shipped" line.
+func TestShipVerbAPIModeNoOp(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Fatalf("not multipart: %v", err)
+ }
+ for {
+ if _, err := mr.NextPart(); err == io.EOF {
+ break
+ } else if err != nil {
+ t.Fatalf("part: %v", err)
+ }
+ }
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]any{
+ "result": "shipped", "version": "abc1234", "artifacts": map[string]any{},
+ })
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "carlos")
+ if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runShip([]string{"-app", "titogo", "-account", apiTestAccount, "-version", "abc1234", "-target", "linux-arm64", bin})
+
+ out := buf.String()
+ if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want %q", out, want)
+ }
+ if strings.Contains(out, "shipped titogo abc1234\n") {
+ t.Fatalf("stdout = %q, must not ALSO print the ordinary shipped line", out)
+ }
+ checkNoRegistry()
+}
+
+// TestPromoteVerbAPIMode pins runPromote's API-mode wiring: it must promote
+// through the console and print the SAME "promoted" line the direct path
+// prints (cli.go's runPromote default case) — the verb, not the transport,
+// owns that wording.
+func TestPromoteVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/promote" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ var req struct{ Version, Channel string }
+ json.NewDecoder(r.Body).Decode(&req)
+ if req.Version != "abc1234" || req.Channel != "canary/x" {
+ t.Errorf("promote body = %+v", req)
+ }
+ json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "from": "", "to": ""})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if want := "promoted titogo abc1234 -> canary/x"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want %q", out, want)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+ checkNoRegistry()
+}
+
+// TestPromoteVerbAPIModeNoOp pins the noop-parity amendment itself: API-mode
+// promote must print the byte-identical "already on X — nothing to do" shape
+// direct mode prints (runPromote's res.NoOp branch), not a differently-worded
+// API-mode-only message.
+func TestPromoteVerbAPIModeNoOp(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"result": "noop"})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runPromote([]string{"-app", "titogo", "-account", apiTestAccount, "abc1234", "canary/x"})
+
+ out := buf.String()
+ if want := "titogo abc1234 is already on canary/x — nothing to do"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want %q", out, want)
+ }
+ checkNoRegistry()
+}
+
+// TestChannelsVerbAPIMode pins runChannels' API-mode wiring: it must read the
+// ladder through the console and print the same tabwriter table direct mode
+// prints, ERROR rows included (apiChannel.Err mirrors runChannels' per-row
+// ERROR behaviour, per apimode.go's doc comment).
+func TestChannelsVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/channels" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "channels": []map[string]any{
+ {"channel": "canary/a", "version": "1.2.3", "promoted_at": "2026-08-03T00:00:00Z", "rung": "canary"},
+ {"channel": "broken", "error": "signature mismatch"},
+ },
+ })
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runChannels([]string{"-app", "titogo", "-account", apiTestAccount})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "VERSION") || !strings.Contains(out, "PROMOTED") {
+ t.Fatalf("stdout = %q, want the tabwriter header", out)
+ }
+ if !strings.Contains(out, "canary/a") || !strings.Contains(out, "1.2.3") {
+ t.Fatalf("stdout = %q, want the canary/a row", out)
+ }
+ if !strings.Contains(out, "broken") || !strings.Contains(out, "ERROR: signature mismatch") {
+ t.Fatalf("stdout = %q, want the broken row's ERROR text", out)
+ }
+ checkNoRegistry()
+}
+
+// TestRollbackVerbAPIMode pins runRollback's API-mode wiring.
+func TestRollbackVerbAPIMode(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ if r.URL.Path != "/api/cli/apps/"+apiTestAccount+"/titogo/rollback" {
+ t.Errorf("path = %s", r.URL.Path)
+ }
+ var req struct{ Channel string }
+ json.NewDecoder(r.Body).Decode(&req)
+ if req.Channel != "canary/x" {
+ t.Errorf("rollback body = %+v", req)
+ }
+ json.NewEncoder(w).Encode(map[string]string{"result": "rolled back"})
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ buf := captureStdout(t)
+ runRollback([]string{"-app", "titogo", "-account", apiTestAccount, "canary/x"})
+
+ out := buf.String()
+ if want := "using " + ts.URL + " as vicky@keymail.dev"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want it to contain %q", out, want)
+ }
+ if want := "rolled back titogo canary/x"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want %q", out, want)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+ checkNoRegistry()
+}
+
+// TestPromoteVerbDirectUnchanged pins the other half of resolveMode's
+// contract: with CARLOS_DEPLOYMENT_DIR set, runPromote must take the existing
+// direct-bucket path unchanged and never dial a console at all — even one it
+// holds a live (and, if it mistakenly used it, perfectly usable) credential
+// for. The httptest server's request counter is the proof: it must stay 0.
+func TestPromoteVerbDirectUnchanged(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer ts.Close()
+ // A live credential pointed AT the counting server: direct mode must win
+ // regardless (CARLOS_DEPLOYMENT_DIR set below), so this must never be
+ // dialed — if it ever were, reqCount would catch it.
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ dir := t.TempDir()
+ t.Setenv("CARLOS_DEPLOYMENT_DIR", dir)
+
+ seed, pub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("CARLOS_RELEASE_KEY", seed)
+ t.Setenv("CARLOS_RELEASE_PUBKEY", pub)
+
+ bin := filepath.Join(t.TempDir(), "carlos")
+ if err := os.WriteFile(bin, []byte("binary bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runShip([]string{"-app", "demoapp", "-version", "abc1234", "-target", "linux-arm64", bin})
+ runPromote([]string{"-app", "demoapp", "abc1234", "canary/x"})
+
+ out := buf.String()
+ // Pins ship's direct-mode print format exactly (review round, MINOR 8):
+ // unlike API mode's deliberately different "shipped <app> <version>"
+ // (no count), direct mode still names the artifact count — this must
+ // stay byte-identical to what it printed before Task 5 touched this verb.
+ if want := "shipped demoapp abc1234 (1 artifacts)"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want the direct-mode shipped line", out)
+ }
+ if want := "promoted demoapp abc1234 -> canary/x"; !strings.Contains(out, want) {
+ t.Fatalf("stdout = %q, want the direct-mode promoted line", out)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 0 {
+ t.Fatalf("server got %d requests, want 0 — direct mode must never dial the console", got)
+ }
+}
+
+// ---- Task 5 review round: runShipAPI's static/multi-file/pre-flight paths -
+
+// TestShipAPIStaticNamesSiteTarGz pins runShipAPI's static branch end to end
+// (review round, IMPORTANT 2 — previously zero coverage): the packed tarball
+// must reach the server in a part named EXACTLY "site.tar.gz", carrying the
+// same bytes edge.PackDir itself produces for the same directory — proof the
+// scratch-temp-file plumbing (PackDir -> temp file -> multipart part) does
+// not mangle anything in between.
+func TestShipAPIStaticNamesSiteTarGz(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("<h1>hi</h1>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ wantBytes, err := edge.PackDir(siteDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var gotName string
+ var gotBytes []byte
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Fatalf("not multipart: %v", err)
+ }
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatalf("part: %v", err)
+ }
+ b, err := io.ReadAll(part)
+ if err != nil {
+ t.Fatalf("read part: %v", err)
+ }
+ if part.FormName() != "meta" {
+ gotName = part.FormName()
+ gotBytes = b
+ }
+ }
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}})
+ }))
+ defer ts.Close()
+
+ sess := newTestSession(ts.URL)
+ fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir)
+ if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err != nil {
+ t.Fatalf("runShipAPI: %v", err)
+ }
+
+ if gotName != "site.tar.gz" {
+ t.Fatalf("server received artifact part named %q, want %q", gotName, "site.tar.gz")
+ }
+ if !bytes.Equal(gotBytes, wantBytes) {
+ t.Fatalf("server received %d bytes, want the %d bytes edge.PackDir produced for the same directory", len(gotBytes), len(wantBytes))
+ }
+ checkNoRegistry()
+}
+
+// TestShipAPIStaticCleansUpTempFileOnServerError pins the temp-file-leak fix
+// (review round, MINOR 4): runShipAPI used to call log.Fatal on a failing
+// sess.ship, which calls os.Exit and skips every deferred function —
+// including the static kind's scratch temp file cleanup, up to 256MiB per
+// invocation. Returning an error instead (the caller log.Fatals) lets that
+// defer actually run.
+func TestShipAPIStaticCleansUpTempFileOnServerError(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ siteDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hi"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.Copy(io.Discard, r.Body)
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer ts.Close()
+
+ tmpParent := os.TempDir()
+ before, err := os.ReadDir(tmpParent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ beforeNames := map[string]bool{}
+ for _, e := range before {
+ beforeNames[e.Name()] = true
+ }
+
+ sess := newTestSession(ts.URL)
+ fs, acctRaw := newShipFlagSet(t, apiTestAccount, siteDir)
+ if err := runShipAPI(sess, acctRaw, fs, "titesite", "abc1234", "static", ""); err == nil {
+ t.Fatal("runShipAPI against a failing server must error")
+ }
+
+ after, err := os.ReadDir(tmpParent)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, e := range after {
+ if beforeNames[e.Name()] {
+ continue
+ }
+ if strings.HasPrefix(e.Name(), "carlos-ship-") {
+ t.Fatalf("temp file %s leaked after a failed ship", e.Name())
+ }
+ }
+ checkNoRegistry()
+}
+
+// TestShipAPIMultiFileGoodBasenames pins the multi-file binary ship path
+// (review round, IMPORTANT 2 + MINOR 7's good case): every basename shaped
+// like a GOOS-GOARCH target ships as its own part, named exactly its
+// basename.
+func TestShipAPIMultiFileGoodBasenames(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ dir := t.TempDir()
+ fileArm := filepath.Join(dir, "linux-arm64")
+ fileAmd := filepath.Join(dir, "linux-amd64")
+ if err := os.WriteFile(fileArm, []byte("arm bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(fileAmd, []byte("amd bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ var reqCount int32
+ gotParts := map[string]bool{}
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ mr, err := r.MultipartReader()
+ if err != nil {
+ t.Fatalf("not multipart: %v", err)
+ }
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatalf("part: %v", err)
+ }
+ io.Copy(io.Discard, part)
+ if part.FormName() != "meta" {
+ gotParts[part.FormName()] = true
+ }
+ }
+ w.WriteHeader(http.StatusCreated)
+ json.NewEncoder(w).Encode(map[string]any{"result": "shipped", "version": "abc1234", "artifacts": map[string]any{}})
+ }))
+ defer ts.Close()
+
+ sess := newTestSession(ts.URL)
+ fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileArm, fileAmd)
+ if err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64"); err != nil {
+ t.Fatalf("runShipAPI: %v", err)
+ }
+
+ if !gotParts["linux-arm64"] || !gotParts["linux-amd64"] || len(gotParts) != 2 {
+ t.Fatalf("server received parts %v, want exactly linux-arm64 and linux-amd64", gotParts)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+ checkNoRegistry()
+}
+
+// TestShipAPIMultiFileBadBasenameRefused pins the client-side name check
+// (review round, MINOR 7's bad case): a multi-file binary ship whose
+// basename doesn't fit the server's own GOOS-GOARCH shape (targetRe,
+// internal/console/apicli.go) must be refused before ever dialing the
+// console, naming the offending file.
+func TestShipAPIMultiFileBadBasenameRefused(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ dir := t.TempDir()
+ fileGood := filepath.Join(dir, "linux-arm64")
+ fileBad := filepath.Join(dir, "output.bin")
+ if err := os.WriteFile(fileGood, []byte("arm bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(fileBad, []byte("bad bytes"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer ts.Close()
+
+ sess := newTestSession(ts.URL)
+ fs, acctRaw := newShipFlagSet(t, apiTestAccount, fileGood, fileBad)
+ err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64")
+ if err == nil {
+ t.Fatal("runShipAPI with a bad basename must error")
+ }
+ if !strings.Contains(err.Error(), "output.bin") {
+ t.Fatalf("error %q does not name the offending file", err)
+ }
+ if !strings.Contains(err.Error(), "invalid artifact name") {
+ t.Fatalf("error %q does not explain the refusal", err)
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 0 {
+ t.Fatalf("server got %d requests, want 0 — a bad basename must be refused before dialing the console", got)
+ }
+ checkNoRegistry()
+}
+
+// TestShipAPIMissingArtifactRefusedBeforeDialing pins the pre-flight stat
+// (review round, MINOR 6): a typo'd artifact path must fail clean before the
+// console is ever dialed, not after.
+func TestShipAPIMissingArtifactRefusedBeforeDialing(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ missing := filepath.Join(t.TempDir(), "linux-arm64") // never created
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer ts.Close()
+
+ sess := newTestSession(ts.URL)
+ fs, acctRaw := newShipFlagSet(t, apiTestAccount, missing)
+ err := runShipAPI(sess, acctRaw, fs, "titogo", "abc1234", "binary", "linux-arm64")
+ if err == nil {
+ t.Fatal("runShipAPI with a missing artifact path must error")
+ }
+ if got := atomic.LoadInt32(&reqCount); got != 0 {
+ t.Fatalf("server got %d requests, want 0 — a missing path must be refused before dialing the console", got)
+ }
+ checkNoRegistry()
+}
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 1a4528ae1..749f32fc7 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -177,7 +177,7 @@ func runEnvSet(args []string) {
fs := flag.NewFlagSet("env set", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)")
- acctOf := accountFlags(fs)
+ _, acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
log.Fatal("usage: carlos env set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]")
@@ -197,7 +197,7 @@ func runEnvUnset(args []string) {
fs := flag.NewFlagSet("env unset", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)")
- acctOf := accountFlags(fs)
+ _, acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
log.Fatal("usage: carlos env unset -app <a> [-environment <name>] KEY [KEY2 ...]")
@@ -214,7 +214,7 @@ func runEnvList(args []string) {
fs := flag.NewFlagSet("env list", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)")
- acctOf := accountFlags(fs)
+ _, acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
log.Fatal("usage: carlos env list -app <a> [-environment <name>]")
@@ -264,7 +264,7 @@ func runEnvExecDelivery(args []string) {
// below. There is no environment-scoped variant to route it to:
// appconfig.SetExecDelivery has no environment parameter at all.
environment := fs.String("environment", "", "refused: exec_delivery is app-level only (default bundle), never a named environment")
- acctOf := accountFlags(fs)
+ _, acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" || fs.NArg() != 1 {
log.Fatal("usage: carlos env exec-delivery -app <a> file|off")
@@ -299,7 +299,7 @@ func runEnvExecDelivery(args []string) {
func runEnvEnvironments(args []string) {
fs := flag.NewFlagSet("env environments", flag.ExitOnError)
app := fs.String("app", "", "app name")
- acctOf := accountFlags(fs)
+ _, acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
log.Fatal("usage: carlos env environments -app <a>")
@@ -362,7 +362,7 @@ func runSecretsSet(args []string) {
fs := flag.NewFlagSet("secrets set", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)")
- acctOf := accountFlags(fs)
+ _, acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]")
@@ -399,7 +399,7 @@ func runSecretsUnset(args []string) {
fs := flag.NewFlagSet("secrets unset", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)")
- acctOf := accountFlags(fs)
+ _, acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] KEY [KEY2 ...]")
@@ -416,7 +416,7 @@ func runSecretsList(args []string) {
fs := flag.NewFlagSet("secrets list", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)")
- acctOf := accountFlags(fs)
+ _, acctOf := accountFlags(fs)
fs.Parse(args)
if *app == "" {
log.Fatal("usage: carlos secrets list -app <a> [-environment <name>]")
diff --git a/auth.go b/auth.go
index 6e3d42c37..fba2b8a39 100644
--- a/auth.go
+++ b/auth.go
@@ -37,6 +37,7 @@ import (
"flag"
"fmt"
"io"
+ "mime/multipart"
"net"
"net/http"
"net/url"
@@ -394,6 +395,71 @@ func (c *consoleClient) do(ctx context.Context, method, path string, in, out any
return res.StatusCode, nil
}
+// shipHTTPClientTimeout is the ABSOLUTE per-request Timeout doMultipart's own
+// client carries — zero (the production default) means none: a ship relies
+// solely on its caller's context deadline (apimode.go's ship gives itself a
+// generous 15 minutes), because the console's own budget for a big upload —
+// up to 10 minutes to read the body, up to 2 more to store it
+// (internal/console/apicli.go) — already exceeds the 30s absolute Timeout
+// c.httpc carries for the JSON verbs. Reusing that client here would abort a
+// large, still-healthy upload CLIENT-side while the server was still working
+// on it, reporting a live ship as failed.
+//
+// A var only so a test can shrink it far below what any real upload takes —
+// set it small against a deliberately slow server and the request fails
+// fast, proving the failure mode the zero-Timeout client is built to avoid,
+// without an actual multi-minute sleep to demonstrate it.
+var shipHTTPClientTimeout time.Duration
+
+// doMultipart runs one multipart/form-data POST, streaming the body through
+// an io.Pipe rather than buffering it whole: write feeds the multipart.Writer
+// from a goroutine while http.Client reads the other end of the pipe as it
+// sends the request, so a 30MiB artifact is never held twice over (once in
+// caller memory, once again in an http.Client-owned buffer).
+//
+// Unlike do(), a non-200 status here is NOT turned into an error — the raw
+// body comes back alongside the status so a caller that needs more than a
+// bare {"error"} string (a stable-refusal's console_url, ship()'s per-
+// artifact result) can decode it itself.
+func (c *consoleClient) doMultipart(ctx context.Context, path string, write func(*multipart.Writer) error) (int, []byte, error) {
+ pr, pw := io.Pipe()
+ mw := multipart.NewWriter(pw)
+ go func() {
+ err := write(mw)
+ if err == nil {
+ err = mw.Close()
+ }
+ pw.CloseWithError(err)
+ }()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, pr)
+ if err != nil {
+ // The write goroutine above is already running (or about to run) and
+ // will otherwise block forever trying to write to a pipe nobody ever
+ // reads: closing our end unblocks it with io.ErrClosedPipe instead of
+ // leaking it.
+ pr.Close()
+ return 0, nil, err
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ if c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ }
+ // A client of its own — see shipHTTPClientTimeout above — never c.httpc,
+ // whose 30s absolute Timeout is sized for the JSON verbs, not a large
+ // upload.
+ client := &http.Client{Timeout: shipHTTPClientTimeout}
+ res, err := client.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return res.StatusCode, nil, err
+ }
+ return res.StatusCode, body, nil
+}
+
type startResponse struct {
UserCode string `json:"user_code"`
DeviceCode string `json:"device_code"`
diff --git a/auth_e2e_test.go b/auth_e2e_test.go
index 2fadb189f..967e34ef3 100644
--- a/auth_e2e_test.go
+++ b/auth_e2e_test.go
@@ -1,7 +1,15 @@
package main
import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
"os"
"os/exec"
"path/filepath"
@@ -9,6 +17,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/console"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/release"
)
@@ -197,3 +206,292 @@ func TestAccountsCreateUsesTheLoggedInIdentity(t *testing.T) {
}
}
}
+
+// ---- Task 6: the whole chain, end to end -----------------------------------
+
+// e2ePostJSON is a bare, unauthenticated JSON POST — the shape authLogin's
+// own start/poll calls make (auth.go).
+func e2ePostJSON(t *testing.T, u string, body any) map[string]any {
+ 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")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ var out map[string]any
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// e2eDeviceLogin drives `carlos auth login`'s three HTTP legs — POST
+// /auth/cli/start, POST /auth/cli/approve, POST /auth/cli/poll — against a
+// REAL console.Server, the exact wire protocol authLogin (auth.go) speaks.
+//
+// The one leg this does NOT reproduce is the human's own trip through
+// Keymail: internal/console/authcli_test.go's TestCLILoginEndToEnd already
+// drives that ceremony end to end (a fake Keymail, PKCE, the passkey
+// step-up redirect) inside package console, so redoing it here would only
+// be the same assertion through a slower path — this test's job is what
+// happens AFTER a token exists, not the ceremony that mints one. So this
+// mints an already-signed-in, already-stepped-up session straight through
+// the Store's own exported primitives — CreateSession + RecordStepUp are
+// exactly what a completed Keymail round trip leaves behind — and then
+// drives approve/poll for real, over real HTTP, against the real handlers.
+func e2eDeviceLogin(t *testing.T, consoleURL string, cst *console.Store, addr, label string) (token string, expiresAt time.Time) {
+ t.Helper()
+ start := e2ePostJSON(t, consoleURL+"/auth/cli/start", map[string]string{"label": label})
+ userCode, _ := start["user_code"].(string)
+ deviceCode, _ := start["device_code"].(string)
+ if userCode == "" || deviceCode == "" {
+ t.Fatalf("auth/cli/start = %v, want both codes", start)
+ }
+
+ now := time.Now()
+ raw, err := cst.CreateSession(addr, "", now)
+ if err != nil {
+ t.Fatalf("mint session: %v", err)
+ }
+ if err := cst.RecordStepUp(raw, addr, now, now); err != nil {
+ t.Fatalf("record step-up: %v", err)
+ }
+
+ form := url.Values{"code": {userCode}, "action": {"approve"}, "csrf": {raw}}
+ req, err := http.NewRequest(http.MethodPost, consoleURL+"/auth/cli/approve", strings.NewReader(form.Encode()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Cookie", "carlos_console_session="+raw)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("approve: %v", err)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Approved") {
+ t.Fatalf("approve = %d:\n%s", resp.StatusCode, body)
+ }
+
+ poll := e2ePostJSON(t, consoleURL+"/auth/cli/poll", map[string]string{"device_code": deviceCode})
+ if poll["status"] != "approved" {
+ t.Fatalf("poll after approval = %v, want approved", poll)
+ }
+ token, _ = poll["token"].(string)
+ if token == "" {
+ t.Fatalf("poll approved with no token: %v", poll)
+ }
+ expiresAt = time.Now().Add(90 * 24 * time.Hour)
+ if es, _ := poll["expires_at"].(string); es != "" {
+ if parsed, err := time.Parse(time.RFC3339, es); err == nil {
+ expiresAt = parsed
+ }
+ }
+ return token, expiresAt
+}
+
+// TestE2ECLIThroughConsole is the acceptance test for the whole feature: a
+// REAL console.Server (a dir store, a real ed25519 signing key — no hand-
+// rolled httptest handlers standing in for it) fronted by an httptest
+// listener, a device-flow login against it landing a real bearer token in a
+// temp credential file, and then — with NO store env set at all, exactly a
+// developer's laptop — every API-mode verb driven through its real cli.go
+// entry point: ship, promote, channels, a refused stable promote, and a
+// rollback.
+func TestE2ECLIThroughConsole(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ tmp := t.TempDir()
+ relStore := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+
+ // The origin must equal the listener's own URL, which only exists after
+ // the listener starts — same indirection internal/console's own fixture
+ // (server_test.go) uses.
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv.ServeHTTP(w, r)
+ }))
+ t.Cleanup(ts.Close)
+
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
+ Registry: filepath.Join(tmp, "registry.db"),
+ Releases: relStore,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ const addr = "paul@keymail.dev"
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ // --- device flow: start -> approve -> poll, to a real token on disk ----
+ token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
+ credFile, err := loadCredentials()
+ if err != nil {
+ t.Fatal(err)
+ }
+ credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
+ if err := credFile.save(); err != nil {
+ t.Fatal(err)
+ }
+
+ // --- ship: a 1KiB linux-arm64 binary, through the console --------------
+ dir := t.TempDir()
+ bin := filepath.Join(dir, "carlos")
+ payload := bytes.Repeat([]byte{0x42}, 1024)
+ if err := os.WriteFile(bin, payload, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ buf := captureStdout(t)
+ runShip([]string{"-app", "titogo", "-account", "ops", "-version", "abc1234", "-target", "linux-arm64", bin})
+ if !strings.Contains(buf.String(), "shipped titogo abc1234") {
+ t.Fatalf("ship stdout = %q, want the shipped line", buf.String())
+ }
+
+ sum := sha256.Sum256(payload)
+ wantHash := hex.EncodeToString(sum[:])
+ m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, "titogo", "abc1234")
+ if err != nil {
+ t.Fatalf("GetManifest after ship: %v", err)
+ }
+ art, ok := m.Artifacts["linux-arm64"]
+ if !ok || art.SHA256 != wantHash || art.Size != int64(len(payload)) {
+ t.Fatalf("shipped manifest artifact = %+v, want sha256 %s size %d", art, wantHash, len(payload))
+ }
+
+ // Identical re-ship: 200, not 201 — the noop-parity line, not the
+ // ordinary "shipped" one.
+ buf.Reset()
+ runShip([]string{"-app", "titogo", "-account", "ops", "-version", "abc1234", "-target", "linux-arm64", bin})
+ if want := "already shipped titogo abc1234 — identical content, nothing to do"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("re-ship stdout = %q, want %q", buf.String(), want)
+ }
+
+ // --- promote to canary/e2e: pointer moves, audit row names the CLI -----
+ buf.Reset()
+ runPromote([]string{"-app", "titogo", "-account", "ops", "abc1234", "canary/e2e"})
+ if want := "promoted titogo abc1234 -> canary/e2e"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("promote stdout = %q, want %q", buf.String(), want)
+ }
+ cp, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e")
+ if err != nil || cp.Version != "abc1234" {
+ t.Fatalf("channel canary/e2e = %+v %v, want version abc1234", cp, err)
+ }
+ events, err := cst.Events(console.OpsAccountID, "titogo", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ foundAudit := false
+ for _, e := range events {
+ if e.Action == "promote" && strings.Contains(e.Detail, "(cli)") {
+ foundAudit = true
+ }
+ }
+ if !foundAudit {
+ t.Fatalf("audit events = %+v, want a promote row naming (cli)", events)
+ }
+
+ // A repeat promote of the same version onto the same channel is a
+ // no-op — the same parity line the direct path prints (Task 5's
+ // noop-parity amendment).
+ buf.Reset()
+ runPromote([]string{"-app", "titogo", "-account", "ops", "abc1234", "canary/e2e"})
+ if want := "titogo abc1234 is already on canary/e2e — nothing to do"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("repeat promote stdout = %q, want %q", buf.String(), want)
+ }
+
+ // --- channels: canary/e2e and its version show up -----------------------
+ buf.Reset()
+ runChannels([]string{"-app", "titogo", "-account", "ops"})
+ if out := buf.String(); !strings.Contains(out, "canary/e2e") || !strings.Contains(out, "abc1234") {
+ t.Fatalf("channels stdout = %q, want canary/e2e and abc1234", out)
+ }
+
+ // --- promote to stable: refused, naming the console; pointer untouched -
+ //
+ // runPromote itself log.Fatals on this error (os.Exit — nothing an
+ // in-process test could recover from), so the refusal is asserted at the
+ // apiSession level instead: the exact call runPromote's API-mode branch
+ // makes, against the same real server, over the same credential.
+ sess := &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr}
+ if _, err := sess.promote(context.Background(), "ops", "titogo", "abc1234", "stable"); err == nil {
+ t.Fatal("promote to stable must refuse")
+ } else {
+ if !strings.Contains(err.Error(), ts.URL) {
+ t.Fatalf("stable refusal %q does not name the console URL", err)
+ }
+ if !strings.Contains(err.Error(), "passkey") {
+ t.Fatalf("stable refusal %q does not carry the console's refusal text", err)
+ }
+ }
+ if _, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "stable"); err == nil {
+ t.Fatal("stable has a pointer — the refused promote must not have created one")
+ }
+
+ // --- a second ship+promote, then roll back ------------------------------
+ payload2 := bytes.Repeat([]byte{0x43}, 1024)
+ bin2 := filepath.Join(dir, "carlos-def5678")
+ if err := os.WriteFile(bin2, payload2, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ buf.Reset()
+ runShip([]string{"-app", "titogo", "-account", "ops", "-version", "def5678", "-target", "linux-arm64", bin2})
+ runPromote([]string{"-app", "titogo", "-account", "ops", "def5678", "canary/e2e"})
+ cp, err = release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e")
+ if err != nil || cp.Version != "def5678" {
+ t.Fatalf("channel canary/e2e after second promote = %+v %v, want def5678", cp, err)
+ }
+
+ buf.Reset()
+ runRollback([]string{"-app", "titogo", "-account", "ops", "canary/e2e"})
+ if want := "rolled back titogo canary/e2e"; !strings.Contains(buf.String(), want) {
+ t.Fatalf("rollback stdout = %q, want %q", buf.String(), want)
+ }
+ cp, err = release.Channel(context.Background(), relStore, pub, release.OpsAccount, "titogo", "canary/e2e")
+ if err != nil || cp.Version != "abc1234" {
+ t.Fatalf("channel canary/e2e after rollback = %+v %v, want back to abc1234", cp, err)
+ }
+
+ checkNoRegistry()
+}
diff --git a/cli.go b/cli.go
index 88872fe60..fdccb2676 100644
--- a/cli.go
+++ b/cli.go
@@ -9,11 +9,14 @@ import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
+ "errors"
"flag"
"fmt"
+ "io"
"log"
"os"
"path/filepath"
+ "regexp"
"strings"
"text/tabwriter"
"time"
@@ -26,6 +29,15 @@ import (
"github.com/carlosframework/platform/internal/version"
)
+// stdout is the print seam for the four release-pipeline verbs that now
+// speak the console API (ship/promote/channels/rollback): a package-level
+// var rather than an os.Stdout literal, so a test can swap it for a buffer
+// and assert on it. Every OTHER verb in this file still writes straight to
+// os.Stdout/fmt.Printf, unchanged — this seam exists only where Task 5 added
+// behaviour worth pinning with an assertion; log.Fatal paths are untouched by
+// it on purpose, exactly as they were before.
+var stdout io.Writer = os.Stdout
+
func dataDir() string {
if d := os.Getenv("CARLOS_DATA_DIR"); d != "" {
return d
@@ -710,13 +722,20 @@ func routeVersionCheck(ctx context.Context, store release.Store, pub ed25519.Pub
// single-tenant deployment never types it. Reads fall back to the legacy
// unqualified keys either way, so an unmigrated app keeps working.
//
-// Returns a thunk rather than the value because resolution has to happen
-// AFTER fs.Parse — and it opens the registry, which a verb that never uses
-// the account should not pay for.
-func accountFlags(fs *flag.FlagSet) func() string {
+// Returns the raw flag value alongside a resolver thunk, rather than the
+// resolved value alone, because resolution has to happen AFTER fs.Parse —
+// and it opens the registry, which a verb that never uses the account should
+// not pay for. The raw pointer exists for API mode (cli.go's four
+// console-API verbs): they must send the -account value verbatim (a
+// developer laptop has no local registry to resolve it against, and the
+// console does its own resolution against the caller's memberships), and
+// reading it straight off this return value keeps that path from being
+// string-coupled to this function's internal flag name the way
+// fs.Lookup("account") would be.
+func accountFlags(fs *flag.FlagSet) (raw *string, resolve func() string) {
acct := fs.String("account", "ops", "account (sqid or name) the app belongs to")
regPath := fs.String("registry", defaultRegistry(), "registry database (resolves -account)")
- return func() string { return resolveAccount(*regPath, *acct) }
+ return acct, func() string { return resolveAccount(*regPath, *acct) }
}
func openStore() release.Store {
@@ -791,14 +810,27 @@ func publicKey() ed25519.PublicKey {
func runShip(args []string) {
fs := flag.NewFlagSet("ship", flag.ExitOnError)
app := fs.String("app", "", "app name")
- acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to ship through (API mode only; default: the only logged-in one)")
kind := fs.String("kind", "binary", "binary | static")
ver := fs.String("version", "", "version (git short sha)")
target := fs.String("target", "linux-arm64", "GOOS-GOARCH the binary was built for (binary kind, single file)")
fs.Parse(args)
if *app == "" || *ver == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] <files-or-site-dir>")
+ log.Fatal("usage: carlos ship -app <a> -version <sha> [-kind binary|static] [-console <origin>] <files-or-site-dir>")
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
}
+ if sess != nil {
+ if err := runShipAPI(sess, acctRaw, fs, *app, *ver, *kind, *target); err != nil {
+ log.Fatal(err)
+ }
+ return
+ }
+
files := map[string][]byte{}
arts := map[string]release.Artifact{}
addFile := func(name string, data []byte) {
@@ -844,7 +876,97 @@ func runShip(args []string) {
if err := release.Ship(context.Background(), openStore(), acctOf(), m, files); err != nil {
log.Fatal(err)
}
- fmt.Printf("shipped %s %s (%d artifacts)\n", *app, *ver, len(files))
+ fmt.Fprintf(stdout, "shipped %s %s (%d artifacts)\n", *app, *ver, len(files))
+}
+
+// shipTargetRe validates a binary artifact's part name client-side, against
+// the exact shape the server enforces (targetRe, internal/console/apicli.go)
+// — checked here too so a malformed name (a build filename shipped without
+// -target, say) is refused before a single byte crosses the wire, rather
+// than after paying for the whole upload and hitting the server's own 400.
+var shipTargetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
+
+// runShipAPI is runShip's API-mode body: the raw -account flag value goes
+// straight to the console (never acctOf/resolveAccount — there is no local
+// registry to resolve against on a developer laptop). Unlike the direct-mode
+// body above, artifacts are streamed from disk by path (apiSession.ship
+// reads them lazily) rather than buffered into memory here first — for a
+// static release, whose bytes only exist in memory (edge.PackDir's tarball),
+// that means writing them to a scratch temp file first so there is a path to
+// hand over at all.
+//
+// Returns an error rather than calling log.Fatal itself (the caller does
+// that): log.Fatal calls os.Exit immediately, which skips every deferred
+// function — including the static kind's scratch temp file cleanup below,
+// up to 256MiB per invocation. Returning lets that defer actually run on
+// every exit path, fatal or not.
+func runShipAPI(sess *apiSession, acctRaw *string, fs *flag.FlagSet, app, ver, kind, target string) error {
+ acct := *acctRaw
+ files := map[string]string{}
+ if kind == "static" {
+ if fs.NArg() != 1 {
+ return errors.New("static ship takes exactly one site directory")
+ }
+ data, err := edge.PackDir(fs.Arg(0))
+ if err != nil {
+ return err
+ }
+ tmp, err := os.CreateTemp("", "carlos-ship-*.tar.gz")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ files["site.tar.gz"] = tmp.Name()
+ } else if fs.NArg() == 1 {
+ files[target] = fs.Arg(0)
+ } else {
+ // Multiple files: pre-named per target (linux-arm64, linux-amd64,
+ // ...) by basename, exactly like the direct-mode body above.
+ for _, path := range fs.Args() {
+ files[filepath.Base(path)] = path
+ }
+ }
+
+ // Client-side name check, binary kind only (a static release's one part
+ // is always named "site.tar.gz" above, never a caller-supplied name) —
+ // same shape the server enforces, checked here so a bad basename is
+ // named and refused before anything is stat'd or dialed.
+ if kind != "static" {
+ for name, path := range files {
+ if !shipTargetRe.MatchString(name) {
+ return fmt.Errorf("ship: invalid artifact name %q (from %s) — want a GOOS-GOARCH shape like linux-arm64 "+
+ "(pass -target to name a single file explicitly)", name, path)
+ }
+ }
+ }
+
+ // Pre-flight: every artifact path must exist before the console is ever
+ // dialed or a byte moves — a typo'd path must fail clean here, not after
+ // the "using ..." line already printed.
+ for name, path := range files {
+ if _, err := os.Stat(path); err != nil {
+ return fmt.Errorf("ship: artifact %q: %w", name, err)
+ }
+ }
+
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ res, err := sess.ship(context.Background(), acct, app, ver, kind, files)
+ if err != nil {
+ return err
+ }
+ if res.NoOp {
+ fmt.Fprintf(stdout, "already shipped %s %s — identical content, nothing to do\n", app, ver)
+ } else {
+ fmt.Fprintf(stdout, "shipped %s %s\n", app, ver)
+ }
+ return nil
}
// runPromote points a channel at a shipped version, with the ladder enforced:
@@ -856,12 +978,42 @@ func runPromote(args []string) {
fs := flag.NewFlagSet("promote", flag.ExitOnError)
app := fs.String("app", "", "app name")
hotfix := fs.Bool("hotfix", false, "bypass the channel ladder (recorded as a hotfix)")
- acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to promote through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 2 {
- log.Fatal("usage: carlos promote -app <a> [-hotfix] <version> <channel>")
+ log.Fatal("usage: carlos promote -app <a> [-hotfix] [-console <origin>] <version> <channel>")
}
version, channel := fs.Arg(0), fs.Arg(1)
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ if *hotfix {
+ // A hotfix bypasses the signed ladder's ordering check entirely —
+ // an operator act, recorded as such (spec §3). The console's own
+ // promote door (handleAPIPromote) has no hotfix field at all, so
+ // there is no way to express this through the API even in
+ // principle; refuse before ever dialing out, rather than let the
+ // flag silently do nothing.
+ log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")
+ }
+ acct := *acctRaw
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ res, err := sess.promote(context.Background(), acct, *app, version, channel)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if res.NoOp {
+ fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
+ } else {
+ fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel)
+ }
+ return
+ }
+
res, err := releaseSigner().PromoteOnLadder(context.Background(), openStore(),
acctOf(), *app, channel, version,
release.PromoteOptions{Hotfix: *hotfix, Actor: promoteActor()})
@@ -870,11 +1022,11 @@ func runPromote(args []string) {
}
switch {
case res.NoOp:
- fmt.Printf("%s %s is already on %s — nothing to do\n", *app, version, channel)
+ fmt.Fprintf(stdout, "%s %s is already on %s — nothing to do\n", *app, version, channel)
case res.Hotfix:
- fmt.Printf("promoted %s %s -> %s (HOTFIX: ladder bypassed, recorded)\n", *app, version, channel)
+ fmt.Fprintf(stdout, "promoted %s %s -> %s (HOTFIX: ladder bypassed, recorded)\n", *app, version, channel)
default:
- fmt.Printf("promoted %s %s -> %s\n", *app, version, channel)
+ fmt.Fprintf(stdout, "promoted %s %s -> %s\n", *app, version, channel)
}
}
@@ -894,11 +1046,37 @@ func promoteActor() string {
func runChannels(args []string) {
fs := flag.NewFlagSet("channels", flag.ExitOnError)
app := fs.String("app", "", "app name")
- acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>]")
+ log.Fatal("usage: carlos channels -app <a> [-account <sqid-or-name>] [-console <origin>]")
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
}
+ if sess != nil {
+ acct := *acctRaw
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ chans, err := sess.channels(context.Background(), acct, *app)
+ if err != nil {
+ log.Fatal(err)
+ }
+ w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
+ fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
+ for _, ch := range chans {
+ if ch.Err != "" {
+ fmt.Fprintf(w, "%s\tERROR: %s\t\n", ch.Channel, ch.Err)
+ continue
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\n", ch.Channel, ch.Version, ch.PromotedAt)
+ }
+ w.Flush()
+ return
+ }
+
acct := acctOf()
s := openStore()
prefix := release.ChannelsPrefix(acct, *app)
@@ -907,7 +1085,7 @@ func runChannels(args []string) {
log.Fatal(err)
}
pub := publicKey()
- w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
+ w := tabwriter.NewWriter(stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "CHANNEL\tVERSION\tPROMOTED")
for _, k := range keys {
name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
@@ -924,15 +1102,31 @@ func runChannels(args []string) {
func runRollback(args []string) {
fs := flag.NewFlagSet("rollback", flag.ExitOnError)
app := fs.String("app", "", "app name")
- acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to roll back through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 1 {
- log.Fatal("usage: carlos rollback -app <a> <channel>")
+ log.Fatal("usage: carlos rollback -app <a> [-console <origin>] <channel>")
}
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ acct := *acctRaw
+ fmt.Fprintf(stdout, "using %s as %s\n", sess.console, sess.address)
+ if err := sess.rollback(context.Background(), acct, *app, fs.Arg(0)); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0))
+ return
+ }
+
if err := releaseSigner().Rollback(context.Background(), openStore(), acctOf(), *app, fs.Arg(0)); err != nil {
log.Fatal(err)
}
- fmt.Printf("rolled back %s %s\n", *app, fs.Arg(0))
+ fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0))
}
// defaultGrantTTL is how long a scoped key lives when the operator does not
diff --git a/docs/superpowers/plans/2026-08-03-cli-console-api.md b/docs/superpowers/plans/2026-08-03-cli-console-api.md
new file mode 100644
index 000000000..7dee0d94c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-03-cli-console-api.md
@@ -0,0 +1,315 @@
+# CLI Through the Console API Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** `carlos ship/promote/rollback/channels` work with only a `carlos auth login` credential — the console does bucket writes and pointer signing server-side.
+
+**Architecture:** New bearer-gated JSON/multipart endpoints in `internal/console` (new file `apicli.go`) that share the browser doors' refusal order and call the same `release.*` functions. The CLI (root package) gains credential-first mode resolution: explicit store env → today's direct path, otherwise a stored credential → API path. Stable promotion is refused at the API with the console URL.
+
+**Tech Stack:** Go stdlib only (net/http, mime/multipart, crypto/sha256). No new dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-08-03-cli-console-api-design.md` — read it first.
+
+## Global Constraints
+
+- Worktree: `.claude/worktrees/cli-console-api` (branch `worktree-cli-console-api`). All work happens there.
+- Build/test env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; tests may need the sandbox disabled (unix sockets / "read-only file system").
+- `go build ./... && go vet ./... && go test ./... -count=1` clean before every commit; `gofmt -l` clean for touched files.
+- Console security-gate ordering is load-bearing (CLAUDE.md): auth → membership/app (byte-identical 404) → cheap refusals → rung gate LAST.
+- Comment style: state constraints the code can't show; match the file's existing density and voice.
+- API paths live under `/api/cli/`; auth endpoints stay under `/auth/cli/` (do not move them).
+- The `{account}` path segment accepts sqid OR name; the server resolves it against the caller's own memberships (resolution and membership proof are the same query — see Task 1).
+
+---
+
+### Task 1: `apicli.go` — auth/resolution helper + the two read endpoints
+
+**Files:**
+- Create: `internal/console/apicli.go`
+- Create: `internal/console/apicli_test.go`
+- Modify: `internal/console/authcli.go` (registerCLIRoutes — add 2 routes)
+
+**Interfaces:**
+- Consumes: `(*Server).cliUser(w, r) (string, bool)` (authcli.go:254), `(*Store).AccountsFor(address) ([]Account, error)`, `(*Store).AppsIn(accountID) ([]string, error)`, `(*Store).HasAppIn(accountID, app) (bool, error)`, `release.Channel(ctx, store, pub, acct, app, name) (*ChannelPointer, error)`, `release.ChannelsPrefix(acct, app) string`, `writeJSON/jsonError` (authcli.go:206,215).
+- Produces (later tasks rely on these exact names):
+ - `func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (addr string, acct *Account, app string, ok bool)` — bearer auth, resolves `r.PathValue("account")` (sqid or name) against the caller's memberships, checks `HasAppIn`. Failure = `http.NotFound(w, r)` after `cliUser` succeeded, `ok=false`; `cliUser` failure already wrote its 401.
+ - Route pattern style: `s.mux.HandleFunc("GET /api/cli/apps", s.handleAPIApps)` etc.
+
+- [ ] **Step 1: Write failing tests** in `apicli_test.go` (package `console`, follow `authcli_test.go` fixtures for building a Server with a temp Store + `release.NewDirStore(t.TempDir())`):
+
+```go
+// TestAPIAppsListsMemberships: seed accounts bab(ops)+bac, member alice in
+// bab only, apps: titogo in bab. Bearer alice-token GET /api/cli/apps →
+// 200 {"accounts":[{"sqid":"bab","name":"ops","role":"member",
+// "apps":["titogo"]}]}. No bac in the body.
+// TestAPIAppsNoToken: no Authorization → 401 with the cliUser body.
+// TestAPIChannelsHappy: promote titogo abc1234 to canary/x in the dir
+// store (release.Ship a minimal manifest, then release.Promote with a test
+// key; ReleasePub pinned to its public half). GET
+// /api/cli/apps/bab/titogo/channels → 200, one entry
+// {"channel":"canary/x","version":"abc1234","rung":"canary",
+// "promoted_at":...}. Account segment "ops" (the NAME) must work
+// identically.
+// TestAPIChannelsRefusalMatrix: (a) member of nothing → 404; (b) member
+// of bab but app unclaimed → 404; (c) both 404 bodies byte-identical to
+// http.NotFound output.
+```
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/console/ -run TestAPI -count=1` → FAIL (handlers undefined).
+
+- [ ] **Step 3: Implement** `apicli.go`:
+
+```go
+// apiAccountApp resolves and authorizes one CLI API request. The account
+// segment accepts a sqid or a name, resolved against the caller's OWN
+// memberships — resolution and membership proof are deliberately the same
+// lookup, so an unknown account and someone else's account are the same 404.
+func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return "", nil, "", false
+ }
+ seg := r.PathValue("account")
+ app := r.PathValue("app")
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not load accounts")
+ return "", nil, "", false
+ }
+ var acct *Account
+ for i := range accts {
+ if accts[i].Sqid == seg || accts[i].Name == seg {
+ acct = &accts[i]
+ break
+ }
+ }
+ if acct == nil {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
+ if has, err := s.store.HasAppIn(acct.ID, app); err != nil || !has {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
+ return addr, acct, app, true
+}
+```
+
+`handleAPIApps`: `cliUser`, `AccountsFor`, per account `AppsIn`, `writeJSON(w, 200, ...)` with the shape in Step 1. `handleAPIChannels`: `apiAccountApp`, then list `s.cfg.Releases.List(ctx, release.ChannelsPrefix(acct.Sqid, app))`, per key `release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, name)`; entries carry `channel`, `version`, `promoted_at` (RFC3339), `rung` (`release.Rung(name)`, empty when not a rung); a pointer that fails verification becomes `{"channel":name,"error":"..."}` — mirror `runChannels`' per-row ERROR behavior (cli.go:856), never a whole-response failure. 15s context timeout like `handlePromote`.
+
+- [ ] **Step 4: Run to verify pass** — same command → PASS.
+- [ ] **Step 5: Commit** — `git add internal/console/apicli.go internal/console/apicli_test.go internal/console/authcli.go && git commit -m "console: CLI API — accounts/apps listing and channels reads"`
+
+---
+
+### Task 2: promote + rollback endpoints
+
+**Files:**
+- Modify: `internal/console/apicli.go`, `internal/console/apicli_test.go`, `internal/console/authcli.go` (2 routes)
+
+**Interfaces:**
+- Consumes: `apiAccountApp` (Task 1), `release.ValidChannel`, `release.ValidVersion`, `release.GetManifest(ctx, store, sqid, app, version)`, `release.PromoteOnLadder(ctx, store, key, sqid, app, channel, version, release.PromoteOptions{Actor: addr}) (PromoteResult, error)`, `release.Rollback(ctx, store, key, sqid, app, channel) error`, `release.Rung(channel) (string, bool)`, `release.RungStable`, `s.audit(acct, app, addr, verb, detail)`.
+- Produces: `POST /api/cli/apps/{account}/{app}/promote` body `{"version":"...","channel":"..."}` → 200 `{"result":"promoted","from":"...","to":"...","noop":false}`; `POST .../rollback` body `{"channel":"..."}` → 200 `{"result":"rolled back"}`. Stable → 403 `{"error":"stable needs a passkey — finish in the console","console_url":"<Origin>/a/<sqid>/apps/<app>"}`.
+
+- [ ] **Step 1: Failing tests**:
+
+```go
+// TestAPIPromoteHappy: shipped abc1234 in dir store; POST promote
+// {version:"abc1234",channel:"canary/x"} → 200; release.Channel now
+// reports abc1234; store audit row exists with detail containing "(cli)".
+// TestAPIPromoteStableRefused: POST channel "stable" → 403 with
+// console_url "<origin>/a/bab/apps/titogo"; response bytes IDENTICAL for
+// a member token and an owner token (spec §3 — role must not leak).
+// TestAPIPromoteNoKey: Server built with Key nil → 503.
+// TestAPIPromoteBadVersion / BadChannel / UnshippedVersion → 400/400/404
+// with jsonError bodies naming the problem.
+// TestAPIPromoteNoHotfix: body {"hotfix":true} decodes into a struct
+// WITHOUT a hotfix field — unknown fields are ignored by encoding/json,
+// and the handler never sets PromoteOptions.Hotfix; ladder-refused
+// promotion stays refused (ship abc1234, promote to beta/x directly →
+// PromoteOnLadder error surfaces as 409 {"error": ladder text}).
+// TestAPIRollbackHappy: two promotions then rollback → pointer back.
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** Door order copied from `handlePromote` (server.go:1170) minus CSRF; the rung gate is the API variant:
+
+```go
+if rung, _ := release.Rung(req.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
+}
+```
+
+Refusal mapping: invalid channel/version → 400; manifest missing → 404 (`jsonError`, text from the browser door: `"Version X is not a shipped release of Y."`); `PromoteOnLadder` error → 409 with `err.Error()` (the ladder text names the rungs); `res.NoOp` → 200 `{"result":"noop"}`. Audit: `s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel+" (cli)")`, rollback likewise.
+
+- [ ] **Step 4: Verify pass.** Also `go test ./internal/console/ -count=1` (whole package — the browser-door tests must still pass).
+- [ ] **Step 5: Commit** — `"console: CLI API — promote and rollback through the browser doors, stable refused"`
+
+---
+
+### Task 3: ship endpoint (multipart upload)
+
+**Files:**
+- Modify: `internal/console/apicli.go`, `internal/console/apicli_test.go`, `internal/console/authcli.go` (1 route)
+
+**Interfaces:**
+- Consumes: `apiAccountApp`, `release.Ship(ctx, store, sqid, m Manifest, files map[string][]byte) error`, `release.Manifest{Schema:1, App, Version, Kind, BuiltBy, BuiltAt, Artifacts map[string]release.Artifact}`, `release.Artifact{File, SHA256, Size}`, `release.ValidVersion`.
+- Produces: `POST /api/cli/apps/{account}/{app}/releases`, multipart/form-data; first part `meta` (JSON `{"version":"...","kind":"binary"|"static"}`), then file parts whose part NAME is the artifact key (`linux-arm64`, `linux-amd64`, or `site.tar.gz` for static). → 201 `{"result":"shipped","version":"...","artifacts":{"linux-arm64":{"sha256":"...","size":N}}}`.
+
+- [ ] **Step 1: Failing tests**:
+
+```go
+// TestAPIShipHappy: multipart with meta {version:"abc1234",kind:"binary"}
+// + part "linux-arm64" of 1KiB random bytes → 201; dir store now has
+// releases/abc1234/linux-arm64 with those bytes and manifest.json whose
+// sha256/size match what the SERVER computed; BuiltBy has prefix
+// "console-api ".
+// TestAPIShipStatic: kind static, part "site.tar.gz" → 201.
+// TestAPIShipRefusals: bad version chars → 400; kind "weird" → 400;
+// binary part named "not-a-target" → 400; static with a part that is not
+// site.tar.gz → 400; no file parts → 400; meta not first part → 400.
+// TestAPIShipTooBig: server built with apiShipMaxBytes lowered to 4KiB
+// (make it a var, default 256<<20) and an 8KiB part → 413.
+// TestAPIShipMemberGate: non-member token → the same 404 as Task 1's
+// matrix.
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** Key points the implementer must not improvise:
+
+```go
+var apiShipMaxBytes int64 = 256 << 20 // one ship, all artifacts
+
+// Part names become bucket key segments via the manifest, so the charset
+// checks below are path-traversal defense, not tidiness.
+var targetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
+```
+
+`r.Body = http.MaxBytesReader(w, r.Body, apiShipMaxBytes)` before parsing; `r.MultipartReader()` (streaming — never `ParseMultipartForm`, which buffers to disk with its own limits); require the first part be `meta`, decode with a 8KiB `io.LimitReader`; each following part: validate name (`targetRe` for binary, exactly `"site.tar.gz"` for static), `io.ReadAll` into memory (release.Ship takes `map[string][]byte` — same buffering the direct path's `os.ReadFile` does; MaxBytesReader is the cap), sha256 the bytes, build `release.Artifact{File: name, SHA256: hex, Size: int64(len(b))}`. Manifest: `Schema:1, App:app, Version:meta.Version, Kind:meta.Kind, BuiltBy:"console-api "+version.Build, BuiltAt:time.Now().UTC().Format(time.RFC3339)`. `release.Ship` error → 502 `jsonError` (the store failed, not the caller). Audit `"shipped "+version+" (cli)"`. A `MaxBytesError` (check with `errors.As(err, new(*http.MaxBytesError))`) → 413.
+
+- [ ] **Step 4: Verify pass** (whole package).
+- [ ] **Step 5: Commit** — `"console: CLI API — multipart ship, hashes computed server-side"`
+
+---
+
+### Task 4: CLI mode resolution + API client methods
+
+**Files:**
+- Create: `apimode.go` (root package)
+- Create: `apimode_test.go`
+- Modify: `auth.go` (one addition: exported-in-package helper on consoleClient for multipart)
+
+**Interfaces:**
+- Consumes: `loggedInCredential(console string) (*credential, error)` (auth.go:659 — `pick` semantics: one credential or `-console` disambiguates), `newConsoleClient(base, token)` (auth.go:344), `(*consoleClient).do(ctx, method, path string, in, out any) (int, error)`.
+- Produces (Task 5 relies on these exact signatures):
+
+```go
+// apiSession is the resolved API mode: which console, as whom.
+type apiSession struct {
+ c *consoleClient
+ console string // origin, for messages
+ address string
+}
+
+// resolveMode returns (nil, nil) when the store env is set — direct mode,
+// the caller proceeds exactly as today. Otherwise a credential is required
+// and its session returned; consoleFlag is the -console value ("" = only).
+func resolveMode(consoleFlag string) (*apiSession, error)
+
+func (s *apiSession) promote(ctx context.Context, account, app, version, channel string) error
+func (s *apiSession) rollback(ctx context.Context, account, app, channel string) error
+func (s *apiSession) channels(ctx context.Context, account, app string) ([]apiChannel, error) // {Channel, Version, PromotedAt, Rung, Err string}
+func (s *apiSession) ship(ctx context.Context, account, app, version, kind string, files map[string]string) error // files: partName -> local path, streamed
+```
+
+- [ ] **Step 1: Failing tests** in `apimode_test.go` (package main; `t.Setenv` for env, temp `HOME`/`CARLOS_CONFIG_DIR` per what `credentialsPath()` (auth.go:125) reads — check it and use the same override the auth tests use):
+
+```go
+// TestResolveModeDirectWins: CARLOS_DEPLOYMENT_BUCKET=x set + credential
+// file present → (nil, nil).
+// TestResolveModeDirWins: CARLOS_DEPLOYMENT_DIR set → (nil, nil).
+// TestResolveModeAPI: no env, one credential → session with its origin.
+// TestResolveModeNothing: no env, no credentials → error mentioning both
+// "CARLOS_DEPLOYMENT_BUCKET" and "carlos auth login".
+// TestAPISessionShipMultipart: httptest server asserting: bearer header,
+// multipart with meta first (exact JSON), then parts in map-key-sorted
+// order; respond 201. ship() with two temp files succeeds.
+// TestAPISessionStableRefusal: server responds 403 {"error":"stable
+// needs a passkey — finish in the console","console_url":"http://x/a/b"};
+// promote() error text contains the console_url.
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** `resolveMode`: env check first (both vars), then `loggedInCredential(consoleFlag)` — its error already names the fixes; wrap the no-credential case with the two-option message from the test. Multipart ship streams via `io.Pipe` + `multipart.NewWriter` in a goroutine (30 MiB artifacts should not double-buffer); part order: `meta` first, then files sorted by name (deterministic tests). The 403 promote path decodes `{error, console_url}` and returns `fmt.Errorf("%s\n %s", body.Error, body.ConsoleURL)`.
+- [ ] **Step 4: Verify pass.**
+- [ ] **Step 5: Commit** — `"cli: credential-first mode resolution and console API client"`
+
+---
+
+### Task 5: wire the four verbs
+
+**Files:**
+- Modify: `cli.go` (`runShip` :753, `runPromote` :817, `runChannels` :856, `runRollback` :886)
+- Modify: `apimode_test.go` (verb-level tests)
+
+**Interfaces:**
+- Consumes: Task 4's `resolveMode` + `apiSession` methods. Note `accountFlags` (cli.go:716) resolves via the LOCAL registry — in API mode there is no registry, so the verbs must NOT call `acctOf()` when a session is returned; they pass the RAW `-account` flag value (sqid or name — the server resolves, Task 1). Add a `-console` string flag to each verb, "" default.
+- Produces: user-visible behavior only.
+
+- [ ] **Step 1: Failing tests** — run each verb function against an httptest console (as in Task 4) by setting the credential file + no store env; assert stdout via the existing capture pattern in `auth_test.go` (find `captureOutput` or equivalent; if none exists, route verb output through a package-level `var stdout io.Writer = os.Stdout` — smallest change, keep `log.Fatal` paths untouched):
+
+```go
+// TestShipVerbAPIMode: runShip([...]) with one artifact file → server got
+// the upload; stdout has "shipped titogo abc1234" and the first-use line
+// "using http://127.0.0.1:PORT as vicky@keymail.dev".
+// TestPromoteVerbAPIMode / TestChannelsVerbAPIMode /
+// TestRollbackVerbAPIMode: analogous.
+// TestPromoteVerbDirectUnchanged: CARLOS_DEPLOYMENT_DIR set → the verb
+// never dials the httptest server (server counts requests; expect 0).
+```
+
+- [ ] **Step 2: Verify failure.**
+- [ ] **Step 3: Implement.** Each verb: parse flags (add `-console`), call `resolveMode(*consoleFlag)`; `nil, nil` → existing body unchanged; session → API call + spec §5 output (`using <origin> as <address>` once per invocation, then the verb line). `runPromote` in API mode with `-hotfix` set → `log.Fatal("hotfix is an operator act — use the direct path (CARLOS_DEPLOYMENT_BUCKET) or the console")`. `runChannels` API mode prints the same tabwriter table, ERROR rows included.
+- [ ] **Step 4: Verify pass** — `go test ./... -count=1` (all 14 packages).
+- [ ] **Step 5: Commit** — `"cli: ship/promote/rollback/channels speak the console API when logged in"`
+
+---
+
+### Task 6: e2e — device flow to shipped release, README, format sweep
+
+**Files:**
+- Modify: `auth_e2e_test.go` (new test, reuse its device-flow helpers)
+- Modify: `README.md` (developer-flow section)
+
+**Interfaces:**
+- Consumes: everything above; `auth_e2e_test.go`'s existing console-server + device-approval fixtures.
+
+- [ ] **Step 1: Write the e2e test** (this one is the acceptance test — write it, watch it pass; every unit is already TDD'd):
+
+```go
+// TestE2ECLIThroughConsole: real console Server (dir store, test signing
+// key), device flow start→approve→poll to a real token in a temp
+// credential file; then, with NO store env:
+// runShip: 1KiB "linux-arm64" → 201, manifest hashes match
+// runPromote to "canary/e2e" → pointer moves, audit row "(cli)"
+// runChannels output contains "canary/e2e" and the version
+// runPromote to "stable" → refusal naming the console URL (assert the
+// verb surfaces it without promoting; pointer unchanged)
+// runRollback "canary/e2e" after a second ship+promote → pointer back
+```
+
+- [ ] **Step 2: Run the full suite** — `go build ./... && go vet ./... && go test ./... -count=1` all green; `gofmt -l` on touched files → empty.
+- [ ] **Step 3: README** — add to the developer section: login → ship → promote in API mode, one paragraph + example; state plainly that stable finishes in the console and that CI/operators keep the env-var path.
+- [ ] **Step 4: Commit** — `"cli+console: e2e — a credential is enough to ship and promote"`
+
+---
+
+## Self-Review (done at write time)
+
+- Spec coverage: §1→Task 4/5, §2→Tasks 1-3, §3→Tasks 1-2 (matrix tests), §4→Task 3, §5→Task 5, §6→Tasks 2-3 (audit asserts), §9→Tasks 1-6; §9's live tito PutObject probe is a deploy-time step, recorded for the PR body, not a code task. §4's "streams to a temp file" is implemented as capped in-memory buffering because `release.Ship` takes `map[string][]byte` — same buffering as the direct path; spec updated to match.
+- Placeholders: none; every handler's refusal mapping and every test's assertion target is named.
+- Type consistency: `apiAccountApp` (Tasks 1-3), `apiSession`/`resolveMode` (Tasks 4-5), `apiShipMaxBytes` (Task 3 test hook) — names match across tasks.
diff --git a/docs/superpowers/specs/2026-08-03-cli-console-api-design.md b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md
new file mode 100644
index 000000000..3e98633e2
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-03-cli-console-api-design.md
@@ -0,0 +1,182 @@
+# CLI through the console API — ship without seeing the infrastructure
+
+**Date:** 2026-08-03 · **Status:** approved (Paul, in-session) · **Slice:** 1
+
+## Problem
+
+`carlos auth login` (device flow, `cli_tokens`) shipped, but nothing consumes
+the credential. The verbs an app developer actually needs still reach around
+the platform's own control plane:
+
+- `carlos ship` opens the S3 store directly — needs AWS credentials, the
+ bucket name, and a region in env.
+- `carlos promote` signs pointers locally — needs the release signing key.
+- `carlos channels` reads the bucket directly.
+
+So "give Vicky deploy access" today means giving her AWS credentials, which
+is exactly the infrastructure the platform exists to hide. carlos commands
+should talk to the carlos API; the deployment's console already holds bucket
+write access and the signing key, gates every mutation on membership, and
+audits it.
+
+## Decisions (made with Paul, 2026-08-03)
+
+1. **CLI refuses `stable`.** Stable promotion is passkey-step-up-gated in the
+ browser (spec §7a) and a bearer token cannot do a passkey ceremony. The
+ API returns the refusal with the console URL to finish in the browser.
+ A pending-action browser-confirm handoff is future work, not this slice.
+2. **AWS-direct stays, for operators and CI.** Resolution is
+ credential-first: a stored `auth login` credential and no explicit
+ `CARLOS_DEPLOYMENT_BUCKET`/`CARLOS_DEPLOYMENT_DIR` in env → API mode.
+ Explicit store env → direct mode, unchanged. Box-side scripts, pinfra,
+ and the console shipping itself keep working; the two modes never mix in
+ one invocation.
+
+## §1 Mode resolution (client)
+
+In `ship`, `promote`, `rollback`, `channels` (and nothing else this slice):
+
+- `CARLOS_DEPLOYMENT_BUCKET` or `CARLOS_DEPLOYMENT_DIR` set → **direct
+ mode**, byte-identical to today.
+- Otherwise, if the credential file has exactly one console (or `-console
+ <origin>` picks one, same `pick` semantics as `auth whoami`) → **API
+ mode** against that origin.
+- Neither → the current "no deployment store configured" error, extended to
+ mention `carlos auth login`.
+
+No silent fallback from API mode to direct mode or vice versa: a failed API
+call is a failed command. An explicit `-console` given alongside either store
+env is refused outright (not a silent direct-mode win), for the same reason:
+the two modes must never mix in one invocation, so a `-console` that would
+otherwise be silently ignored fails closed instead. The `-account` flag keeps
+its meaning in both modes; in API mode the server additionally verifies
+membership.
+
+## §2 API surface (server)
+
+All under the existing CLI route family (`registerCLIRoutes`), all bearer
+(`cliUser`), all JSON except the upload, all sharing the browser doors'
+semantics — a hand-written request meets exactly the checks a button does:
+
+- `GET /api/cli/apps` — accounts the address belongs to, each with its
+ claimed apps and the caller's role. (Powers `carlos apps`, and lets ship
+ fail fast on a bad account/app pair.)
+- `GET /api/cli/apps/{account}/{app}/channels` — channel pointers:
+ channel, version, promoted_at, rung. Powers `carlos channels`.
+- `POST /api/cli/apps/{account}/{app}/releases` — ship. Multipart:
+ `meta` part (JSON: version, kind binary|static) then one or more artifact
+ parts, each part NAMED by its target (`linux-arm64`, …, or `site.tar.gz`
+ for static) — the part name is the artifact key; meta carries no
+ per-file fields. Server computes sha256 and sizes from the bytes it
+ received — the client never states a hash it could lie about — builds the
+ manifest exactly as `runShip` does (target-keyed artifacts; `BuiltBy:
+ "console-api <console version>"`), and calls `release.Ship` against its
+ own store. Idempotent per (account, app, version) the same way a re-run
+ `carlos ship` is.
+- `POST /api/cli/apps/{account}/{app}/promote` — body {version, channel}.
+ Mirrors `handlePromote` minus CSRF (bearer replaces the session): valid
+ channel, valid version, manifest exists, rung gate, then
+ `release.PromoteOnLadder` with `Actor: addr`. No `-hotfix` from the API
+ this slice — a recorded ladder bypass is an operator act, and operators
+ have direct mode.
+- `POST /api/cli/apps/{account}/{app}/rollback` — body {channel}. Mirrors
+ the browser rollback door (rollback never bakes); stable is refused with
+ the same 403 as promote (Paul's ruling 2026-08-03 — §8's no-stable blast
+ radius governs).
+
+## §3 Authorization and refusal order
+
+Every endpoint: bearer → address; membership in `{account}`; app claimed in
+that account (`HasAppIn`). Any failure in that chain is the byte-identical
+404 the console's gate ordering rule demands — a token holder probing app
+names learns nothing a stranger wouldn't. Then, and only then, the cheap
+refusals (bad channel, bad version, unknown release), then the rung gate:
+
+- non-stable rung: membership already proved — allowed.
+- stable: `403` with body
+ `{"error":"stable needs a passkey — finish in the console","console_url":
+ "<origin>/a/<sqid>/apps/<app>"}`. Role is NOT consulted first — the 403 is
+ identical for member and owner, because distinguishing them would leak
+ role to a stolen token. (The browser's owner-404 ordering protects a
+ different secret — account membership — which the 404 above already
+ covers.)
+
+Rate limits: reuse the `cliLimits` family. Uploads get their own bucket-of-
+tokens (small: ships are rare) keyed by token hash, not IP — a shared NAT
+must not let one developer starve another, and a token is the natural
+principal here.
+
+## §4 Upload handling
+
+- `http.MaxBytesReader` at 256 MiB per request (largest current artifact is
+ ~30 MiB; 256 leaves room for multi-target ships without inviting abuse).
+- Artifacts are read part-by-part from the streaming multipart reader and
+ hashed as read; each artifact is held in memory once (the size cap is the
+ guard) because `release.Ship` takes `map[string][]byte` — the same
+ buffering the direct path's `os.ReadFile` does.
+- `version` and `target` validated against the same charsets ship enforces
+ (`release.ValidVersion`; targets `GOOS-GOARCH` shaped) — they become
+ bucket key segments, so the validation is path-traversal defense, stated
+ as such in code.
+- The console's existing store credentials cover `releases/*` writes: it
+ writes `apps/<sqid>/<app>/channels/*` today and the box role grants the
+ whole bucket; §9 verifies this live on tito before merge.
+
+## §5 What the CLI prints
+
+API-mode output stays verb-for-verb compatible with direct mode where it
+can (`shipped <app> <version>`, `promoted <version> to <channel>`), plus the
+console origin on first use of a credential (`using https://carlos.tito.io
+as vicky@keymail.dev`) so a developer with two deployments always knows
+which one they just shipped to. Stable refusal prints the console URL from
+the 403 body.
+
+## §6 Audit
+
+API mutations audit through the same `s.audit` rows as the browser, actor =
+the token's address, detail suffixed `" (cli)"`. The issuance notification
+path (Keymail message on token creation) already exists; no new
+notification this slice.
+
+## §7 Out of scope (recorded, deliberate)
+
+- Claim/create-app via API — claim-proofing semantics stay in the browser
+ and box-side (migrated names need operator judgment, as tito's titogo
+ just demonstrated).
+- `-hotfix`, `carlos route/add/remove/instances`, env/secrets verbs —
+ operator/box territory or future slices.
+- Stable via pending-action handoff.
+- `carlos canary` — uses ship+promote internals; follows automatically if
+ those go through the client seam, but is not a named requirement and is
+ not tested this slice.
+
+## §8 Security notes
+
+- Blast radius of a stolen token: non-stable promotes and ships on apps the
+ address can already deploy. No key material, no stable, no AWS.
+- The signing key never moves: promotion signing stays in the console
+ process. Ship needs no signature (pointers, not artifacts, are signed —
+ the signed pointer names the version and the manifest carries the
+ server-computed hashes).
+- Server-side hashing means a compromised client cannot make the manifest
+ disagree with the artifact bytes.
+- Registry stays read-only to the console; nothing here touches routes.
+
+## §9 Testing
+
+- Unit: mode resolution matrix (env × credential file × -console flag);
+ upload validation refusals; the 404/403 refusal order (a non-member and a
+ member probing stable must be byte-distinguishable only where designed).
+- e2e (`auth_e2e_test.go` pattern): device flow → token → `GET apps` →
+ multipart ship → promote to a canary channel → channels shows it →
+ stable refused with console_url → rollback. Against `httptest` console
+ with a dir-backed store.
+- Live before merge: on tito, confirm the box role accepts a
+ `releases/*` PutObject from the console process (a 1-byte probe object,
+ deleted after).
+
+## §10 Rollout
+
+Server first (console is backward-compatible: new routes only), then CLI.
+One PR, both halves; deploy console via the normal ship/promote/adopt;
+nothing changes for existing users until they run `carlos auth login`.
diff --git a/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md b/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md
index 4b50a3dc9..8f45cbdf7 100644
--- a/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md
+++ b/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md
@@ -107,6 +107,14 @@ holds. If that is a scoped seed, the accompanying `CARLOS_RELEASE_GRANT`
outside the grant fails LOCALLY with a clear error before any write —
matching the ladder's refusal style.
+That sentence is DIRECT-mode truth only. In the CLI's API mode (a stored
+`carlos auth login` credential and no store env — see the cli-console-api
+spec §1) a `CARLOS_RELEASE_KEY` in the environment is INERT: the console
+signs with the deployment's root key and authority is membership + the rung
+gate, so a "canary-only" scoped key on a laptop does not constrain what
+that laptop's console credential can promote. Scoped keys constrain the
+direct path; memberships constrain the API path.
+
## 5. Verification surface
One function change: `verifyPointer` grows grant handling. Its callers —
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
new file mode 100644
index 000000000..9ab77f6da
--- /dev/null
+++ b/internal/console/apicli.go
@@ -0,0 +1,679 @@
+// The CLI's read-only console API: GET routes under /api/cli/ that let a
+// terminal list its own accounts/apps and read a channel ladder by talking
+// to the console over HTTP, rather than opening the deployment bucket
+// directly. Auth and account/app resolution are ONE helper (apiAccountApp)
+// for every route below that names an account and an app, because
+// resolution and membership proof are deliberately the same lookup: an
+// unknown account and an account you don't belong to must be the identical
+// 404, or a token holder learns something by probing names.
+package console
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+)
+
+// apiAccountApp resolves and authorizes one CLI API request. The account
+// segment accepts a sqid or a name, resolved against the caller's OWN
+// memberships — resolution and membership proof are deliberately the same
+// lookup, so an unknown account and someone else's account are the same 404.
+//
+// Resolution follows the same doctrine as the CLI's own resolveAccount
+// (accounts.go): an exact SQID match wins outright, and only when none
+// matches does a NAME match apply, case-insensitively (EqualFold) — and an
+// ambiguous name (two of the caller's own accounts sharing it) is refused
+// rather than guessed at, because picking the wrong account here would list
+// or promote against the wrong tenant.
+func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return "", nil, "", false
+ }
+ seg := r.PathValue("account")
+ app := r.PathValue("app")
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ jsonError(w, http.StatusInternalServerError, "could not load accounts")
+ return "", nil, "", false
+ }
+ var acct *Account
+ for i := range accts {
+ if accts[i].Sqid == seg {
+ acct = &accts[i]
+ break
+ }
+ }
+ if acct == nil {
+ var matches []*Account
+ for i := range accts {
+ if strings.EqualFold(accts[i].Name, seg) {
+ matches = append(matches, &accts[i])
+ }
+ }
+ switch len(matches) {
+ case 1:
+ acct = matches[0]
+ case 0:
+ // acct stays nil — the same 404 an unknown or someone-else's
+ // account gets, below.
+ default:
+ sqids := make([]string, len(matches))
+ for i, m := range matches {
+ sqids[i] = m.Sqid
+ }
+ jsonError(w, http.StatusConflict,
+ fmt.Sprintf("account name %q is ambiguous between %s — use a sqid", seg, strings.Join(sqids, ", ")))
+ return "", nil, "", false
+ }
+ }
+ if acct == nil {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
+ has, err := s.store.HasAppIn(acct.ID, app)
+ if err != nil {
+ log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err)
+ }
+ if err != nil || !has {
+ http.NotFound(w, r)
+ return "", nil, "", false
+ }
+ return addr, acct, app, true
+}
+
+// handleAPIApps answers `carlos apps`: every account the token's holder
+// belongs to, and the app names each one has claimed. Scoped to the caller's
+// OWN memberships — there is no "list every account" verb here, on the same
+// principle apiAccountApp enforces one call at a time.
+func (s *Server) handleAPIApps(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.cliUser(w, r)
+ if !ok {
+ return
+ }
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ log.Printf("console: api apps for %s: %v", addr, err)
+ jsonError(w, http.StatusInternalServerError, "could not load accounts")
+ return
+ }
+ out := make([]map[string]any, 0, len(accts))
+ for _, a := range accts {
+ apps, err := s.store.AppsIn(a.ID)
+ if err != nil {
+ log.Printf("console: api apps in %s: %v", a.Sqid, err)
+ jsonError(w, http.StatusInternalServerError, "could not load apps")
+ return
+ }
+ if apps == nil {
+ apps = []string{}
+ }
+ out = append(out, map[string]any{
+ "sqid": a.Sqid, "name": a.Name, "role": a.Role, "apps": apps,
+ })
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"accounts": out})
+}
+
+// handleAPIChannels answers `carlos channels`: every channel pointer under
+// one app, verified the same way the CLI verifies them itself. A pointer
+// that fails verification becomes one erroring ROW rather than a failed
+// response — mirroring runChannels' per-row ERROR behaviour (cli.go), since
+// one bad pointer must not hide every other channel's answer from the
+// caller.
+func (s *Server) handleAPIChannels(w http.ResponseWriter, r *http.Request) {
+ _, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ prefix := release.ChannelsPrefix(acct.Sqid, app)
+ keys, err := s.cfg.Releases.List(ctx, prefix)
+ if err != nil {
+ log.Printf("console: api channels list %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not list channels")
+ return
+ }
+ out := make([]map[string]any, 0, len(keys))
+ for _, k := range keys {
+ name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
+ cp, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, name)
+ if err != nil {
+ out = append(out, map[string]any{"channel": name, "error": err.Error()})
+ continue
+ }
+ // rung is always present, "" when the channel name isn't one of the
+ // ladder's rungs — a typed client (later tasks) can rely on the key
+ // existing rather than testing for its absence.
+ rung, _ := release.Rung(name)
+ out = append(out, map[string]any{
+ "channel": name,
+ "version": cp.Version,
+ "promoted_at": cp.PromotedAt.UTC().Format(time.RFC3339),
+ "rung": rung,
+ })
+ }
+ 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
+ }
+ // Keyed on the ADDRESS, not the token: the principal a write budget
+ // belongs to is the PERSON, not the terminal they happen to be typing on
+ // — the same key handleCLINewAccount already uses on this limiter.
+ // Keying by token instead would give someone with several live tokens an
+ // independent budget per token, which is the limit doing less than it
+ // should, not more.
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ 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). Below stable there is no rung gate,
+// matching the browser door — rolling back never climbs the ladder, so
+// nothing about maturity has a say over it.
+//
+// Stable is the one exception, and it is Paul's ruling (2026-08-03): a
+// rollback that lands on stable puts a build in front of every user of the
+// app exactly as a promotion onto it would (§8's no-stable blast radius
+// governs regardless of which verb got there), so it is refused with the
+// SAME byte-identical 403 handleAPIPromote gives stable — before
+// release.Rollback is ever called, and before the channel's current pointer
+// is even read, so the refusal cannot vary with what that pointer contains.
+func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ // Keyed on the ADDRESS, not the token: the principal a write budget
+ // belongs to is the PERSON, not the terminal they happen to be typing on
+ // — the same key handleCLINewAccount already uses on this limiter.
+ // Keying by token instead would give someone with several live tokens an
+ // independent budget per token, which is the limit doing less than it
+ // should, not more.
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ 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
+ }
+ 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
+ }
+ 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"})
+}
+
+// ---- ship (multipart upload) ------------------------------------------------
+
+// apiShipMaxBytes bounds one ship request across every part TOGETHER — meta
+// plus all artifacts — not any single part on its own. A var, not a const,
+// so a test can lower it without rebuilding the fixture. 256MiB comfortably
+// covers today's binaries and a static site tarball in one request.
+var apiShipMaxBytes int64 = 256 << 20
+
+// apiShipMaxPartsStatic and apiShipMaxPartsBinary bound the NUMBER of file
+// parts a ship request may carry: one for a static release (the site.tar.gz
+// name alone), eight for a binary release (comfortably above today's
+// platform count). r.MultipartReader() has no stdlib cap on part count at all — that
+// cap exists only on mime/multipart's ReadForm (1000 parts), which
+// streaming deliberately opts out of. Without a check of our own, a body
+// full of tiny, validly-named parts fits millions of them inside
+// apiShipMaxBytes: each one a map entry on the console's heap, and — had it
+// ever gotten that far — one sequential putImmutable call per part inside
+// release.Ship until the ship context timed out, orphaning however many
+// artifact objects had already landed (Ship writes the manifest LAST by
+// design, so a killed run leaves bytes with no manifest naming them). This
+// check IS the part-count cap; nothing upstream provides one.
+const (
+ apiShipMaxPartsStatic = 1
+ apiShipMaxPartsBinary = 8
+)
+
+// targetRe validates a binary artifact's part NAME before it is used to
+// build a bucket key: release.ArtifactKey joins it straight into the object
+// path, so this check is path-traversal defense, not tidiness — a part
+// named "../../etc/passwd" must never reach release.Ship as a file name.
+var targetRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
+
+// shipMaxBytesRefused answers 413 if err is the http.MaxBytesReader wrapped
+// around the request body cutting a read short, and reports whether it did
+// — the caller returns immediately when this is true, and falls through to
+// its own (more specific) 400 otherwise.
+func shipMaxBytesRefused(w http.ResponseWriter, err error) bool {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ jsonError(w, http.StatusRequestEntityTooLarge, "upload too large")
+ return true
+ }
+ return false
+}
+
+// shipImmutableConflict reports whether err is release.Ship's refusal to
+// overwrite an existing version's object with DIFFERENT bytes — a
+// permanent conflict a retry can never fix, unlike every other error Ship
+// returns (a store/IO failure, which a retry might actually resolve).
+//
+// release.Ship/putImmutable (internal/release/release.go) expose no
+// sentinel or typed error for this specific case — the only signal
+// available is the literal wording putImmutable formats ("... already
+// exists with different content — refusing to overwrite an immutable
+// release object"), so this is a STRING match against release.go's current
+// wording, not a durable error-type check (errors.Is/As have nothing to
+// grab onto here). If that wording ever changes in release.go, this
+// silently stops firing and the conflict falls back to the 502 below —
+// worth revisiting if release.go ever grows a proper sentinel for it.
+//
+// validateManifest's errors also return from Ship along this same
+// unwrapped path, but none of its messages ("invalid app...", "invalid
+// kind...", "manifest has no artifacts", etc) contain this wording, so they
+// can never match here. They are unreachable in this branch for an
+// unrelated reason: every field validateManifest checks (app, version,
+// kind, artifact file names, sha256, size) was already validated by
+// handleAPIShip itself, using the same rules, before Ship was ever called.
+func shipImmutableConflict(err error) bool {
+ return strings.Contains(err.Error(), "already exists with different content")
+}
+
+// shipArtifactsMatch reports whether two artifact maps describe the exact
+// same shipped CONTENT — same file set, same bytes (by sha256+size) for
+// each. BuiltAt/BuiltBy are deliberately excluded: they are metadata, not
+// content, and both handleAPIShip and the direct `carlos ship` CLI path
+// stamp a fresh BuiltAt into every manifest they build, so comparing
+// manifests wholesale (as release.Ship's own putImmutable does,
+// byte-for-byte) would call a genuinely identical re-ship a conflict for a
+// reason no caller could ever avoid.
+func shipArtifactsMatch(a, b map[string]release.Artifact) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for k, av := range a {
+ bv, ok := b[k]
+ if !ok || av.File != bv.File || av.SHA256 != bv.SHA256 || av.Size != bv.Size {
+ return false
+ }
+ }
+ return true
+}
+
+// handleAPIShip answers `carlos ship`: a multipart upload of one release's
+// artifacts. Hashes and sizes are computed HERE, from the bytes that
+// actually arrived on the wire, and handed to release.Ship as
+// already-filled Artifact{SHA256,Size} — Ship itself recomputes and
+// cross-checks them (release.go), so there is no way for a caller to lie
+// about either even in principle: the wire shape below has no field to lie
+// in (the meta part carries only version and kind).
+//
+// Streamed, never buffered to disk: r.MultipartReader() reads one part at a
+// time. ParseMultipartForm was ruled out on purpose — it spills whatever
+// doesn't fit its own in-memory threshold to a temp file under an
+// INDEPENDENT size limit, one that would sit alongside apiShipMaxBytes
+// rather than being governed by it. Each part is still read fully into
+// memory here, because release.Ship's own shape (map[string][]byte) leaves
+// no smaller option — the same buffering the CLI's own direct-ship path
+// already does with os.ReadFile — but the MaxBytesReader wrapped around the
+// whole request body below is what actually caps the total.
+func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ // Keyed on the TOKEN, not the address: ships are rare and heavy — bytes
+ // on the wire, an object written per artifact — so a token is the
+ // natural principal for an upload, unlike a write budget. Keying by
+ // address instead would make a laptop and a CI runner sharing one
+ // human's identity share one shipping budget between them, which is the
+ // limit doing less than it should, not more.
+ if !s.cli.ship.allow(hashToken(bearer(r)), time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many uploads — wait a while and try again")
+ return
+ }
+ // The rate limiter bounds requests over TIME; it says nothing about how
+ // many run AT ONCE. A ship holds a goroutine and a substantial buffer
+ // open for as long as the client trickles bytes in, so a handful of
+ // slow uploads in parallel is a resource-exhaustion path the counter
+ // above does nothing to close. Non-blocking: a full semaphore means
+ // "busy right now", answered immediately, rather than a request that
+ // queues indefinitely behind someone else's slow upload.
+ select {
+ case s.shipSem <- struct{}{}:
+ defer func() { <-s.shipSem }()
+ default:
+ jsonError(w, http.StatusTooManyRequests, "another ship is in progress — try again shortly")
+ return
+ }
+ if s.shipStarted != nil {
+ s.shipStarted <- struct{}{}
+ }
+
+ // Bounds the read side in TIME, not just in bytes: with no deadline, a
+ // client sending one byte a second could hold this goroutine (and
+ // whatever it has already buffered) open for hours — the semaphore
+ // above only bounds how many can do that at once, not for how long.
+ // http.ErrNotSupported is tolerated because some transports (test
+ // servers among them, in principle) don't implement the underlying
+ // deadline setter; the semaphore is still there as a backstop on those.
+ rc := http.NewResponseController(w)
+ if err := rc.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
+ log.Printf("console: api ship set read deadline: %v", err)
+ }
+ defer func() {
+ if err := rc.SetReadDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) {
+ log.Printf("console: api ship clear read deadline: %v", err)
+ }
+ }()
+
+ r.Body = http.MaxBytesReader(w, r.Body, apiShipMaxBytes)
+ mr, err := r.MultipartReader()
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, "expected a multipart/form-data body")
+ return
+ }
+
+ first, err := mr.NextPart()
+ if err != nil {
+ if shipMaxBytesRefused(w, err) {
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "empty upload")
+ return
+ }
+ if first.FormName() != "meta" {
+ jsonError(w, http.StatusBadRequest, `the first part must be named "meta"`)
+ return
+ }
+ var meta struct {
+ Version string `json:"version"`
+ Kind string `json:"kind"`
+ }
+ if err := json.NewDecoder(io.LimitReader(first, 8<<10)).Decode(&meta); err != nil {
+ jsonError(w, http.StatusBadRequest, "malformed meta part")
+ return
+ }
+ ver := strings.TrimSpace(meta.Version)
+ if !release.ValidVersion(ver) || ver == "dev" {
+ jsonError(w, http.StatusBadRequest, "invalid version")
+ return
+ }
+ if meta.Kind != "binary" && meta.Kind != "static" {
+ jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`)
+ return
+ }
+ maxParts := apiShipMaxPartsBinary
+ if meta.Kind == "static" {
+ maxParts = apiShipMaxPartsStatic
+ }
+
+ files := make(map[string][]byte)
+ artifacts := make(map[string]release.Artifact)
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ if shipMaxBytesRefused(w, err) {
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "malformed multipart body")
+ return
+ }
+ // Checked before anything else touches this part — including its
+ // name, and certainly before io.ReadAll — because the cost this
+ // guards against is per-PART, not per-byte: a body of nothing but
+ // tiny, validly-named parts must be refused on the (maxParts+1)th
+ // one, not after buffering it.
+ if len(files) >= maxParts {
+ jsonError(w, http.StatusBadRequest,
+ fmt.Sprintf("too many file parts (max %d for a %s release)", maxParts, meta.Kind))
+ return
+ }
+ name := part.FormName()
+ if meta.Kind == "static" {
+ if name != "site.tar.gz" {
+ jsonError(w, http.StatusBadRequest, `a static release ships exactly one file part, named "site.tar.gz"`)
+ return
+ }
+ } else if !targetRe.MatchString(name) {
+ jsonError(w, http.StatusBadRequest, "invalid artifact name "+name)
+ return
+ }
+ // A second part claiming a name already seen is refused rather than
+ // silently overwriting the first: both would otherwise be charged
+ // against apiShipMaxBytes and apiShipMaxPartsBinary/Static, but only
+ // the last one's bytes would ever reach the manifest — a caller
+ // deserves an error, not a release that quietly ships less than it
+ // paid to upload.
+ if _, dup := files[name]; dup {
+ jsonError(w, http.StatusBadRequest, "duplicate artifact part "+name)
+ return
+ }
+ b, err := io.ReadAll(part)
+ if err != nil {
+ if shipMaxBytesRefused(w, err) {
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "could not read part "+name)
+ return
+ }
+ sum := sha256.Sum256(b)
+ files[name] = b
+ artifacts[name] = release.Artifact{
+ File: name,
+ SHA256: hex.EncodeToString(sum[:]),
+ Size: int64(len(b)),
+ }
+ }
+ if len(files) == 0 {
+ jsonError(w, http.StatusBadRequest, "no artifact parts in the upload")
+ return
+ }
+
+ m := release.Manifest{
+ Schema: 1,
+ App: app,
+ Version: ver,
+ Kind: meta.Kind,
+ BuiltBy: "console-api " + version.Build,
+ BuiltAt: time.Now().UTC().Format(time.RFC3339),
+ Artifacts: artifacts,
+ }
+ // Longer than the other CLI doors' 15-20s: this one moves real bytes —
+ // up to apiShipMaxBytes of them — into the bucket, not a handful of
+ // short strings.
+ ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
+ defer cancel()
+
+ // A version's objects are immutable once shipped, but BOTH callers that
+ // build a manifest like this one (this handler and the direct `carlos
+ // ship` CLI path) stamp BuiltAt with a fresh time.Now() on every
+ // invocation. Comparing the freshly-marshaled manifest byte-for-byte
+ // against whatever release.Ship's own putImmutable finds already
+ // stored — which is exactly what it does — would make a genuinely
+ // identical re-ship register as a "different content" conflict for a
+ // reason no caller could ever avoid: its OWN timestamp. Comparing
+ // ARTIFACTS here first — content, not metadata — is what makes
+ // re-shipping the exact same bytes twice actually succeed instead of
+ // permanently wedging on its own BuiltAt.
+ if existing, gerr := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, ver); gerr == nil {
+ if !shipArtifactsMatch(existing.Artifacts, artifacts) {
+ jsonError(w, http.StatusConflict,
+ "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
+ return
+ }
+ // Identical content already shipped: answer success without
+ // writing anything — calling release.Ship again would only
+ // rediscover this the slow way, and would in fact trip its OWN
+ // manifest-level putImmutable conflict (see above), since BuiltAt
+ // drifts every call even when the artifacts don't. 200, not 201:
+ // nothing was created by this request.
+ out := make(map[string]any, len(existing.Artifacts))
+ for name, a := range existing.Artifacts {
+ out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "result": "shipped", "version": ver, "artifacts": out,
+ })
+ return
+ }
+
+ if err := release.Ship(ctx, s.cfg.Releases, acct.Sqid, m, files); err != nil {
+ log.Printf("console: api ship %s %s: %v", app, ver, err)
+ if shipImmutableConflict(err) {
+ // A permanent conflict, not a transient one: reachable when this
+ // raced against another ship of the same version (the
+ // GetManifest check above is not atomic with the Ship call
+ // below), not through the common path above. Retrying this
+ // exact request will never succeed, so it gets its own status
+ // rather than the retry-flavored 502 below.
+ jsonError(w, http.StatusConflict,
+ "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
+ return
+ }
+ // Anything else is the store failing, not the caller: a 502,
+ // matching the other doors' convention that a bucket-side error is
+ // never described to the caller in bucket-side terms.
+ jsonError(w, http.StatusBadGateway, "could not store the release — try again")
+ return
+ }
+ s.audit(acct, app, addr, "ship", "shipped "+ver+" (cli)")
+
+ out := make(map[string]any, len(artifacts))
+ for name, a := range artifacts {
+ out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size}
+ }
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "result": "shipped", "version": ver, "artifacts": out,
+ })
+}
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
new file mode 100644
index 000000000..60afc5b60
--- /dev/null
+++ b/internal/console/apicli_test.go
@@ -0,0 +1,1134 @@
+package console
+
+// The CLI's read-only API surface (apicli.go): `carlos apps` and `carlos
+// channels` reading through the console instead of the bucket directly. The
+// properties that matter, in the order they'd hurt if lost: a token only
+// ever sees ITS OWN memberships, account resolution never guesses between
+// candidates, an unknown account and one you don't belong to are the
+// identical 404, and a channel-ladder read mirrors the CLI's own per-row
+// ERROR behaviour rather than failing the whole response.
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// getRaw is getJSON's plain-text cousin: some answers here (http.NotFound's
+// body) are not JSON, and asserting on them byte-for-byte is the point of
+// TestAPIChannelsRefusalMatrix.
+func getRaw(t *testing.T, u, bearer string) (int, string) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, u, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ body := make([]byte, 0, 256)
+ buf := make([]byte, 256)
+ for {
+ n, err := res.Body.Read(buf)
+ body = append(body, buf[:n]...)
+ if err != nil {
+ break
+ }
+ }
+ 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.
+func TestAPIAppsListsMemberships(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ // bac: a second account alice is NOT a member of, with its own app.
+ if err := fx.st.CreateAccount(2, "bac", "widgets", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(2, "secretapp", "bob@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "alice@keymail.dev", "code-apps-alice")
+
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps", token)
+ if status != http.StatusOK {
+ t.Fatalf("apps = %d %v", status, out)
+ }
+ accts, _ := out["accounts"].([]any)
+ if len(accts) != 1 {
+ t.Fatalf("accounts = %v, want exactly bab (bac must not appear)", out["accounts"])
+ }
+ a, _ := accts[0].(map[string]any)
+ if str(t, a, "sqid") != "bab" || str(t, a, "name") != "ops" || str(t, a, "role") != "member" {
+ t.Fatalf("account = %v", a)
+ }
+ apps, _ := a["apps"].([]any)
+ found := false
+ for _, v := range apps {
+ if v == "titogo" {
+ found = true
+ }
+ if v == "secretapp" {
+ t.Fatalf("bab's apps leaked bac's app: %v", apps)
+ }
+ }
+ if !found {
+ t.Fatalf("apps = %v, want titogo among them", apps)
+ }
+}
+
+// TestAPIAppsNoToken: the same 401 cliUser always gives.
+func TestAPIAppsNoToken(t *testing.T) {
+ fx := newConsoleFixture(t)
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps", "")
+ if status != http.StatusUnauthorized {
+ t.Fatalf("apps with no token = %d %v, want 401", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "not signed in") {
+ t.Fatalf("apps 401 body = %v, want the cliUser sentence", out)
+ }
+}
+
+// TestAPIChannelsHappy: a promoted channel reads back through the API
+// exactly as the CLI's own `carlos channels` reads it — the account segment
+// works identically whether given as the sqid or the name, `rung` is always
+// present (empty for a channel outside the ladder's vocabulary), and
+// `promoted_at` is a real RFC3339 timestamp a typed client can parse.
+func TestAPIChannelsHappy(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")
+ seedChannel(t, fx, "titogo", "canary/x", "abc1234")
+ // A second, non-rung channel — "adhoc" matches no ladder rung, so `rung`
+ // must come back "" rather than being omitted.
+ shipVersion(t, fx, "titogo", "def5678")
+ seedChannel(t, fx, "titogo", "adhoc", "def5678")
+ token := cliToken(t, fx, "paul@keymail.dev", "code-channels-paul")
+
+ for _, seg := range []string{"bab", "ops"} {
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/"+seg+"/titogo/channels", token)
+ if status != http.StatusOK {
+ t.Fatalf("channels (%s) = %d %v", seg, status, out)
+ }
+ chans, _ := out["channels"].([]any)
+ if len(chans) != 2 {
+ t.Fatalf("channels (%s) = %v, want exactly two entries", seg, out["channels"])
+ }
+ byName := map[string]map[string]any{}
+ for _, c := range chans {
+ m, _ := c.(map[string]any)
+ byName[str(t, m, "channel")] = m
+ }
+ canary, ok := byName["canary/x"]
+ if !ok {
+ t.Fatalf("channels (%s) missing canary/x: %v", seg, chans)
+ }
+ if str(t, canary, "version") != "abc1234" || str(t, canary, "rung") != "canary" {
+ t.Fatalf("canary/x entry (%s) = %v", seg, canary)
+ }
+ if _, err := time.Parse(time.RFC3339, str(t, canary, "promoted_at")); err != nil {
+ t.Fatalf("canary/x promoted_at (%s) = %q, not RFC3339: %v", seg, str(t, canary, "promoted_at"), err)
+ }
+ adhoc, ok := byName["adhoc"]
+ if !ok {
+ t.Fatalf("channels (%s) missing adhoc: %v", seg, chans)
+ }
+ if str(t, adhoc, "version") != "def5678" {
+ t.Fatalf("adhoc entry (%s) = %v", seg, adhoc)
+ }
+ if v, present := adhoc["rung"]; !present || v != "" {
+ t.Fatalf("adhoc rung (%s) = %v (present=%v), want present and empty", seg, v, present)
+ }
+ if _, err := time.Parse(time.RFC3339, str(t, adhoc, "promoted_at")); err != nil {
+ t.Fatalf("adhoc promoted_at (%s) = %q, not RFC3339: %v", seg, str(t, adhoc, "promoted_at"), err)
+ }
+ }
+}
+
+// TestAPIChannelsErrorRow: a channel pointer that fails verification becomes
+// one erroring ROW, not a failed response — the good channel alongside it
+// must still read back intact.
+func TestAPIChannelsErrorRow(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")
+ seedChannel(t, fx, "titogo", "canary/x", "abc1234")
+ // A garbage pointer at a second channel key — not signed, not even JSON —
+ // planted directly in the bucket the way a corrupted or hand-edited
+ // object would arrive.
+ garbage := []byte("not a valid signed channel pointer")
+ if _, err := fx.rel.Put(context.Background(), release.ChannelKey(release.OpsAccount, "titogo", "broken"), garbage, ""); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-channels-error-row")
+
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token)
+ if status != http.StatusOK {
+ t.Fatalf("channels = %d %v, want 200 even with one bad pointer", status, out)
+ }
+ chans, _ := out["channels"].([]any)
+ if len(chans) != 2 {
+ t.Fatalf("channels = %v, want exactly two rows (one good, one broken)", out["channels"])
+ }
+ byName := map[string]map[string]any{}
+ for _, c := range chans {
+ m, _ := c.(map[string]any)
+ byName[str(t, m, "channel")] = m
+ }
+ broken, ok := byName["broken"]
+ if !ok {
+ t.Fatalf("channels missing the broken row: %v", chans)
+ }
+ if str(t, broken, "error") == "" {
+ t.Fatalf("broken row = %v, want a non-empty error", broken)
+ }
+ if _, present := broken["version"]; present {
+ t.Fatalf("broken row = %v, want no version alongside the error", broken)
+ }
+ good, ok := byName["canary/x"]
+ if !ok {
+ t.Fatalf("channels missing the good row: %v", chans)
+ }
+ if str(t, good, "version") != "abc1234" || str(t, good, "rung") != "canary" {
+ t.Fatalf("good row = %v, want it unaffected by the broken one", good)
+ }
+}
+
+// TestAPIChannelsRefusalMatrix: an unknown account, and an account you belong
+// to but an app it hasn't claimed, are the SAME 404 — byte-identical to
+// http.NotFound's own output, so a token holder learns nothing by probing.
+func TestAPIChannelsRefusalMatrix(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+
+ // (a) member of nothing.
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-channels-eve")
+ statusA, bodyA := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", eve)
+ if statusA != http.StatusNotFound {
+ t.Fatalf("member-of-nothing channels = %d, want 404", statusA)
+ }
+
+ // (b) member of bab, but the app is unclaimed there.
+ if err := fx.st.AddMember(OpsAccountID, "frank@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ frank := cliToken(t, fx, "frank@keymail.dev", "code-channels-frank")
+ statusB, bodyB := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/ghost-app/channels", frank)
+ if statusB != http.StatusNotFound {
+ t.Fatalf("unclaimed-app channels = %d, want 404", statusB)
+ }
+
+ if bodyA != bodyB {
+ t.Fatalf("the two refusals are not byte-identical:\n(a) %q\n(b) %q", bodyA, bodyB)
+ }
+ // http.NotFound's own, well-known output — pinned so a future change to
+ // either handler is caught even if it stays self-consistent.
+ if bodyA != "404 page not found\n" {
+ t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", bodyA)
+ }
+}
+
+// TestAPIAccountSqidBeatsName: a SQID match wins outright, even when the
+// segment given also happens to equal ANOTHER of the caller's accounts'
+// NAME. Picking that other account here would list (or later, act on) the
+// wrong tenant.
+func TestAPIAccountSqidBeatsName(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ // bac's NAME is literally "bab" — the sqid of the ops account. Alice
+ // belongs to both.
+ if err := fx.st.CreateAccount(2, "bac", "bab", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "alice@keymail.dev", "code-sqid-beats-name")
+
+ // "bab" as the account segment must resolve to the SQID match (the ops
+ // account, which has titogo) and not the NAME match (bac, which doesn't).
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token)
+ if status != http.StatusOK {
+ t.Fatalf("channels = %d %v, want 200 (sqid match, not bac's name match)", status, out)
+ }
+}
+
+// TestAPIAccountAmbiguousNameRefused: two of the caller's own accounts
+// sharing a name is refused with 409 and both candidate sqids named, never
+// silently resolved to one of them.
+func TestAPIAccountAmbiguousNameRefused(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ sqidA, sqidB := domains.Sqid(2), domains.Sqid(3)
+ if err := fx.st.CreateAccount(2, sqidA, "widgets", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.CreateAccount(3, sqidB, "widgets", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "alice@keymail.dev", "code-ambiguous-name")
+
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/widgets/anyapp/channels", token)
+ if status != http.StatusConflict {
+ t.Fatalf("ambiguous-name channels = %d %v, want 409", status, out)
+ }
+ msg := str(t, out, "error")
+ if !strings.Contains(msg, sqidA) || !strings.Contains(msg, sqidB) {
+ t.Fatalf("ambiguous-name error = %q, want both %q and %q named", msg, sqidA, sqidB)
+ }
+}
+
+// TestAPIAccountNameMatchIsCaseInsensitive: a unique name match applies
+// EqualFold, matching resolveAccount's own doctrine (accounts.go).
+func TestAPIAccountNameMatchIsCaseInsensitive(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ sqid := domains.Sqid(2)
+ if err := fx.st.CreateAccount(2, sqid, "Widgets", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(2, "gizmo", "alice@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "alice@keymail.dev", "code-case-insensitive-name")
+
+ status, out := getJSON(t, fx.ts.URL+"/api/cli/apps/widgets/gizmo/channels", token)
+ if status != http.StatusOK {
+ 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)
+ }
+ // The refusal must be true, not just spoken: neither call may have moved
+ // the rung gate to a spot where PromoteOnLadder ran first and the 403 was
+ // merely decorative. If a future edit reordered the gate below
+ // PromoteOnLadder, this is what would catch it — the test above would
+ // still pass on status and body alone.
+ if _, err := release.Channel(context.Background(), fx.rel, fx.pub, release.OpsAccount, "titogo", "stable"); err == nil {
+ t.Fatalf("stable channel exists after two refused promotions, want it absent")
+ }
+}
+
+// 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")
+
+ // 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)
+ 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"), "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")
+ }
+}
+
+// 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)
+ }
+}
+
+// TestAPIRollbackStableRefused: Paul's ruling (2026-08-03) — a rollback that
+// would land on stable is refused with the SAME byte-identical 403 as a
+// stable promote, for a member token and an owner token alike, and the
+// pointer it would have touched is left exactly as it was.
+func TestAPIRollbackStableRefused(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")
+ // A stable channel with a real history to roll back to — so a refusal
+ // that let the rollback through would be visible in what "stable" points
+ // at afterwards, not just in a channel that never existed.
+ seedChannel(t, fx, "titogo", "stable", "abc1234")
+ seedChannel(t, fx, "titogo", "stable", "def5678")
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-rollback-stable-owner")
+ member := cliToken(t, fx, "alice@keymail.dev", "code-rollback-stable-member")
+
+ body := map[string]string{"channel": "stable"}
+ statusO, outO := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", body, owner)
+ statusM, outM := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/rollback", body, member)
+ if statusO != http.StatusForbidden || statusM != http.StatusForbidden {
+ t.Fatalf("stable rollback = owner %d, member %d, want both 403", statusO, statusM)
+ }
+ if outO != outM {
+ t.Fatalf("stable rollback 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 rollback refusal = %q, want console_url %q", outO, wantURL)
+ }
+ if !strings.Contains(outO, "passkey") {
+ t.Fatalf("stable rollback refusal = %q, want it to name the passkey requirement", outO)
+ }
+ if cp := fx.channelVersion(t, "titogo", "stable"); cp.Version != "def5678" {
+ t.Fatalf("stable channel after two refused rollbacks = %q, want def5678 (unchanged)", cp.Version)
+ }
+}
+
+// ---- ship (apicli.go) -------------------------------------------------------
+//
+// `carlos ship`'s door: a multipart upload whose bytes are hashed and sized
+// HERE, server-side, from what actually arrived on the wire — never from a
+// field a caller could set, because there is no such field on this wire
+// shape (the meta part carries only version and kind).
+
+// shipPart is one part of a multipart ship request, in the order it should
+// be written — order matters, since the handler insists "meta" come first.
+type shipPart struct {
+ name string
+ body []byte
+}
+
+// metaPart builds the JSON "meta" part every ship request starts with.
+func metaPart(t *testing.T, version, kind string) shipPart {
+ t.Helper()
+ raw, err := json.Marshal(map[string]string{"version": version, "kind": kind})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return shipPart{name: "meta", body: raw}
+}
+
+// randomBytes is a stand-in artifact payload: real bytes, not a fixed
+// string, so a hash mismatch would actually be caught rather than a lucky
+// coincidence.
+func randomBytes(t *testing.T, n int) []byte {
+ t.Helper()
+ b := make([]byte, n)
+ if _, err := rand.Read(b); err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+// postShip POSTs a multipart/form-data ship request built from parts, in
+// the order given, and decodes whatever JSON comes back (a refusal that
+// never reaches the handler's own JSON responses — e.g. a body cut short by
+// MaxBytesReader — decodes to an empty map, which the caller only inspects
+// via the status code).
+func postShip(t *testing.T, u, bearer string, parts []shipPart) (int, map[string]any) {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ for _, p := range parts {
+ fw, err := mw.CreateFormFile(p.name, p.name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fw.Write(p.body); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := mw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ req, err := http.NewRequest(http.MethodPost, u, &buf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ raw, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ out := map[string]any{}
+ _ = json.Unmarshal(raw, &out)
+ return res.StatusCode, out
+}
+
+// TestAPIShipHappy: a fresh binary release ships through the multipart
+// door, lands at the exact bucket key the direct-to-bucket path would use,
+// and the manifest the server writes carries hashes/sizes it computed
+// itself — matching what the artifact bytes actually hash to, and BuiltBy
+// naming the console API as the shipper.
+func TestAPIShipHappy(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-ship-happy")
+ payload := randomBytes(t, 1024)
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship = %d %v", status, out)
+ }
+ if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" {
+ t.Fatalf("ship body = %v", out)
+ }
+ sum := sha256.Sum256(payload)
+ wantSHA := hex.EncodeToString(sum[:])
+ arts, _ := out["artifacts"].(map[string]any)
+ arm64, _ := arts["linux-arm64"].(map[string]any)
+ if arm64 == nil {
+ t.Fatalf("ship artifacts = %v, want linux-arm64", arts)
+ }
+ if str(t, arm64, "sha256") != wantSHA {
+ t.Fatalf("artifact sha256 = %v, want %s (computed from the bytes sent)", arm64["sha256"], wantSHA)
+ }
+ if sz, _ := arm64["size"].(float64); int64(sz) != int64(len(payload)) {
+ t.Fatalf("artifact size = %v, want %d", arm64["size"], len(payload))
+ }
+
+ ctx := context.Background()
+ stored, _, err := fx.rel.Get(ctx, release.ArtifactKey(release.OpsAccount, "titogo", "abc1234", "linux-arm64"))
+ if err != nil {
+ t.Fatalf("stored artifact bytes: %v", err)
+ }
+ if !bytes.Equal(stored, payload) {
+ t.Fatalf("stored artifact bytes differ from what was shipped")
+ }
+ m, err := release.GetManifest(ctx, fx.rel, release.OpsAccount, "titogo", "abc1234")
+ if err != nil {
+ t.Fatalf("manifest: %v", err)
+ }
+ a, ok := m.Artifacts["linux-arm64"]
+ if !ok {
+ t.Fatalf("manifest artifacts = %v, want linux-arm64", m.Artifacts)
+ }
+ if a.SHA256 != wantSHA || a.Size != int64(len(payload)) {
+ t.Fatalf("manifest artifact = %+v, want sha256=%s size=%d", a, wantSHA, len(payload))
+ }
+ if !strings.HasPrefix(m.BuiltBy, "console-api ") {
+ t.Fatalf("manifest built_by = %q, want prefix %q", m.BuiltBy, "console-api ")
+ }
+
+ events, err := fx.st.Events(OpsAccountID, "titogo", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, e := range events {
+ if e.Action == "ship" && strings.Contains(e.Detail, "(cli)") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("events = %v, want a ship row whose detail names the CLI", events)
+ }
+}
+
+// TestAPIShipStatic: a static site release ships under the single
+// "site.tar.gz" artifact name the static kind requires.
+func TestAPIShipStatic(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "website", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-static")
+ payload := randomBytes(t, 512)
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/website/releases", token,
+ []shipPart{metaPart(t, "def5678", "static"), {name: "site.tar.gz", body: payload}})
+ if status != http.StatusCreated {
+ t.Fatalf("ship static = %d %v", status, out)
+ }
+ arts, _ := out["artifacts"].(map[string]any)
+ if _, ok := arts["site.tar.gz"]; !ok {
+ t.Fatalf("ship static artifacts = %v, want site.tar.gz", arts)
+ }
+}
+
+// TestAPIShipRefusals: every malformed shape of a ship request is a 400,
+// and none of them reach release.Ship.
+func TestAPIShipRefusals(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-ship-refusals")
+ payload := randomBytes(t, 128)
+
+ cases := []struct {
+ name string
+ parts []shipPart
+ }{
+ {"bad version chars", []shipPart{
+ metaPart(t, "../etc/passwd", "binary"), {name: "linux-arm64", body: payload}}},
+ {"bad kind", []shipPart{
+ metaPart(t, "abc1234", "weird"), {name: "linux-arm64", body: payload}}},
+ {"binary part not a target name", []shipPart{
+ metaPart(t, "abc1234", "binary"), {name: "not-a-target", body: payload}}},
+ {"static part not site.tar.gz", []shipPart{
+ metaPart(t, "abc1234", "static"), {name: "linux-arm64", body: payload}}},
+ {"no file parts", []shipPart{
+ metaPart(t, "abc1234", "binary")}},
+ {"meta not first part", []shipPart{
+ {name: "linux-arm64", body: payload}, metaPart(t, "abc1234", "binary")}},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, c.parts)
+ if status != http.StatusBadRequest {
+ t.Fatalf("%s = %d %v, want 400", c.name, status, out)
+ }
+ })
+ }
+}
+
+// TestAPIShipTooBig: apiShipMaxBytes bounds the WHOLE request, not any one
+// part — lowered here to 4KiB, an 8KiB artifact part is refused with 413
+// before it is ever handed to release.Ship.
+func TestAPIShipTooBig(t *testing.T) {
+ old := apiShipMaxBytes
+ apiShipMaxBytes = 4 << 10
+ defer func() { apiShipMaxBytes = old }()
+
+ 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-ship-toobig")
+ payload := randomBytes(t, 8<<10)
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversized ship = %d %v, want 413", status, out)
+ }
+}
+
+// TestAPIShipMemberGate: a non-member token gets the identical 404 every
+// other CLI-API door gives an unknown or someone-else's account — apiAccountApp
+// runs before any multipart parsing, so a garbage body never matters here.
+func TestAPIShipMemberGate(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "eve@keymail.dev", "code-ship-gate")
+
+ status, body := postRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", map[string]string{}, token)
+ if status != http.StatusNotFound {
+ t.Fatalf("ship as non-member = %d, want 404", status)
+ }
+ if body != "404 page not found\n" {
+ t.Fatalf("ship as non-member body = %q, want the stdlib http.NotFound output", body)
+ }
+}
+
+// TestAPIShipTooManyParts: apiShipMaxBytes bounds bytes, not COUNT — a body
+// of nothing but tiny, validly-named parts would otherwise fit millions of
+// them in one small request. The ninth binary part (max is
+// apiShipMaxPartsBinary=8) is refused before it is ever read, and nothing
+// from this request reaches release.Ship: no manifest for this version
+// exists afterward.
+func TestAPIShipTooManyParts(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-ship-toomanyparts")
+
+ parts := []shipPart{metaPart(t, "abc1234", "binary")}
+ for i := 0; i < 9; i++ {
+ parts = append(parts, shipPart{name: fmt.Sprintf("part-%d", i), body: []byte("x")})
+ }
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, parts)
+ if status != http.StatusBadRequest {
+ t.Fatalf("9-part ship = %d %v, want 400", status, out)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil {
+ t.Fatalf("manifest exists after a refused too-many-parts ship, want none — nothing should reach release.Ship")
+ }
+}
+
+// TestAPIShipDuplicatePart: two parts claiming the same artifact name are
+// refused rather than letting the second silently win — both would
+// otherwise be charged against the byte and part-count budgets while only
+// the last one's bytes ever reached the manifest.
+func TestAPIShipDuplicatePart(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-ship-duplicate")
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{
+ metaPart(t, "abc1234", "binary"),
+ {name: "linux-arm64", body: randomBytes(t, 32)},
+ {name: "linux-arm64", body: randomBytes(t, 32)},
+ })
+ if status != http.StatusBadRequest {
+ t.Fatalf("duplicate-named ship = %d %v, want 400", status, out)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil {
+ t.Fatalf("manifest exists after a refused duplicate-part ship, want none")
+ }
+}
+
+// TestAPIShipTraversalRefusals: every part name that reads like an attempt
+// to escape the artifact's bucket-key segment is refused with 400, and — the
+// point of this test, not just its status code — none of them leave any
+// trace in the release store. Part names become bucket key segments via the
+// manifest (release.ArtifactKey joins them straight into the object path),
+// so this is path-traversal defense, not tidiness.
+func TestAPIShipTraversalRefusals(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-ship-traversal")
+ payload := randomBytes(t, 64)
+
+ cases := []struct {
+ name string
+ version string
+ kind string
+ part string
+ }{
+ {"dotdot slash etc passwd", "trv00001", "binary", "../../etc/passwd"},
+ {"nested traversal under a valid-looking prefix", "trv00002", "binary", "linux-arm64/../../x"},
+ {"bare dotdot", "trv00003", "binary", ".."},
+ {"empty part name", "trv00004", "binary", ""},
+ {"static traversal", "trv00005", "static", "../site.tar.gz"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, c.version, c.kind), {name: c.part, body: payload}})
+ if status != http.StatusBadRequest {
+ t.Fatalf("%s = %d %v, want 400", c.name, status, out)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil {
+ t.Fatalf("%s: manifest exists for version %s, want none — release.Ship must never have been reached",
+ c.name, c.version)
+ }
+ })
+ }
+}
+
+// TestAPIShipImmutableConflict: re-shipping an already-shipped version with
+// DIFFERENT bytes is a permanent 409, not the generic 502 every other
+// release.Ship failure gets — a caller retrying a 502 would re-upload
+// forever and never learn this can't succeed. Re-shipping the exact same
+// bytes, by contrast, must stay a success: it is the one case release.Ship
+// itself calls idempotent, and a caller retrying a dropped connection needs
+// that retry to actually land.
+func TestAPIShipImmutableConflict(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-ship-immutable")
+ payload := randomBytes(t, 256)
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusCreated {
+ t.Fatalf("first ship = %d %v", status, out)
+ }
+
+ different := randomBytes(t, 256)
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: different}})
+ if status != http.StatusConflict {
+ t.Fatalf("re-ship with different bytes = %d %v, want 409", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "immutable") {
+ t.Fatalf("conflict error = %q, want it to name immutability", str(t, out, "error"))
+ }
+
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusOK {
+ t.Fatalf("re-ship with identical bytes = %d %v, want 200 (idempotent, nothing created)", status, out)
+ }
+ if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" {
+ t.Fatalf("idempotent re-ship body = %v", out)
+ }
+}
+
+// TestAPIShipConcurrencyLimit: the rate limiter bounds requests over TIME,
+// but says nothing about how many run AT ONCE. Two ships already in flight
+// (each holding the server's ship semaphore while its body trickles in,
+// via an io.Pipe under the test's control) leave a third concurrent
+// request no room — it must be refused immediately with 429, not queued
+// behind either of the first two.
+func TestAPIShipConcurrencyLimit(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-ship-concurrency")
+
+ // A hook (same shape as cliNotified): signalled once a request has
+ // actually acquired shipSem, so this test waits on a real fact instead
+ // of racing the two background goroutines below with a sleep.
+ fx.srv.shipStarted = make(chan struct{}, 2)
+
+ var wg sync.WaitGroup
+ statuses := make([]int, 2)
+ var closers []func() error
+ for i := 0; i < 2; i++ {
+ pr, pw := io.Pipe()
+ closers = append(closers, pw.Close)
+ req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", pr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "multipart/form-data; boundary=xshipboundaryx")
+ req.Header.Set("Authorization", "Bearer "+token)
+ wg.Add(1)
+ idx := i
+ go func() {
+ defer wg.Done()
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ statuses[idx] = -1
+ return
+ }
+ defer res.Body.Close()
+ io.Copy(io.Discard, res.Body)
+ statuses[idx] = res.StatusCode
+ }()
+ }
+
+ for i := 0; i < 2; i++ {
+ select {
+ case <-fx.srv.shipStarted:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for the first two ships to start (acquire shipSem)")
+ }
+ }
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if status != http.StatusTooManyRequests {
+ t.Fatalf("third concurrent ship = %d %v, want 429", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "in progress") {
+ t.Fatalf("third concurrent ship error = %q, want it to name the concurrency limit", str(t, out, "error"))
+ }
+
+ // Unblock the two held-open requests (each now sees an immediately-EOF
+ // body and is refused as an empty upload) so the test doesn't leak
+ // goroutines, and confirm they actually finish.
+ for _, c := range closers {
+ c()
+ }
+ done := make(chan struct{})
+ go func() { wg.Wait(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for the held-open ships to finish after closing their bodies")
+ }
+ for i, st := range statuses {
+ if st != http.StatusBadRequest {
+ t.Fatalf("held-open ship %d finished with status %d, want 400 (empty upload, once unblocked)", i, st)
+ }
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 6fdd51679..2ebb76b69 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -62,6 +62,18 @@ const (
cliWriteBurst = 30
// cliPollInterval is the wait the console asks the CLI to keep, seconds.
cliPollInterval = 2
+
+ // cliShipWindow is ship's own budget window — an hour, not the minute
+ // every other CLI limiter uses: a ship is a deliberate release cut, not
+ // a poll or a login guess, so the window matches how often a human (or
+ // a CI job) actually does this.
+ cliShipWindow = time.Hour
+ // cliShipBurst bounds ship uploads per TOKEN (see handleAPIShip) within
+ // that hour. Ships are rare: a build-and-ship is a deliberate act run a
+ // handful of times a day at most, so ten in an hour is roomy for
+ // legitimate use while making a stolen token's usefulness for flooding
+ // storage negligible.
+ cliShipBurst = 10
)
// rateLimiter is a fixed-window counter, keyed by whatever the caller thinks
@@ -126,7 +138,7 @@ func (l *rateLimiter) allow(key string, now time.Time) bool {
// cliLimits is the set of limiters the CLI endpoints share, hung off the
// Server so a test gets a fresh set per fixture.
type cliLimits struct {
- start, poll, pollIP, guessAddr, guessIP, write *rateLimiter
+ start, poll, pollIP, guessAddr, guessIP, write, ship *rateLimiter
}
func newCLILimits() *cliLimits {
@@ -140,6 +152,10 @@ func newCLILimits() *cliLimits {
guessAddr: newRateLimiter(cliGuessBurst, cliRateWindow),
guessIP: newRateLimiter(cliGuessBurst, cliRateWindow),
write: newRateLimiter(cliWriteBurst, cliRateWindow),
+ // Ship gets its own budget, on its own window, keyed by token rather
+ // than address (see handleAPIShip) — an upload is a different shape
+ // of cost (bandwidth, storage) from the JSON mutations write covers.
+ ship: newRateLimiter(cliShipBurst, cliShipWindow),
}
}
@@ -154,6 +170,14 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("GET /auth/cli/whoami", s.handleCLIWhoami)
s.mux.HandleFunc("POST /auth/cli/revoke", s.handleCLIRevoke)
s.mux.HandleFunc("POST /auth/cli/accounts", s.handleCLINewAccount)
+ // The CLI's read-only API (apicli.go) — deliberately a different prefix
+ // (/api/cli/) from the identity routes above (/auth/cli/), which must not
+ // 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)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip)
}
// clientIP is who the rate limiter counts against.
diff --git a/internal/console/server.go b/internal/console/server.go
index 53fb6adf1..abcf0babb 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -120,6 +120,22 @@ type Server struct {
// notification attempt. Tests only — the send is off the request
// goroutine, and a test that asserted on it without this would be racing.
cliNotified chan struct{}
+ // shipSem bounds how many `carlos ship` uploads (apicli.go,
+ // handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which
+ // bounds them over TIME. A rate limiter is a counter; it says nothing
+ // about concurrency, and a ship holds a goroutine plus a substantial
+ // buffer open for as long as the client trickles bytes in — a handful
+ // of slow uploads run in parallel is a resource-exhaustion path the
+ // counter alone does nothing to close. Always initialized (never nil):
+ // unlike cliNotified below this is load-bearing in production, not a
+ // test hook.
+ shipSem chan struct{}
+ // shipStarted, when non-nil, is signalled after a ship request
+ // acquires shipSem, before it does any (possibly slow, possibly
+ // blocking) reading of the request body. Tests only, same reasoning as
+ // cliNotified: without this a test racing to fill shipSem would have no
+ // way to know the earlier requests actually hold it yet.
+ shipStarted chan struct{}
}
// NewServer wires a server over an opened Store, loading (or minting) the
@@ -140,6 +156,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme),
secure: strings.HasPrefix(cfg.Origin, "https://"),
cli: newCLILimits(),
+ // Two concurrent ships at a time: roomy for one operator's CI plus a
+ // human running `carlos ship` by hand, tight enough that a fleet of
+ // slow-loris uploads against a stolen or shared token can't pile up
+ // goroutines and buffers without bound.
+ shipSem: make(chan struct{}, 2),
}
s.mux.HandleFunc("GET /{$}", s.handleHome)
s.mux.HandleFunc("POST /auth/continue", s.handleContinue)