carlos / platform Public

console: CLI API — accounts/apps listing and channels reads

Paul Campbell 5856dc5307549f70c95a2301016612a4921329f2 parent d0495cb
3 files changed, +297 −0
  • internal/console/apicli.go +127 −0
  • internal/console/apicli_test.go +165 −0
  • internal/console/authcli.go +5 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
new file mode 100644
index 000000000..5ab047c7b
--- /dev/null
+++ b/internal/console/apicli.go
@@ -0,0 +1,127 @@
+// 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"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// 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 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
+ }
+ entry := map[string]any{
+ "channel": name,
+ "version": cp.Version,
+ "promoted_at": cp.PromotedAt.UTC().Format(time.RFC3339),
+ }
+ if rung, ok := release.Rung(name); ok {
+ entry["rung"] = rung
+ }
+ out = append(out, entry)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"channels": out})
+}
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
new file mode 100644
index 000000000..0cda5876a
--- /dev/null
+++ b/internal/console/apicli_test.go
@@ -0,0 +1,165 @@
+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, 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 (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+// 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)
+}
+
+// TestAPIAppsListsMemberships: a token only ever learns about the accounts
+// IT belongs to, and only that account's apps — a second account existing
+// (bac) never leaks into the body.
+func TestAPIAppsListsMemberships(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ // bac: a second account alice is NOT a member of.
+ if err := fx.st.CreateAccount(2, "bac", "widgets", "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 == "bac" {
+ t.Fatalf("apps for bab leaked something from bac: %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 — and the account
+// segment works identically whether given as the sqid or the name.
+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")
+ 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) != 1 {
+ t.Fatalf("channels (%s) = %v, want exactly one entry", seg, out["channels"])
+ }
+ entry, _ := chans[0].(map[string]any)
+ if str(t, entry, "channel") != "canary/x" || str(t, entry, "version") != "abc1234" ||
+ str(t, entry, "rung") != "canary" || str(t, entry, "promoted_at") == "" {
+ t.Fatalf("channels (%s) entry = %v", seg, entry)
+ }
+ }
+}
+
+// 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)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 6fdd51679..9c7e96633 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -154,6 +154,11 @@ 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)
}
// clientIP is who the rate limiter counts against.