carlos / platform Public

feat: console slice 2 — claim apps, promote, rollback from the browser

Claim-once app names (CLI-granted apps stay claim-proof); per-version
promote to existing or new channels with bake-window notes; per-channel
rollback; keyless deployments degrade to read-only. CSRF throughout;
owner-gating identical to 404.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8d8c61784811d987f30e920c3b19e8e81fef5496 parent 3a71518
10 files changed, +734 −42
  • cmd/console/main.go +16 −2
  • internal/console/server.go +228 −14
  • internal/console/server_test.go +240 −8
  • internal/console/store.go +34 −0
  • internal/console/store_test.go +29 −0
  • internal/console/templates/app.html +48 −2
  • internal/console/templates/apps.html +10 −2
  • internal/console/templates/layout.html +12 −5
  • internal/console/view.go +60 −1
  • internal/console/view_test.go +57 −8
diff --git a/cmd/console/main.go b/cmd/console/main.go
index c009273ba..9bade7513 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -14,12 +14,14 @@
// /data/console/console.db), CONSOLE_ORIGIN (required, e.g.
// https://platform.carlosframework.com), CONSOLE_KEYMAIL (default
// https://keymail.dev), CARLOS_REGISTRY (default /data/registry.db),
-// CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY.
-// grant/revoke/owners need only CONSOLE_DB.
+// CARLOS_DEPLOYMENT_BUCKET or CARLOS_DEPLOYMENT_DIR, CARLOS_RELEASE_PUBKEY,
+// and optionally CARLOS_RELEASE_KEY (enables promote/rollback from the
+// console; unset = read-only). grant/revoke/owners need only CONSOLE_DB.
package main
import (
"context"
+ "crypto/ed25519"
"fmt"
"log"
"net/http"
@@ -85,6 +87,17 @@ func serve() {
if err != nil {
log.Fatal(err)
}
+ // The signing key is optional: without it the console runs read-only and
+ // the promote/rollback surface is disabled. Never logged, never rendered.
+ var key ed25519.PrivateKey
+ if os.Getenv("CARLOS_RELEASE_KEY") != "" {
+ key, err = release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "CARLOS_RELEASE_KEY")
+ if err != nil {
+ log.Fatal(err)
+ }
+ } else {
+ log.Print("console: CARLOS_RELEASE_KEY unset — promotions disabled, running read-only")
+ }
st := openConsoleDB()
srv, err := console.NewServer(console.Config{
Origin: origin,
@@ -92,6 +105,7 @@ func serve() {
Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"),
Releases: openStore(),
ReleasePub: pub,
+ Key: key,
}, st)
if err != nil {
log.Fatal(err)
diff --git a/internal/console/server.go b/internal/console/server.go
index 10f103873..0ecc52387 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -43,6 +43,10 @@ type Config struct {
Registry string // path to the platform registry DB (read-only here)
Releases release.Store // the deployment bucket
ReleasePub ed25519.PublicKey // pinned channel-pointer verification key
+ // Key signs channel pointers for promote/rollback (CARLOS_RELEASE_KEY).
+ // nil is a legal deployment: the console runs read-only, the promotion
+ // UI renders disabled, and the mutation handlers refuse with 503.
+ Key ed25519.PrivateKey
}
// Server is the console's http.Handler.
@@ -78,7 +82,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /auth/confirm", s.handleConfirm)
s.mux.HandleFunc("POST /auth/signout", s.handleSignout)
s.mux.HandleFunc("GET /apps", s.handleApps)
+ s.mux.HandleFunc("POST /apps/new", s.handleNewApp)
s.mux.HandleFunc("GET /apps/{app}", s.handleApp)
+ s.mux.HandleFunc("POST /apps/{app}/promote", s.handlePromote)
+ s.mux.HandleFunc("POST /apps/{app}/rollback", s.handleRollback)
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
@@ -102,8 +109,13 @@ type page struct {
Msg string // generic message page body
- Apps []appRow // /apps
- Detail *AppView // /apps/{app}
+ Flash string // one-shot success notice (cookie-carried across a redirect)
+ FlashErr string // one-shot error notice
+
+ Apps []appRow // /apps
+ Detail *AppView // /apps/{app}
+ Versions []VersionView // /apps/{app}: shipped releases, newest first
+ CanPromote bool // a signing key is configured on this host
}
// appRow is one line of the "your apps" table.
@@ -167,6 +179,35 @@ func (s *Server) clearCookie(w http.ResponseWriter, name string) {
s.setCookie(w, name, "", -1)
}
+// ---- flash notices ----------------------------------------------------------
+
+const flashCookie = "carlos_console_flash"
+
+// setFlash parks a one-shot notice ("ok" or "err") in a cookie for the page
+// after the redirect — no query-string flashes an outsider could spoof into
+// a link.
+func (s *Server) setFlash(w http.ResponseWriter, kind, msg string) {
+ s.setCookie(w, flashCookie, url.QueryEscape(kind+"|"+msg), 300)
+}
+
+// takeFlash reads and clears the notice.
+func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) (flash, flashErr string) {
+ c, err := r.Cookie(flashCookie)
+ if err != nil || c.Value == "" {
+ return "", ""
+ }
+ s.clearCookie(w, flashCookie)
+ v, err := url.QueryUnescape(c.Value)
+ if err != nil {
+ return "", ""
+ }
+ kind, msg, _ := strings.Cut(v, "|")
+ if kind == "err" {
+ return "", msg
+ }
+ return msg, ""
+}
+
// ---- auth handlers ----------------------------------------------------------
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
@@ -359,6 +400,7 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
p.Welcome = true
s.clearCookie(w, welcomeCookie)
}
+ p.Flash, p.FlashErr = s.takeFlash(w, r)
apps, err := s.store.AppsFor(addr)
if err != nil {
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
@@ -378,25 +420,33 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "apps", p)
}
-func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
- addr, raw, ok := s.session(r)
- if !ok {
+// ownedApp gates the per-app pages and mutations: session required, and the
+// path's app must be owned by the signed-in address — not owned and
+// nonexistent look identical (404) on purpose.
+func (s *Server) ownedApp(w http.ResponseWriter, r *http.Request) (addr, raw, app string, ok bool) {
+ addr, raw, sok := s.session(r)
+ if !sok {
http.Redirect(w, r, "/", http.StatusFound)
- return
+ return "", "", "", false
}
- app := r.PathValue("app")
+ app = r.PathValue("app")
apps, err := s.store.AppsFor(addr)
if err != nil {
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
- return
+ return "", "", "", false
}
- owned := false
for _, a := range apps {
- owned = owned || a == app
+ if a == app {
+ return addr, raw, app, true
+ }
}
- if !owned {
- // Not owned and nonexistent look identical on purpose.
- s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.")
+ s.renderMessage(w, r, http.StatusNotFound, "Not found", "No such app here.")
+ return "", "", "", false
+}
+
+func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
+ addr, raw, app, ok := s.ownedApp(w, r)
+ if !ok {
return
}
views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, []string{app})
@@ -405,10 +455,174 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
return
}
- p := page{Title: app, Address: addr, CSRF: raw, Detail: &views[0]}
+ p := page{Title: app, Address: addr, CSRF: raw, Detail: &views[0], CanPromote: s.cfg.Key != nil}
+ p.Flash, p.FlashErr = s.takeFlash(w, r)
+ versions, err := Versions(s.cfg.Releases, app)
+ if err != nil {
+ log.Printf("console: versions %s: %v", app, err)
+ p.FlashErr = "Could not list this app's releases."
+ }
+ p.Versions = versions
s.render(w, http.StatusOK, "app", p)
}
+// handleNewApp claims an app name — once, ever. A name is refused when the
+// apps table has it, when ANY owner exists for it (CLI-granted apps stay
+// claim-proof), or when the release store already holds anything under
+// apps/<name>/.
+func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ fail := func(msg string) {
+ s.setFlash(w, "err", msg)
+ http.Redirect(w, r, "/apps", http.StatusSeeOther)
+ }
+ name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
+ if !release.ValidApp(name) {
+ fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")
+ return
+ }
+ if has, err := s.store.HasApp(name); err != nil {
+ fail("Could not check that name. Please try again.")
+ return
+ } else if has {
+ fail("That name is taken.")
+ return
+ }
+ if has, err := s.store.HasOwners(name); err != nil {
+ fail("Could not check that name. Please try again.")
+ return
+ } else if has {
+ fail("That name is taken.")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ if keys, err := s.cfg.Releases.List(ctx, "apps/"+name+"/"); err != nil {
+ fail("Could not check the release store. Please try again.")
+ return
+ } else if len(keys) > 0 {
+ fail("That name is taken.")
+ return
+ }
+ created, err := s.store.CreateApp(name, addr, time.Now())
+ if err != nil {
+ fail("Could not create the app. Please try again.")
+ return
+ }
+ if !created { // lost a claim race after the checks
+ fail("That name is taken.")
+ return
+ }
+ if err := s.store.Grant(addr, name); err != nil {
+ fail("Created the app, but granting you ownership failed — ask an operator.")
+ return
+ }
+ s.setFlash(w, "ok", "Created app "+name+" — it's yours.")
+ http.Redirect(w, r, "/apps/"+url.PathEscape(name), http.StatusSeeOther)
+}
+
+// handlePromote points a channel at a shipped version, from the app page.
+func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
+ _, raw, app, ok := s.ownedApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if s.cfg.Key == nil {
+ s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled",
+ "No signing key on this host — the console is running read-only.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ }
+ channel := strings.TrimSpace(r.FormValue("new_channel"))
+ if channel == "" {
+ channel = strings.TrimSpace(r.FormValue("channel"))
+ }
+ if !release.ValidChannel(channel) {
+ back("err", "Invalid channel name.")
+ return
+ }
+ version := strings.TrimSpace(r.FormValue("version"))
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ if !release.ValidVersion(version) {
+ back("err", "Invalid version.")
+ return
+ }
+ if _, err := release.GetManifest(ctx, s.cfg.Releases, app, version); err != nil {
+ back("err", "Version "+version+" is not a shipped release of "+app+".")
+ return
+ }
+ if err := release.Promote(ctx, s.cfg.Releases, s.cfg.Key, app, channel, version); err != nil {
+ log.Printf("console: promote %s %s -> %s: %v", app, version, channel, err)
+ back("err", "Promote failed: "+err.Error())
+ return
+ }
+ msg := "Promoted " + version + " to " + channel + "."
+ if release.HoldFor(release.DefaultHolds, channel) > 0 {
+ msg += " Adopts after the bake window on each host — rollback never bakes."
+ }
+ back("ok", msg)
+}
+
+// handleRollback swaps a channel back to its previous version.
+func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
+ _, raw, app, ok := s.ownedApp(w, r)
+ if !ok {
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 {
+ s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.")
+ return
+ }
+ if s.cfg.Key == nil {
+ s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled",
+ "No signing key on this host — the console is running read-only.")
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ }
+ channel := strings.TrimSpace(r.FormValue("channel"))
+ if !release.ValidChannel(channel) {
+ back("err", "Invalid channel name.")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, app, channel)
+ if err != nil {
+ back("err", "Could not read channel "+channel+".")
+ return
+ }
+ if len(cur.History) == 0 {
+ back("err", "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, app, channel); err != nil {
+ log.Printf("console: rollback %s %s: %v", app, channel, err)
+ back("err", "Rollback failed: "+err.Error())
+ return
+ }
+ back("ok", "Rolled back "+channel+" to "+target+".")
+}
+
// summarize folds one AppView into its "your apps" table row.
func summarize(v AppView) appRow {
row := appRow{App: v.App, Hosts: len(v.Routes)}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 05dd9e659..a878c5c78 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -1,6 +1,8 @@
package console
import (
+ "context"
+ "crypto/ed25519"
"io"
"net/http"
"net/http/cookiejar"
@@ -10,20 +12,31 @@ import (
"regexp"
"strings"
"testing"
+
+ "github.com/carlosframework/platform/internal/release"
)
// consoleFixture is a whole console: fixture platform (registry + bucket
// from view_test.go), console store, fake keymail, and the server under an
// httptest listener whose URL is its own OAuth origin.
type consoleFixture struct {
- ts *httptest.Server
- st *Store
- fk *fakeKeymail
+ ts *httptest.Server
+ st *Store
+ fk *fakeKeymail
+ rel release.Store
+ priv ed25519.PrivateKey
+ pub ed25519.PublicKey
}
func newConsoleFixture(t *testing.T) *consoleFixture {
+ return newConsoleFixtureKeyed(t, true)
+}
+
+// newConsoleFixtureKeyed builds the fixture with or without the channel
+// signing key (withKey=false is the read-only deployment shape).
+func newConsoleFixtureKeyed(t *testing.T, withKey bool) *consoleFixture {
t.Helper()
- regPath, relStore, pub := buildFixtures(t)
+ regPath, relStore, priv, pub := buildFixtures(t)
st, err := Open(filepath.Join(t.TempDir(), "console.db"))
if err != nil {
t.Fatal(err)
@@ -37,20 +50,34 @@ func newConsoleFixture(t *testing.T) *consoleFixture {
srv.ServeHTTP(w, r)
}))
t.Cleanup(ts.Close)
- srv, err = NewServer(Config{
+ cfg := Config{
Origin: ts.URL,
Keymail: fk.srv.URL,
Registry: regPath,
Releases: relStore,
ReleasePub: pub,
- }, st)
+ }
+ if withKey {
+ cfg.Key = priv
+ }
+ srv, err = NewServer(cfg, st)
if err != nil {
t.Fatal(err)
}
if err := st.Grant("paul@keymail.dev", "kass"); err != nil {
t.Fatal(err)
}
- return &consoleFixture{ts: ts, st: st, fk: fk}
+ return &consoleFixture{ts: ts, st: st, fk: fk, rel: relStore, priv: priv, pub: pub}
+}
+
+// channelVersion reads a channel pointer straight from the bucket.
+func (fx *consoleFixture) channelVersion(t *testing.T, app, channel string) *release.ChannelPointer {
+ t.Helper()
+ p, err := release.Channel(context.Background(), fx.rel, fx.pub, app, channel)
+ if err != nil {
+ t.Fatalf("channel %s/%s: %v", app, channel, err)
+ }
+ return p
}
func (fx *consoleFixture) setUser(address string) {
@@ -320,7 +347,7 @@ func TestEmptyStateWelcomesNewUser(t *testing.T) {
}
for _, want := range []string{
"Account created", "Welcome to the CARLOS platform",
- "bob@keymail.dev", "coming soon",
+ "bob@keymail.dev", "Create app", `action="/apps/new"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("empty state missing %q in:\n%s", want, body)
@@ -330,3 +357,208 @@ func TestEmptyStateWelcomesNewUser(t *testing.T) {
t.Fatal("empty state leaked someone else's apps")
}
}
+
+// signInPaul signs a fresh browser in as the fixture's kass owner.
+func signInPaul(t *testing.T, fx *consoleFixture) *http.Client {
+ t.Helper()
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-"+t.Name())
+ return c
+}
+
+func TestCreateApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // Happy path: claim, own, land on the ship-your-first-release panel.
+ status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/new", url.Values{"csrf": {csrf}, "name": {"zephyr"}})
+ if status != http.StatusSeeOther || loc != "/apps/zephyr" {
+ t.Fatalf("claim = %d -> %q, want 303 -> /apps/zephyr", status, loc)
+ }
+ status, _, body := get(t, c, fx.ts.URL+"/apps/zephyr")
+ if status != 200 {
+ t.Fatalf("/apps/zephyr = %d", status)
+ }
+ for _, want := range []string{
+ "Created app zephyr", "Nothing shipped yet",
+ "carlos ship -app zephyr -version $(git rev-parse --short HEAD) -target linux-arm64",
+ "carlos ship -app zephyr -kind static -version v1",
+ "promoted to channels from this page",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body)
+ }
+ }
+ apps, err := fx.st.AppsFor("paul@keymail.dev")
+ if err != nil || !strings.Contains(strings.Join(apps, " "), "zephyr") {
+ t.Fatalf("AppsFor = %v %v, want zephyr owned", apps, err)
+ }
+
+ // A second user cannot claim the same name, a CLI-granted app, or an
+ // app with releases already in the bucket — all the same "taken".
+ if err := release.Ship(context.Background(), fx.rel, release.Manifest{
+ Schema: 1, App: "orphan", Version: "v1", Kind: "binary",
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "orphan"}},
+ }, map[string][]byte{"linux-amd64": []byte("bin")}); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("bob@keymail.dev")
+ cb := newBrowser(t)
+ signUp(t, fx, cb, "code-bob")
+ bobCSRF := sessionToken(t, fx, cb)
+ for _, name := range []string{"zephyr", "kass", "orphan"} {
+ if status, loc, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {bobCSRF}, "name": {name}}); status != http.StatusSeeOther || loc != "/apps" {
+ t.Fatalf("claim %s = %d -> %q, want 303 -> /apps", name, status, loc)
+ }
+ if _, _, body := get(t, cb, fx.ts.URL+"/apps"); !strings.Contains(body, "That name is taken.") {
+ t.Fatalf("claim %s: no taken flash", name)
+ }
+ if apps, _ := fx.st.AppsFor("bob@keymail.dev"); len(apps) != 0 {
+ t.Fatalf("bob owns %v after refused claim of %s", apps, name)
+ }
+ }
+
+ // Invalid names refused; CSRF enforced.
+ if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {bobCSRF}, "name": {"Bad Name!"}}); status != http.StatusSeeOther {
+ t.Fatalf("invalid name = %d, want redirect with flash", status)
+ }
+ if _, _, body := get(t, cb, fx.ts.URL+"/apps"); !strings.Contains(body, "Invalid app name") {
+ t.Fatal("no invalid-name flash")
+ }
+ if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/new", url.Values{"csrf": {"wrong"}, "name": {"fine-name"}}); status != http.StatusForbidden {
+ t.Fatalf("bad csrf claim = %d, want 403", status)
+ }
+}
+
+func TestPromoteFlow(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // The app page offers promote forms per shipped version.
+ _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ if !strings.Contains(body, `action="/apps/kass/promote"`) {
+ t.Fatal("no promote form on the app page")
+ }
+ if strings.Contains(body, "Promotions disabled") {
+ t.Fatal("keyed fixture rendered the disabled note")
+ }
+
+ // Promote v2 to stable (select field).
+ status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}})
+ if status != http.StatusSeeOther || loc != "/apps/kass" {
+ t.Fatalf("promote = %d -> %q", status, loc)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" || len(p.History) == 0 || p.History[0] != "v1" {
+ t.Fatalf("stable pointer = %+v, want v2 with v1 in history", p)
+ }
+ _, _, body = get(t, c, fx.ts.URL+"/apps/kass")
+ if !strings.Contains(body, "Promoted v2 to stable.") ||
+ !strings.Contains(body, "bake window") || !strings.Contains(body, "rollback never bakes") {
+ t.Fatalf("promote flash missing bake note:\n%s", body)
+ }
+
+ // A brand-new channel via the free-text field; beta also bakes.
+ postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ url.Values{"csrf": {csrf}, "version": {"v1"}, "channel": {"stable"}, "new_channel": {"beta"}})
+ if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v1" {
+ t.Fatalf("beta pointer = %+v, want v1", p)
+ }
+ if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Promoted v1 to beta.") {
+ t.Fatal("no beta promote flash")
+ }
+
+ // Unshipped version: refused, pointer unmoved.
+ postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ url.Values{"csrf": {csrf}, "version": {"v9"}, "channel": {"stable"}})
+ if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "not a shipped release") {
+ t.Fatal("no bad-version flash")
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
+ t.Fatalf("stable moved to %s on a refused promote", p.Version)
+ }
+
+ // Invalid channel name refused.
+ postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ url.Values{"csrf": {csrf}, "version": {"v1"}, "new_channel": {"bad//name"}})
+ if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Invalid channel name.") {
+ t.Fatal("no invalid-channel flash")
+ }
+
+ // CSRF enforced; non-owners see the same 404 as everyone else.
+ if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ url.Values{"csrf": {"wrong"}, "version": {"v1"}, "channel": {"stable"}}); status != http.StatusForbidden {
+ t.Fatalf("bad csrf promote = %d, want 403", status)
+ }
+ fx.setUser("bob@keymail.dev")
+ cb := newBrowser(t)
+ signUp(t, fx, cb, "code-bob-p")
+ if status, _, _ := postForm(t, cb, fx.ts.URL+"/apps/kass/promote",
+ url.Values{"csrf": {sessionToken(t, fx, cb)}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusNotFound {
+ t.Fatalf("non-owner promote = %d, want 404", status)
+ }
+}
+
+func TestRollbackFlow(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // Fixture stable is v1 with no history: no rollback button for it (nor
+ // for the single-promote canary), and a forced POST politely refuses.
+ _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ if strings.Contains(body, `name="channel" value="stable"`) || strings.Contains(body, `name="channel" value="canary/x"`) {
+ t.Fatal("rollback button rendered for a channel with no history")
+ }
+ postForm(t, c, fx.ts.URL+"/apps/kass/rollback", url.Values{"csrf": {csrf}, "channel": {"canary/x"}})
+ if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "no previous version") {
+ t.Fatal("no empty-history rollback flash")
+ }
+
+ // Promote v2 over v1, then roll back: pointer returns to v1 and v2
+ // becomes the (re-)rollback target.
+ postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}})
+ if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, `name="channel" value="stable"`) {
+ t.Fatal("no rollback button after stable gained history")
+ }
+ status, loc, _ := postForm(t, c, fx.ts.URL+"/apps/kass/rollback",
+ url.Values{"csrf": {csrf}, "channel": {"stable"}})
+ if status != http.StatusSeeOther || loc != "/apps/kass" {
+ t.Fatalf("rollback = %d -> %q", status, loc)
+ }
+ p := fx.channelVersion(t, "kass", "stable")
+ if p.Version != "v1" || len(p.History) == 0 || p.History[0] != "v2" {
+ t.Fatalf("after rollback stable = %+v, want v1 with v2 first in history", p)
+ }
+ if _, _, body := get(t, c, fx.ts.URL+"/apps/kass"); !strings.Contains(body, "Rolled back stable to v1.") {
+ t.Fatal("no rollback flash")
+ }
+}
+
+func TestPromotionsDisabledWithoutKey(t *testing.T) {
+ fx := newConsoleFixtureKeyed(t, false)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ _, _, body := get(t, c, fx.ts.URL+"/apps/kass")
+ if !strings.Contains(body, "Promotions disabled: no signing key on this host.") {
+ t.Fatal("keyless console is missing the disabled note")
+ }
+ if strings.Contains(body, `action="/apps/kass/promote"`) || strings.Contains(body, `action="/apps/kass/rollback"`) {
+ t.Fatal("keyless console rendered mutation forms")
+ }
+ if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/promote",
+ url.Values{"csrf": {csrf}, "version": {"v2"}, "channel": {"stable"}}); status != http.StatusServiceUnavailable {
+ t.Fatalf("keyless promote = %d, want 503", status)
+ }
+ if status, _, _ := postForm(t, c, fx.ts.URL+"/apps/kass/rollback",
+ url.Values{"csrf": {csrf}, "channel": {"stable"}}); status != http.StatusServiceUnavailable {
+ t.Fatalf("keyless rollback = %d, want 503", status)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
+ t.Fatalf("keyless console moved a pointer to %s", p.Version)
+ }
+}
diff --git a/internal/console/store.go b/internal/console/store.go
index cd7211f89..6cbfcbe2f 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -112,6 +112,11 @@ func (s *Store) migrate() error {
name TEXT PRIMARY KEY,
pkcs8 TEXT NOT NULL,
created_at INTEGER NOT NULL
+);`,
+ `CREATE TABLE IF NOT EXISTS apps (
+ app TEXT PRIMARY KEY,
+ created_by TEXT NOT NULL,
+ created_at INTEGER NOT NULL
);`,
} {
if _, err := s.db.Exec(stmt); err != nil {
@@ -179,6 +184,35 @@ func (s *Store) Owners() (map[string][]string, error) {
return out, rows.Err()
}
+// ---- apps (claim-once names) ------------------------------------------------
+
+// CreateApp claims an app name, once. created is false when the name was
+// already claimed — the caller treats that exactly like every other "taken".
+func (s *Store) CreateApp(app, createdBy string, now time.Time) (created bool, err error) {
+ res, err := s.db.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES (?, ?, ?)
+ON CONFLICT(app) DO NOTHING`, app, createdBy, now.Unix())
+ if err != nil {
+ return false, err
+ }
+ n, err := res.RowsAffected()
+ return n > 0, err
+}
+
+// HasApp reports whether an app name is claimed in the apps table.
+func (s *Store) HasApp(app string) (bool, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE app = ?`, app).Scan(&n)
+ return n > 0, err
+}
+
+// HasOwners reports whether ANY owner exists for app — CLI-granted apps
+// predate the apps table and must stay claim-proof.
+func (s *Store) HasOwners(app string) (bool, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM owners WHERE app = ?`, app).Scan(&n)
+ return n > 0, err
+}
+
// ---- users ------------------------------------------------------------------
// EnsureUser records address as a console user if it isn't one yet. created
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
index f91d4726e..6ec1a8e0d 100644
--- a/internal/console/store_test.go
+++ b/internal/console/store_test.go
@@ -226,3 +226,32 @@ func TestReopenMigrationIdempotent(t *testing.T) {
t.Fatalf("after reopen AppsFor = %v %v", apps, err)
}
}
+
+func TestCreateAppClaimOnce(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Now()
+ created, err := s.CreateApp("zephyr", "paul@keymail.dev", now)
+ if err != nil || !created {
+ t.Fatalf("first CreateApp = %v %v, want true nil", created, err)
+ }
+ created, err = s.CreateApp("zephyr", "bob@keymail.dev", now)
+ if err != nil || created {
+ t.Fatalf("second CreateApp = %v %v, want false nil", created, err)
+ }
+ if has, _ := s.HasApp("zephyr"); !has {
+ t.Fatal("HasApp(zephyr) = false after claim")
+ }
+ if has, _ := s.HasApp("ghost"); has {
+ t.Fatal("HasApp(ghost) = true")
+ }
+ // HasOwners sees CLI-style grants independently of the apps table.
+ if has, _ := s.HasOwners("kass"); has {
+ t.Fatal("HasOwners before any grant")
+ }
+ if err := s.Grant("paul@keymail.dev", "kass"); err != nil {
+ t.Fatal(err)
+ }
+ if has, _ := s.HasOwners("kass"); !has {
+ t.Fatal("HasOwners(kass) = false after grant")
+ }
+}
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html
index 4c848dae3..f344b7a49 100644
--- a/internal/console/templates/app.html
+++ b/internal/console/templates/app.html
@@ -1,5 +1,7 @@
{{define "app"}}{{template "top" .}}
<h1>{{.Detail.App}}</h1>
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
<h2>Routes</h2>
{{if .Detail.Routes}}
@@ -24,7 +26,7 @@
{{if .Detail.Channels}}
<div class="tablewrap">
<table>
- <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th></tr>
+ <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th><th></th></tr>
{{range .Detail.Channels}}
<tr>
<td>{{.Name}}</td>
@@ -35,10 +37,54 @@
<td>{{fmtTime .PromotedAt}}</td>
<td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}</td>
{{end}}
+ <td>{{if and $.CanPromote .HasHistory (not .Err)}}
+ <form method="post" action="/apps/{{$.Detail.App}}/rollback">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="channel" value="{{.Name}}">
+ <button type="submit" class="linkish">Roll back</button>
+ </form>
+ {{end}}</td>
</tr>
{{end}}
</table>
</div>
-<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record.</p>
+<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p>
{{else}}<p class="small">No channels yet.</p>{{end}}
+
+<h2>Releases</h2>
+{{if .Versions}}
+<div class="tablewrap">
+<table>
+ <tr><th>Version</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
+ {{range .Versions}}
+ <tr>
+ <td><code>{{.Version}}</code></td>
+ {{if .Err}}
+ <td colspan="2" class="error">{{.Err}}</td>
+ {{else}}
+ <td>{{.Kind}}</td>
+ <td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td>
+ {{end}}
+ {{if $.CanPromote}}<td>
+ <form method="post" action="/apps/{{$.Detail.App}}/promote">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="version" value="{{.Version}}">
+ <select name="channel">
+ {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
+ </select>
+ <input type="text" name="new_channel" placeholder="or a new channel" size="14">
+ <button type="submit" class="linkish">Promote</button>
+ </form>
+ </td>{{end}}
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}
+<p>Nothing shipped yet. Ship your first release from your repo:</p>
+<pre><code>carlos ship -app {{.Detail.App}} -version $(git rev-parse --short HEAD) -target linux-arm64 &lt;binary&gt;
+carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code></pre>
+<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p>
+{{end}}
+{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
index a8cb6ad39..9c6cf08e2 100644
--- a/internal/console/templates/apps.html
+++ b/internal/console/templates/apps.html
@@ -1,5 +1,7 @@
{{define "apps"}}{{template "top" .}}
{{if .Welcome}}<p class="banner">Account created — welcome! You signed up as <strong>{{.Address}}</strong>.</p>{{end}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{if .Apps}}
<h1>Your apps</h1>
<div class="tablewrap">
@@ -19,7 +21,13 @@
</div>
{{else}}
<h1>Welcome to the CARLOS platform</h1>
-<p>Your account <strong>{{.Address}}</strong> is ready. No apps live here yet — creating apps from the console is coming soon.</p>
-<p class="small">When an app is granted to this address, it appears right here with its live routes and release channels.</p>
+<p>Your account <strong>{{.Address}}</strong> is ready. No apps live here yet — claim a name below and it's yours.</p>
{{end}}
+<h2>Create an app</h2>
+<form class="signin" method="post" action="/apps/new">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="name" placeholder="my-app">
+ <button type="submit">Create app</button>
+</form>
+<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — claimed once, yours forever.</p>
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index b6968d4b3..1990cb678 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -29,6 +29,17 @@
font-size: 0.88em; background: var(--accent-soft);
padding: 0.1em 0.35em; border-radius: 0.25rem;
}
+ pre {
+ background: var(--accent-soft); border-radius: 0.5rem;
+ padding: 0.9rem 1.1rem; margin: 0 0 1rem; max-width: 40rem;
+ overflow-x: auto; font-size: 0.85rem; line-height: 1.6;
+ }
+ pre code { background: none; padding: 0; }
+ select, input[type=text] {
+ font: inherit; font-size: 0.9rem; color: var(--ink); background: var(--bg);
+ border: 1px solid var(--rule); border-radius: 0.5rem; padding: 0.3rem 0.5rem;
+ }
+ td form { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin: 0; }
header {
max-width: 56rem; margin: 0 auto; padding: 1.4rem 0;
display: flex; align-items: baseline; gap: 1rem;
@@ -60,11 +71,7 @@
}
th, td { padding: 0.5rem 1rem 0.5rem 0; border-bottom: 1px solid var(--rule); }
form.signin { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 1rem; }
- input[type=text] {
- font: inherit; color: var(--ink); background: var(--bg);
- border: 1px solid var(--rule); border-radius: 0.5rem;
- padding: 0.55rem 0.8rem; min-width: 16rem;
- }
+ form.signin input[type=text] { padding: 0.55rem 0.8rem; min-width: 16rem; }
button, .btn {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 0.95rem; font-weight: 600; background: var(--accent);
diff --git a/internal/console/view.go b/internal/console/view.go
index 21a9eb318..11666639c 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -39,11 +39,21 @@ type ChannelView struct {
// clock is each box's own first-seen record (release/bake.go), which the
// console cannot see, and per-deployment hold overrides are not read here.
BakedUntil time.Time
+ // HasHistory is whether the pointer records previous versions — i.e.
+ // whether a rollback has somewhere to go.
+ HasHistory bool
// Err degrades a missing or invalid channel pointer to one row's error
// text instead of a failed page.
Err string
}
+// VersionView is one shipped release on the app page.
+type VersionView struct {
+ Version, Kind, BuiltAt string
+ // Err degrades an unreadable manifest to one row's error text.
+ Err string
+}
+
// registry.Registry has no Close, so opening per page would leak a handle
// each request; one cached handle per path instead. Freshness is not
// sacrificed: Registry.List reads over a fresh connection every call.
@@ -133,7 +143,10 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe
out = append(out, ChannelView{Name: name, Err: err.Error()})
continue
}
- cv := ChannelView{Name: name, Version: p.Version, PromotedAt: p.PromotedAt}
+ cv := ChannelView{
+ Name: name, Version: p.Version, PromotedAt: p.PromotedAt,
+ HasHistory: len(p.History) > 0,
+ }
if hold := release.HoldFor(release.DefaultHolds, name); hold > 0 {
if until := p.PromotedAt.Add(hold); now.Before(until) {
cv.BakedUntil = until
@@ -143,3 +156,49 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe
}
return out
}
+
+// versionsCap bounds the app page's release list.
+const versionsCap = 20
+
+// Versions lists an app's shipped releases — the distinct version dirs under
+// apps/<app>/releases/ with each manifest's kind and build stamp — newest
+// first by built_at (best effort: RFC3339 parses win, then version string),
+// capped at versionsCap. Unreadable manifests degrade to a row error.
+func Versions(s release.Store, app string) ([]VersionView, error) {
+ ctx := context.Background()
+ prefix := "apps/" + app + "/releases/"
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ seen := map[string]bool{}
+ var out []VersionView
+ for _, k := range keys {
+ v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/")
+ if !ok || v == "" || seen[v] {
+ continue
+ }
+ seen[v] = true
+ m, err := release.GetManifest(ctx, s, app, v)
+ if err != nil {
+ out = append(out, VersionView{Version: v, Err: err.Error()})
+ continue
+ }
+ out = append(out, VersionView{Version: v, Kind: m.Kind, BuiltAt: m.BuiltAt})
+ }
+ parse := func(s string) time.Time {
+ t, _ := time.Parse(time.RFC3339, s)
+ return t
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ ti, tj := parse(out[i].BuiltAt), parse(out[j].BuiltAt)
+ if !ti.Equal(tj) {
+ return ti.After(tj)
+ }
+ return out[i].Version > out[j].Version
+ })
+ if len(out) > versionsCap {
+ out = out[:versionsCap]
+ }
+ return out, nil
+}
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index 4b1e3d451..ae421743b 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -14,7 +14,7 @@ import (
// buildFixtures makes a little platform: a registry with routes for apps
// kass (ready instance on stable) and otherapp, and a deployment bucket with
// kass v1 promoted to stable and v2 to canary/x. Shared with server_test.go.
-func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed25519.PublicKey) {
+func buildFixtures(t *testing.T) (regPath string, store release.Store, priv ed25519.PrivateKey, pub ed25519.PublicKey) {
t.Helper()
dir := t.TempDir()
regPath = filepath.Join(dir, "registry.db")
@@ -40,7 +40,7 @@ func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed255
if err != nil {
t.Fatal(err)
}
- priv, err := release.ParseSigningKey(seed, "test")
+ priv, err = release.ParseSigningKey(seed, "test")
if err != nil {
t.Fatal(err)
}
@@ -49,9 +49,13 @@ func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed255
t.Fatal(err)
}
ctx := context.Background()
+ builtAt := map[string]string{
+ "v1": "2026-07-01T10:00:00Z",
+ "v2": "2026-07-02T10:00:00Z",
+ }
for _, v := range []string{"v1", "v2"} {
m := release.Manifest{
- Schema: 1, App: "kass", Version: v, Kind: "binary",
+ Schema: 1, App: "kass", Version: v, Kind: "binary", BuiltAt: builtAt[v],
Artifacts: map[string]release.Artifact{"linux-amd64": {File: "kass"}},
}
if err := release.Ship(ctx, store, m, map[string][]byte{"linux-amd64": []byte("binary " + v)}); err != nil {
@@ -64,7 +68,7 @@ func buildFixtures(t *testing.T) (regPath string, store release.Store, pub ed255
if err := release.Promote(ctx, store, priv, "kass", "canary/x", "v2"); err != nil {
t.Fatal(err)
}
- return regPath, store, pub
+ return regPath, store, priv, pub
}
func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView {
@@ -79,7 +83,7 @@ func findChannel(t *testing.T, chans []ChannelView, name string) ChannelView {
}
func TestAssemble(t *testing.T) {
- regPath, store, pub := buildFixtures(t)
+ regPath, store, _, pub := buildFixtures(t)
views, err := Assemble(regPath, store, pub, []string{"kass"})
if err != nil {
t.Fatal(err)
@@ -126,7 +130,7 @@ func TestAssemble(t *testing.T) {
}
func TestAssembleFiltersToGivenApps(t *testing.T) {
- regPath, store, pub := buildFixtures(t)
+ regPath, store, _, pub := buildFixtures(t)
views, err := Assemble(regPath, store, pub, []string{"kass"})
if err != nil {
t.Fatal(err)
@@ -144,7 +148,7 @@ func TestAssembleFiltersToGivenApps(t *testing.T) {
}
func TestAssembleEmptyAppStillListed(t *testing.T) {
- regPath, store, pub := buildFixtures(t)
+ regPath, store, _, pub := buildFixtures(t)
views, err := Assemble(regPath, store, pub, []string{"ghost", "kass"})
if err != nil {
t.Fatal(err)
@@ -158,7 +162,7 @@ func TestAssembleEmptyAppStillListed(t *testing.T) {
}
func TestAssembleDegradesBadChannel(t *testing.T) {
- regPath, store, pub := buildFixtures(t)
+ regPath, store, _, pub := buildFixtures(t)
// A garbage pointer must become one row's error, never a failed page.
if _, err := store.Put(context.Background(), "apps/kass/channels/beta.json", []byte("not a signed pointer"), ""); err != nil {
t.Fatal(err)
@@ -175,3 +179,48 @@ func TestAssembleDegradesBadChannel(t *testing.T) {
t.Fatalf("healthy channel degraded too: %+v", stable)
}
}
+
+func TestVersionsNewestFirst(t *testing.T) {
+ _, store, _, _ := buildFixtures(t)
+ vs, err := Versions(store, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(vs) != 2 {
+ t.Fatalf("versions = %+v, want 2", vs)
+ }
+ if vs[0].Version != "v2" || vs[1].Version != "v1" {
+ t.Fatalf("order = %s, %s — want v2 (newer built_at) first", vs[0].Version, vs[1].Version)
+ }
+ if vs[0].Kind != "binary" || vs[0].BuiltAt != "2026-07-02T10:00:00Z" || vs[0].Err != "" {
+ t.Fatalf("v2 = %+v", vs[0])
+ }
+}
+
+func TestVersionsDegradesBadManifest(t *testing.T) {
+ _, store, _, _ := buildFixtures(t)
+ if _, err := store.Put(context.Background(), "apps/kass/releases/v3/manifest.json", []byte("junk"), ""); err != nil {
+ t.Fatal(err)
+ }
+ vs, err := Versions(store, "kass")
+ if err != nil {
+ t.Fatalf("Versions must not fail on one bad manifest: %v", err)
+ }
+ var v3 *VersionView
+ for i := range vs {
+ if vs[i].Version == "v3" {
+ v3 = &vs[i]
+ }
+ }
+ if v3 == nil || v3.Err == "" {
+ t.Fatalf("versions = %+v, want a degraded v3 row", vs)
+ }
+}
+
+func TestVersionsEmptyApp(t *testing.T) {
+ _, store, _, _ := buildFixtures(t)
+ vs, err := Versions(store, "ghost")
+ if err != nil || len(vs) != 0 {
+ t.Fatalf("Versions(ghost) = %+v %v, want empty nil", vs, err)
+ }
+}