carlos / platform Public

Merge origin/main into console-ux-01-status

Paul Campbell pushed by paul@keymail.dev 6157e34307a3e5f14058988ece3fb1a74bcc812b parent b4a7d80parent f33f340
50 files changed, +1411 −317
  • apimode.go +7 −4
  • canary.go +1 −1
  • instances_cli.go +1 −1
  • instances_cli_test.go +20 −0
  • internal/console/accountreview.go +99 −0
  • internal/console/accountreview_test.go +283 −0
  • internal/console/activity.go +45 −0
  • internal/console/activity_test.go +86 −0
  • internal/console/analyticsview.go +1 −1
  • internal/console/apicli.go +45 −6
  • internal/console/apiconfig.go +9 −2
  • internal/console/apidomains.go +10 −2
  • internal/console/apidomains_test.go +40 −1
  • internal/console/apifeatures.go +11 −3
  • internal/console/apifeatures_test.go +11 −7
  • internal/console/apiinstances.go +17 −2
  • internal/console/apiinstances_test.go +20 −1
  • internal/console/apirestartscope.go +16 −1
  • internal/console/apirestartscope_test.go +31 −0
  • internal/console/appdomains.go +8 −3
  • internal/console/appdomains_test.go +4 −4
  • internal/console/appsettings.go +83 −47
  • internal/console/appsettings_gate_test.go +4 −4
  • internal/console/appsettings_sections_test.go +120 −0
  • internal/console/appsettings_test.go +33 −29
  • internal/console/channelmove.go +3 −3
  • internal/console/controlplane.go +3 −4
  • internal/console/controlplane_test.go +12 −12
  • internal/console/domainattach.go +17 −4
  • internal/console/errorsview.go +1 −1
  • internal/console/mismatchcopy.go +23 −0
  • internal/console/overview_test.go +5 −2
  • internal/console/pendinghandlers.go +4 −4
  • internal/console/pipelinepage.go +7 −2
  • internal/console/server.go +130 −6
  • internal/console/server_account_test.go +5 −8
  • internal/console/server_test.go +9 −6
  • internal/console/statuscopy_test.go +1 −1
  • internal/console/store.go +32 −9
  • internal/console/store_test.go +25 −0
  • internal/console/templates/account_review.html +24 −0
  • internal/console/templates/app_activity.html +6 −3
  • internal/console/templates/app_overview.html +5 −24
  • internal/console/templates/app_releases.html +4 −23
  • internal/console/templates/app_settings.html +41 −7
  • internal/console/templates/layout.html +21 −0
  • internal/console/templates/pipeline.html +7 −0
  • internal/console/view.go +14 −66
  • internal/console/view_test.go +6 −12
  • mail_cli.go +1 −1
diff --git a/apimode.go b/apimode.go
index bb2eb6675..abe737731 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2562,7 +2562,7 @@ func (s *apiSession) pendingList(ctx context.Context, a apiAcct, app string) ([]
// interpreting a non-200 as an error itself — getJSON's plumbing with the
// method swapped, for the instance-record retire door (issue #66), the one
// DELETE this client speaks.
-func (s *apiSession) deleteJSON(ctx context.Context, path string) (int, []byte, error) {
+func (s *apiSession) deleteJSON(ctx context.Context, path, message string) (int, []byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.c.base+path, nil)
if err != nil {
return 0, nil, err
@@ -2570,6 +2570,9 @@ func (s *apiSession) deleteJSON(ctx context.Context, path string) (int, []byte,
if s.c.token != "" {
req.Header.Set("Authorization", "Bearer "+s.c.token)
}
+ if message != "" {
+ req.Header.Set("X-Carlos-Message", message)
+ }
res, err := s.c.httpc.Do(req)
if err != nil {
return 0, nil, err
@@ -3157,8 +3160,8 @@ func (s *apiSession) telemetryPut(ctx context.Context, a apiAcct, app string, bo
// instanceDelete retires one instance record. Idempotent server-side (the
// caller's goal is "not there"), so a 200 is the only success shape.
-func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host string) error {
- status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)))
+func (s *apiSession) instanceDelete(ctx context.Context, a apiAcct, app, host, message string) error {
+ status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), message)
if err != nil {
return err
}
@@ -3295,7 +3298,7 @@ func (s *apiSession) domainAttach(ctx context.Context, a apiAcct, app, host, rou
// holds — the door's own quiet-in-both-directions neutrality (spec §5), not
// distinguished here either.
func (s *apiSession) domainDetach(ctx context.Context, a apiAcct, app, host string) (removed bool, err error) {
- status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host)))
+ status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/domains/"+url.PathEscape(host)), "")
if err != nil {
return false, err
}
diff --git a/canary.go b/canary.go
index 3e749c707..8a33eaaa6 100644
--- a/canary.go
+++ b/canary.go
@@ -580,7 +580,7 @@ func runCanaryRemove(args []string) {
if !ok {
log.Fatalf("carlos canary rm: %s has no canary named %q — carlos canary ls --app %s lists them", app2, canary, app2)
}
- if err := sess.instanceDelete(ctx, acct, app2, rec.Host); err != nil {
+ if err := sess.instanceDelete(ctx, acct, app2, rec.Host, ""); err != nil {
log.Fatalf("canary rm: delete %s: %v", rec.Host, err)
}
fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("removed"), rec.Host)
diff --git a/instances_cli.go b/instances_cli.go
index b5341dace..12dc48b93 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -404,7 +404,7 @@ func runInstancesDelete(args []string) {
printUsing(stdout, sess)
acct := resolvedAccount(sess, acctRaw, acctIDRaw)
app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage)
- if err := sess.instanceDelete(context.Background(), acct, app2, strings.TrimSpace(*host)); err != nil {
+ if err := sess.instanceDelete(context.Background(), acct, app2, strings.TrimSpace(*host), ""); err != nil {
log.Fatalf("instances delete: %v", err)
}
fmt.Fprintf(stdout, "%s %s — its route retires on the next reconcile pass; parked data stays\n",
diff --git a/instances_cli_test.go b/instances_cli_test.go
index 9a3c80ccf..395408395 100644
--- a/instances_cli_test.go
+++ b/instances_cli_test.go
@@ -9,12 +9,32 @@ package main
import (
"context"
+ "net/http"
+ "net/http/httptest"
"testing"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
+func TestInstanceDeleteCarriesActivityMessageHeader(t *testing.T) {
+ var gotMessage string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMessage = r.Header.Get("X-Carlos-Message")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer ts.Close()
+
+ err := newTestSession(ts.URL).instanceDelete(context.Background(), apiAcct{Seg: "bab"},
+ "titogo", "web.example.test", "retire the old web copy")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gotMessage != "retire the old web copy" {
+ t.Fatalf("message header = %q", gotMessage)
+ }
+}
+
func TestInstancesEnableWritesManifest(t *testing.T) {
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
diff --git a/internal/console/accountreview.go b/internal/console/accountreview.go
new file mode 100644
index 000000000..81af04eac
--- /dev/null
+++ b/internal/console/accountreview.go
@@ -0,0 +1,99 @@
+package console
+
+import (
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type AccountReviewGroup struct {
+ Account Account
+ Hosts []string
+}
+
+type AccountReview struct {
+ Groups []AccountReviewGroup
+ Count int
+ NoInstances bool
+ Owned int
+ blocked bool
+}
+
+func (a AccountReview) SummaryText() string {
+ return strings.ReplaceAll(approvedMismatchCopy.Summary, "{count}", strconv.Itoa(a.Count))
+}
+
+// appAccountReview returns only candidates whose source account the viewer
+// can access. A source with its own same-named app is a legitimate sibling,
+// not evidence that the viewed app is half-migrated.
+func (s *Server) appAccountReview(address string, viewed *Account, app string) (AccountReview, bool, error) {
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ accounts, err := s.store.AccountsFor(address)
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ accessible := make(map[string]Account, len(accounts))
+ for _, account := range accounts {
+ accessible[account.Sqid] = account
+ }
+ groups := make(map[string][]string)
+ owned, hidden, sibling := 0, false, false
+ for _, rt := range routes {
+ if rt.App != app || rt.Platform {
+ continue
+ }
+ source := release.RouteAccount(rt)
+ if source == viewed.Sqid {
+ owned++
+ continue
+ }
+ account, ok := accessible[source]
+ if !ok {
+ hidden = true
+ continue
+ }
+ has, err := s.store.HasAppIn(account.ID, app)
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ if !has {
+ has, err = s.store.HasTrashedAppIn(account.ID, app)
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ }
+ if has {
+ sibling = true
+ continue
+ }
+ groups[source] = append(groups[source], rt.Host)
+ }
+ var review AccountReview
+ review.Owned = owned
+ review.blocked = hidden || sibling
+ for source, hosts := range groups {
+ sort.Strings(hosts)
+ review.Groups = append(review.Groups, AccountReviewGroup{Account: accessible[source], Hosts: hosts})
+ review.Count += len(hosts)
+ }
+ sort.Slice(review.Groups, func(i, j int) bool { return review.Groups[i].Account.Sqid < review.Groups[j].Account.Sqid })
+ return review, review.Count > 0 && owned == 0 && !hidden && !sibling && len(review.Groups) == 1, nil
+}
+
+func (s *Server) handleAppAccountReview(w http.ResponseWriter, r *http.Request) {
+ p, _, ok := s.appPage(w, r, "overview")
+ if !ok {
+ return
+ }
+ s.render(w, http.StatusOK, "app_account_review", p)
+}
diff --git a/internal/console/accountreview_test.go b/internal/console/accountreview_test.go
new file mode 100644
index 000000000..67e5493e0
--- /dev/null
+++ b/internal/console/accountreview_test.go
@@ -0,0 +1,283 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAccountReviewExcludesLegitimateSameNamedApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const otherID = 4242
+ if err := fx.st.CreateAccount(otherID, "cdef", "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(otherID, "kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "other.example", App: "kass", Account: "cdef", Kind: "instance"})
+ viewed, err := fx.st.AccountBySqid("bab")
+ if err != nil || viewed == nil {
+ t.Fatal(err)
+ }
+ review, all, err := fx.srv.appAccountReview("paul@keymail.dev", viewed, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if review.Count != 0 || all {
+ t.Fatalf("legitimate sibling became a repair candidate: %+v all=%v", review, all)
+ }
+}
+
+func TestAccountReviewSiblingPreventsUnrelatedMove(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ for _, tc := range []struct {
+ id int64
+ sqid string
+ name string
+ app bool
+ }{{4242, "cdef", "Sibling", true}, {4343, "cghi", "Candidate", false}} {
+ if err := fx.st.CreateAccount(tc.id, tc.sqid, tc.name, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(tc.id, "paul@keymail.dev", RoleOwner, now); err != nil {
+ t.Fatal(err)
+ }
+ if tc.app {
+ if _, err := fx.st.CreateAppIn(tc.id, "kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ fx.addRoute(t, registry.Route{Host: "sibling.example", App: "kass", Account: "cdef", Kind: "instance"})
+ fx.addRoute(t, registry.Route{Host: "candidate.example", App: "kass", Account: "cghi", Kind: "instance"})
+ viewed, _ := fx.st.AccountBySqid("bab")
+ review, all, err := fx.srv.appAccountReview("paul@keymail.dev", viewed, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if review.Count != 1 || all {
+ t.Fatalf("review = %+v all=%v; sibling must prevent move authority", review, all)
+ }
+ if _, agreed, err := fx.srv.routeAccountFor("paul@keymail.dev", viewed, "kass"); !errors.Is(err, errMoveTargetUnavailable) || agreed {
+ t.Fatalf("routeAccountFor agreed=%v err=%v; same-named sibling must hide the move target", agreed, err)
+ }
+}
+
+func TestAccountReviewHidesInaccessibleSource(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: "secret.example", App: "kass", Account: "unknown", Kind: "instance"})
+ viewed, _ := fx.st.AccountBySqid("bab")
+ review, all, err := fx.srv.appAccountReview("paul@keymail.dev", viewed, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if review.Count != 0 || all {
+ t.Fatalf("inaccessible source disclosed: %+v all=%v", review, all)
+ }
+ if _, agreed, err := fx.srv.routeAccountFor("paul@keymail.dev", viewed, "kass"); !errors.Is(err, errMoveTargetUnavailable) || agreed {
+ t.Fatalf("routeAccountFor agreed=%v err=%v; an inaccessible source must hide the move target", agreed, err)
+ }
+}
+
+func TestAccountReviewTreatsTrashedSameNamedAppAsSibling(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const otherID = 4242
+ if err := fx.st.CreateAccount(otherID, "cdef", "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(otherID, "kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.TrashAppRow(otherID, "kass", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "trashed-sibling.example", App: "kass", Account: "cdef", Kind: "instance"})
+ viewed, _ := fx.st.AccountBySqid("bab")
+ review, all, err := fx.srv.appAccountReview("paul@keymail.dev", viewed, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if review.Count != 0 || all {
+ t.Fatalf("trashed sibling became a move candidate: %+v all=%v", review, all)
+ }
+ if _, agreed, err := fx.srv.routeAccountFor("paul@keymail.dev", viewed, "kass"); !errors.Is(err, errMoveTargetUnavailable) || agreed {
+ t.Fatalf("routeAccountFor agreed=%v err=%v; trashed sibling must hide the move target", agreed, err)
+ }
+}
+
+func TestAccountReviewPageShowsOwnedCandidatesAndHidesFromNonMembers(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const otherID = 4242
+ if err := fx.st.CreateAccount(otherID, "cdef", "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "candidate.example", App: "kass", Account: "cdef", Kind: "instance"})
+
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/account-review"))
+ if status != http.StatusOK || !strings.Contains(body, "candidate.example") || !strings.Contains(body, "Other") ||
+ !strings.Contains(body, approvedMismatchCopy.Partial) {
+ t.Fatalf("account review = %d, missing candidate/account/partial copy:\n%s", status, body)
+ }
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ routes, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, route := range routes {
+ if route.App == "kass" && !route.Platform {
+ if err := reg.SetAccount(route.Host, "cdef"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ reg.Close()
+ status, _, body = get(t, c, fx.u("/apps/kass/account-review"))
+ if status != http.StatusOK || !strings.Contains(body, approvedMismatchCopy.Repairable) ||
+ strings.Contains(body, approvedMismatchCopy.Partial) {
+ t.Fatalf("fully mismatched review = %d, missing repairable branch:\n%s", status, body)
+ }
+
+ fx.setUser("eve@keymail.dev")
+ stranger := newBrowser(t)
+ signUp(t, fx, stranger, "code-account-review-stranger")
+ strangerCSRF := sessionToken(t, fx, stranger)
+ strangerStatus, _, strangerBody := get(t, stranger, fx.u("/apps/kass/account-review"))
+ ghostStatus, _, ghostBody := get(t, stranger, fx.ts.URL+"/definitely-not-a-page")
+ if strangerStatus != http.StatusNotFound || strangerStatus != ghostStatus || strangerBody != ghostBody {
+ t.Fatalf("non-member review = %d %q; unrouted = %d %q", strangerStatus, strangerBody, ghostStatus, ghostBody)
+ }
+ moveStatus, _, moveBody := postForm(t, stranger, fx.u("/apps/kass/move"), url.Values{"csrf": {strangerCSRF}})
+ if moveStatus != http.StatusNotFound || moveBody != ghostBody {
+ t.Fatalf("non-member move = %d %q; unrouted = %d %q", moveStatus, moveBody, ghostStatus, ghostBody)
+ }
+}
+
+func TestMoveRefusesHiddenOrSiblingSources(t *testing.T) {
+ for _, tc := range []struct {
+ name, account string
+ sibling bool
+ }{
+ {name: "hidden", account: "unknown"},
+ {name: "sibling", account: "cdef", sibling: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if tc.sibling {
+ const otherID = 4242
+ if err := fx.st.CreateAccount(otherID, tc.account, "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(otherID, "kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ fx.addRoute(t, registry.Route{Host: tc.name + ".example", App: "kass", Account: tc.account, Kind: "instance"})
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, _, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}})
+ ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
+ t.Fatalf("unavailable move = %d %q; unrouted = %d %q", status, body, ghostStatus, ghostBody)
+ }
+ accounts, err := fx.st.AppAccounts("kass")
+ if err != nil || len(accounts) == 0 || accounts[0] != OpsAccountID {
+ t.Fatalf("AppAccounts after refusal = %v, %v", accounts, err)
+ }
+ })
+ }
+}
+
+func TestPublishedAppWithoutOwnedInstancesWarnsFromOwnedStateOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "empty", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+
+ _, _, empty := get(t, c, fx.u("/apps/empty"))
+ if strings.Contains(empty, `<p class="banner">`+approvedMismatchCopy.NoInstances) {
+ t.Fatalf("an unshipped empty app shows the published-app warning:\n%s", empty)
+ }
+
+ manifest := release.Manifest{
+ Schema: 1, App: "empty", Version: "v1", Kind: "binary", BuiltAt: time.Now().UTC().Format(time.RFC3339),
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "empty"}},
+ }
+ if err := release.Ship(context.Background(), fx.rel, "bab", manifest,
+ map[string][]byte{"linux-amd64": []byte("binary")}); err != nil {
+ t.Fatal(err)
+ }
+ seedChannel(t, fx, "empty", "stable", "v1")
+ _, _, absentForeign := get(t, c, fx.u("/apps/empty"))
+ if !strings.Contains(absentForeign, `<p class="banner">`+approvedMismatchCopy.NoInstances) ||
+ !strings.Contains(absentForeign, `/apps/empty/instances">Instances</a>`) {
+ t.Fatalf("published app with no owned instances lacks the warning and Instances link:\n%s", absentForeign)
+ }
+ _, _, settings := get(t, c, fx.u("/apps/empty/settings"))
+ if !strings.Contains(settings, approvedMismatchCopy.NoInstances) || strings.Contains(settings, "This app deploys from") {
+ t.Fatalf("settings reassures a published app with no owned instances:\n%s", settings)
+ }
+
+ fx.addRoute(t, registry.Route{Host: "hidden.example", App: "empty", Account: "unknown", Kind: "instance"})
+ _, _, hiddenForeign := get(t, c, fx.u("/apps/empty"))
+ if hiddenForeign != absentForeign {
+ t.Fatal("a hidden foreign route changed the owned app warning render")
+ }
+
+ fx.addRoute(t, registry.Route{Host: "static.example", App: "empty", Account: "bab", Kind: "static"})
+ _, _, ownedStatic := get(t, c, fx.u("/apps/empty"))
+ if strings.Contains(ownedStatic, approvedMismatchCopy.NoInstances) {
+ t.Fatalf("an owned static route still shows the no-instances warning:\n%s", ownedStatic)
+ }
+}
+
+func TestAccountReviewDoesNotClaimOwnedInstancesWhenABlockerLeavesNone(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const candidateID = 4242
+ if err := fx.st.CreateAccount(candidateID, "cdef", "Candidate", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(candidateID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ const siblingID = 4343
+ if err := fx.st.CreateAccount(siblingID, "cghi", "Sibling", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(siblingID, "empty", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "empty", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "candidate.example", App: "empty", Account: "cdef", Kind: "instance"})
+ fx.addRoute(t, registry.Route{Host: "sibling.example", App: "empty", Account: "cghi", Kind: "instance"})
+
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/empty/account-review"))
+ if status != http.StatusOK || !strings.Contains(body, "candidate.example") {
+ t.Fatalf("blocked review = %d, missing safe candidate:\n%s", status, body)
+ }
+ if strings.Contains(body, approvedMismatchCopy.Partial) || strings.Contains(body, "sibling.example") {
+ t.Fatalf("blocked review claims owned instances or exposes sibling:\n%s", body)
+ }
+}
diff --git a/internal/console/activity.go b/internal/console/activity.go
new file mode 100644
index 000000000..17e2ccb83
--- /dev/null
+++ b/internal/console/activity.go
@@ -0,0 +1,45 @@
+package console
+
+import (
+ "net/http"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+)
+
+const (
+ activityMessageMaxRunes = 240
+ activityMessageHeader = "X-Carlos-Message"
+)
+
+type activityCopy struct {
+ Description, Action, Target, Scope, InvalidMessage string
+}
+
+var activityText = activityCopy{
+ Description: "Description",
+ Action: "Action",
+ Target: "Target",
+ Scope: "This history includes console actions and CLI actions sent through the console.",
+ InvalidMessage: "Message must be one line and 240 characters or fewer.",
+}
+
+func validActivityMessage(message string) bool {
+ if !utf8.ValidString(message) || utf8.RuneCountInString(message) > activityMessageMaxRunes {
+ return false
+ }
+ for _, r := range message {
+ if unicode.IsControl(r) {
+ return false
+ }
+ }
+ return true
+}
+
+func checkedActivityMessage(w http.ResponseWriter, message string) (string, bool) {
+ if !validActivityMessage(message) {
+ jsonError(w, http.StatusBadRequest, activityText.InvalidMessage)
+ return "", false
+ }
+ return strings.TrimSpace(message), true
+}
diff --git a/internal/console/activity_test.go b/internal/console/activity_test.go
new file mode 100644
index 000000000..721f46a5b
--- /dev/null
+++ b/internal/console/activity_test.go
@@ -0,0 +1,86 @@
+package console
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestValidActivityMessage(t *testing.T) {
+ for _, message := range []string{"", "ship the new checkout", strings.Repeat("界", activityMessageMaxRunes)} {
+ if !validActivityMessage(message) {
+ t.Errorf("validActivityMessage(%q) = false", message)
+ }
+ }
+ for _, message := range []string{
+ strings.Repeat("x", activityMessageMaxRunes+1),
+ "two\nlines", "tab\tseparated", "nul\x00byte", string([]byte{0xff}),
+ } {
+ if validActivityMessage(message) {
+ t.Errorf("validActivityMessage(%q) = true", message)
+ }
+ }
+}
+
+func TestCheckedActivityMessageRejectsTrailingControl(t *testing.T) {
+ for _, message := range []string{"description\n", "\tdescription", "description\r"} {
+ w := httptest.NewRecorder()
+ if _, ok := checkedActivityMessage(w, message); ok || w.Code != 400 {
+ t.Errorf("accepted %q: status %d", message, w.Code)
+ }
+ }
+ if got, ok := checkedActivityMessage(httptest.NewRecorder(), " description "); !ok || got != "description" {
+ t.Fatalf("space trimming = %q, %v", got, ok)
+ }
+}
+
+func TestActivityRendersDescriptionAndCompleteAction(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ for _, event := range []Activity{
+ {Action: "promote", TargetType: "channel", Target: "edge", Message: "Fix <script>alert(1)</script>", Detail: "promoted v3 to edge (cli)"},
+ {Action: "config", Message: "Update delivery configuration", Detail: "config: 2 set, 0 unset (relay) (cli)"},
+ {Action: "ship", TargetType: "release", Target: "v3", Message: "Publish checkout fix", Detail: "shipped v3 (cli)"},
+ } {
+ if err := fx.st.RecordActivity(OpsAccountID, "kass", "paul@keymail.dev", event, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ status, _, body := get(t, c, fx.u("/apps/kass/activity"))
+ if status != 200 {
+ t.Fatalf("status = %d", status)
+ }
+ for _, want := range []string{"Fix &lt;script&gt;alert(1)&lt;/script&gt;", "promoted v3 to edge (cli)", "Update delivery configuration", "config: 2 set, 0 unset (relay) (cli)", `/apps/kass/releases#version-v3`, "shipped v3 (cli)"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("missing %q", want)
+ }
+ }
+ if strings.Contains(body, "<script>alert(1)</script>") || strings.Contains(body, "CLI promotions don") {
+ t.Fatal("activity contains unescaped message or obsolete CLI exclusion")
+ }
+}
+
+func TestActivityAppRestartBodyCompatibility(t *testing.T) {
+ for _, body := range []string{"", "{}", `{"message":"Reload delivery configuration"}`} {
+ t.Run(body, func(t *testing.T) {
+ fx, token := restartFixture(t, "activity-restart-body")
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, body)
+ if status != 200 {
+ t.Fatalf("restart = %d %s", status, out)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 10)
+ if err != nil || len(events) != 1 {
+ t.Fatalf("events = %+v, %v", events, err)
+ }
+ want := ""
+ if strings.Contains(body, "message") {
+ want = "Reload delivery configuration"
+ }
+ e := events[0]
+ if e.Action != "restart" || e.TargetType != "app" || e.Target != "titogo" || e.Message != want || e.Detail == "" {
+ t.Fatalf("event = %+v", e)
+ }
+ })
+ }
+}
diff --git a/internal/console/analyticsview.go b/internal/console/analyticsview.go
index f9892dc57..8a596d72c 100644
--- a/internal/console/analyticsview.go
+++ b/internal/console/analyticsview.go
@@ -95,7 +95,7 @@ func (s *Server) handleAppAnalyticsSetting(w http.ResponseWriter, r *http.Reques
return
}
count, door, bad := analyticsFormValues(r)
- back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
+ back := appSettingsURL(acct.Sqid, app, "general")
// The setting lives in the deployment bucket, so a console without one
// has nowhere to put it — refused before the write rather than panicking
// inside it, exactly as the errors level is.
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index e6e2613b5..f57ae5bc2 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -695,6 +695,7 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
var req struct {
Version string `json:"version"`
Channel string `json:"channel"`
+ Message string `json:"message"`
// Hotfix is spec §5's order bypass, and it is read nowhere above the
// rules decision below — see this handler's doc comment.
Hotfix bool `json:"hotfix"`
@@ -702,6 +703,10 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &req) {
return
}
+ message, ok := checkedActivityMessage(w, req.Message)
+ if !ok {
+ return
+ }
channel := strings.TrimSpace(req.Channel)
if !release.ValidChannel(channel) {
jsonError(w, http.StatusBadRequest, "invalid channel name")
@@ -853,7 +858,10 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) {
})
return
}
- s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel+" (cli)")
+ s.auditActivity(acct, app, addr, Activity{
+ Action: "promote", TargetType: "channel", Target: channel, Message: message,
+ Detail: "promoted " + version + " to " + channel + " (cli)",
+ })
s.bumpSerial(ctx)
writeJSON(w, http.StatusOK, map[string]any{
"result": "promoted", "from": res.From, "to": res.To, "noop": false,
@@ -942,10 +950,15 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
}
var req struct {
Channel string `json:"channel"`
+ Message string `json:"message"`
}
if !readJSON(w, r, &req) {
return
}
+ message, ok := checkedActivityMessage(w, req.Message)
+ if !ok {
+ return
+ }
channel := strings.TrimSpace(req.Channel)
if !release.ValidChannel(channel) {
jsonError(w, http.StatusBadRequest, "invalid channel name")
@@ -1023,7 +1036,10 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusConflict, err.Error())
return
}
- s.audit(acct, app, addr, "rollback", "rolled back "+channel+" to "+target+" (cli)")
+ s.auditActivity(acct, app, addr, Activity{
+ Action: "rollback", TargetType: "channel", Target: channel, Message: message,
+ Detail: "rolled back " + channel + " to " + target + " (cli)",
+ })
s.bumpSerial(ctx)
writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"})
}
@@ -1095,6 +1111,18 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) {
"no deployment bucket on this host — this console cannot request a restart")
return
}
+ var req struct {
+ Message string `json:"message"`
+ }
+ // The original restart door had no request body. Keep accepting those
+ // clients; a body is present only when a newer caller supplies context.
+ if r.ContentLength != 0 && !readJSON(w, r, &req) {
+ return
+ }
+ message, ok := checkedActivityMessage(w, req.Message)
+ if !ok {
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
if err := release.TouchRestartGen(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
@@ -1107,7 +1135,10 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again")
return
}
- s.audit(acct, app, addr, "restart", "requested a restart (cli)")
+ s.auditActivity(acct, app, addr, Activity{
+ Action: "restart", TargetType: "app", Target: app, Message: message,
+ Detail: "requested a restart (cli)",
+ })
// The request is in the bucket; this is what makes boxes LOOK at it — they
// read restart generations only when the serial moves. The difference
// between "seconds" and "up to an hour" for the one verb people type when
@@ -1590,8 +1621,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// both optional and both metadata rather than content — a re-ship of
// the same bytes tolerates a different value for either, the same
// way it already tolerates a different BuiltAt.
- Label string `json:"label,omitempty"`
- Notes string `json:"notes,omitempty"`
+ Label string `json:"label,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ Message string `json:"message,omitempty"`
// Capabilities is the app's declaration (internal/caps), ADDITIVE:
// absent from every request a CLI shipped before this field existed,
// and absent from every request that does not pass -capabilities. It
@@ -1612,6 +1644,10 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "malformed meta part")
return
}
+ message, ok := checkedActivityMessage(w, meta.Message)
+ if !ok {
+ return
+ }
if meta.Kind != "binary" && meta.Kind != "static" {
jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`)
return
@@ -1922,7 +1958,10 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not store the release — try again")
return
}
- s.audit(acct, app, addr, "ship", "shipped "+ver+" (cli)")
+ s.auditActivity(acct, app, addr, Activity{
+ Action: "ship", TargetType: "release", Target: ver, Message: message,
+ Detail: "shipped " + ver + " (cli)",
+ })
out := make(map[string]any, len(artifacts))
for name, a := range artifacts {
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go
index 24d7911d4..2de0abb18 100644
--- a/internal/console/apiconfig.go
+++ b/internal/console/apiconfig.go
@@ -289,6 +289,7 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) {
// signed, so a console with no signing key writes them just as well.
var req struct {
Environment string `json:"environment"`
+ Message string `json:"message"`
Set map[string]struct {
Kind string `json:"kind"`
Value string `json:"value"`
@@ -305,6 +306,10 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) {
if !readJSONLimit(w, r, &req, configPutMaxBytes) {
return
}
+ message, ok := checkedActivityMessage(w, req.Message)
+ if !ok {
+ return
+ }
if len(req.Set) == 0 && len(req.Unset) == 0 && req.ExecDelivery == nil {
jsonError(w, http.StatusBadRequest, "nothing to do — send set, unset or exec_delivery")
return
@@ -442,8 +447,10 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) {
// review, MINOR 9): a row claiming three removals when nothing was
// there would mislead exactly the person reading rows to find out
// what happened.
- s.audit(acct, app, addr, "config",
- fmt.Sprintf("config: %d set, %d unset (%s) (cli)", len(sets), removed, bundle))
+ s.auditActivity(acct, app, addr, Activity{
+ Action: "config", TargetType: "environment", Target: bundle, Message: message,
+ Detail: fmt.Sprintf("config: %d set, %d unset (%s) (cli)", len(sets), removed, bundle),
+ })
// Box-consumed (carlos-deploy §3): the fast loop's config-sync leg
// renders this bundle onto every route that references it. Announced
// per successful write rather than once at the end, for the same
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 35bcdd10d..36a809882 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -248,6 +248,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
Route string `json:"route"`
Kind string `json:"kind"`
Channel string `json:"channel"`
+ Message string `json:"message"`
// Wildcard and Catchall are the woodstar-carloku capability flips
// (Task 5), decoded as pointers so an ABSENT field means "leave
// unchanged" — the same tri-state rule apps place's -target and
@@ -261,6 +262,10 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
if !readJSON(w, r, &in) {
return
}
+ message, ok := checkedActivityMessage(w, in.Message)
+ if !ok {
+ return
+ }
// Path-traversal defence stated as such: this hostname becomes a bucket
// key segment (domains/<host>.json) and, on the box, a registry alias row
// and a certificate name. Nothing below runs until it is a well-formed,
@@ -300,7 +305,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// from the core's result: on the first-route branch the host IS the
// route the record just declared; on the alias branch it is the
// route the claim names.
- res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal)
+ res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal, message)
if ref != nil {
jsonError(w, ref.Status, ref.Msg)
return
@@ -375,7 +380,10 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// reload pass, so a bump per call would keep every box converging onto
// nothing and an audit row per call would bury the tenant's log.
if changed {
- s.audit(acct, app, principal, "domain-attach", "attached hostname "+host+" -> "+route+" (cli)")
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "domain-attach", TargetType: "instance", Target: route, Message: message,
+ Detail: "attached hostname " + host + " -> " + route + " (cli)",
+ })
// The claim is what a box's domains pass mints an alias row from, so
// this is a box-consumed write (carlos-deploy §3): announce it, and
// the hostname is serving seconds after the call instead of on the
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go
index 96c6c0da6..909d1f34b 100644
--- a/internal/console/apidomains_test.go
+++ b/internal/console/apidomains_test.go
@@ -133,6 +133,37 @@ func TestAPIDomainAttachCreates(t *testing.T) {
}
}
+func TestAPIDomainAttachRecordsDescriptionAndCanonicalRoute(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-message")
+ body := `{"host":"tickets.example.com","route":"go.tito.io","message":"attach the ticket checkout"}`
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
+ if code != http.StatusOK {
+ t.Fatalf("attach = %d %s, want 200", code, out)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 1)
+ if err != nil || len(events) != 1 {
+ t.Fatalf("Events = %+v, %v", events, err)
+ }
+ got := events[0]
+ if got.Action != "domain-attach" || got.TargetType != "instance" || got.Target != "go.tito.io" ||
+ got.Message != "attach the ticket checkout" || got.Detail != "attached hostname tickets.example.com -> go.tito.io (cli)" {
+ t.Fatalf("activity = %+v", got)
+ }
+}
+
+func TestAPIDomainAttachRejectsInvalidDescriptionBeforeWriting(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-message-invalid")
+ body := `{"host":"tickets.example.com","route":"go.tito.io","message":"first\nsecond"}`
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
+ if code != http.StatusBadRequest {
+ t.Fatalf("attach = %d %s, want 400", code, out)
+ }
+ claimAbsent(t, fx, "tickets.example.com")
+ if events, err := fx.st.Events(OpsAccountID, "titogo", 10); err != nil || len(events) != 0 {
+ t.Fatalf("activity after invalid message = %+v, %v", events, err)
+ }
+}
+
// TestAPIDomainAttachAppToken: the app-held credential works on its own app
// — and a token bound to a NEIGHBOURING app in the same account meets the
// family's byte-identical 404 (apiInstancePrincipal's contract), never a
@@ -598,7 +629,7 @@ func TestAPIDomainAttachKindStaticFirstRoute(t *testing.T) {
const host = "fresh.example.com"
code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
- attachBodyKind(host, "", "static", ""))
+ `{"host":"fresh.example.com","kind":"static","message":"publish the new site"}`)
if code != http.StatusOK {
t.Fatalf("attach = %d %s, want 200", code, out)
}
@@ -620,6 +651,14 @@ func TestAPIDomainAttachKindStaticFirstRoute(t *testing.T) {
if rec.Kind != "static" || rec.Channel != "canary/live" || rec.Slug != "" {
t.Fatalf("record = %+v, want a static record on canary/live", *rec)
}
+ events, err := fx.st.Events(OpsAccountID, "titogo", 1)
+ if err != nil || len(events) != 1 {
+ t.Fatalf("Events = %+v, %v", events, err)
+ }
+ if got := events[0]; got.Action != "domain-attach" || got.TargetType != "instance" || got.Target != host ||
+ got.Message != "publish the new site" || got.Detail != host+" (first route, canary/live)" {
+ t.Fatalf("activity = %+v", got)
+ }
}
// TestAPIDomainAttachKindStaticWithReadyRouteAliases: the same kind=static
diff --git a/internal/console/apifeatures.go b/internal/console/apifeatures.go
index 6073a5dc7..99daa43af 100644
--- a/internal/console/apifeatures.go
+++ b/internal/console/apifeatures.go
@@ -102,12 +102,17 @@ func (s *Server) handleAPIFeaturesSet(w http.ResponseWriter, r *http.Request) {
return
}
var in struct {
- Set map[string]string `json:"set"`
- Unset []string `json:"unset"`
+ Set map[string]string `json:"set"`
+ Unset []string `json:"unset"`
+ Message string `json:"message"`
}
if !readJSON(w, r, &in) {
return
}
+ message, ok := checkedActivityMessage(w, in.Message)
+ if !ok {
+ return
+ }
if len(in.Set) == 0 && len(in.Unset) == 0 {
jsonError(w, http.StatusBadRequest, "nothing to do — name at least one key to set or unset")
return
@@ -145,7 +150,10 @@ func (s *Server) handleAPIFeaturesSet(w http.ResponseWriter, r *http.Request) {
return
}
if changed {
- s.audit(acct, app, principal, "features", featuresAuditDetail(in.Set, in.Unset))
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "features", TargetType: "app", Target: app, Message: message,
+ Detail: featuresAuditDetail(in.Set, in.Unset),
+ })
}
resp := featuresResponse(acct.Sqid, app, rec)
resp.Changed = &changed
diff --git a/internal/console/apifeatures_test.go b/internal/console/apifeatures_test.go
index bea0555bc..0b60c91be 100644
--- a/internal/console/apifeatures_test.go
+++ b/internal/console/apifeatures_test.go
@@ -54,7 +54,8 @@ func TestAPIFeaturesSetMergesAuditsAndDoesNotAnnounce(t *testing.T) {
}
// A second set MERGES and an unset in the same call lands too.
- status, out = postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"beta": "1"}, "unset": ["sandbox"]}`)
+ status, out = postBody(t, featuresURL(fx, "bab", "titogo"), token,
+ `{"set": {"beta": "1"}, "unset": ["sandbox"], "message": "open the beta to testers"}`)
if status != http.StatusOK {
t.Fatalf("merge = %d %s, want 200", status, out)
}
@@ -67,20 +68,23 @@ func TestAPIFeaturesSetMergesAuditsAndDoesNotAnnounce(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- var rows []string
+ var rows []Event
for _, e := range events {
if e.Action == "features" {
- rows = append(rows, e.Detail)
+ rows = append(rows, e)
}
}
if len(rows) != 2 {
t.Fatalf("features audit rows = %q, want 2", rows)
}
// Newest first: the merge names both what it set and what it cleared.
- if !strings.Contains(rows[0], "beta=1") || !strings.Contains(rows[0], "unset sandbox") {
+ if !strings.Contains(rows[0].Detail, "beta=1") || !strings.Contains(rows[0].Detail, "unset sandbox") {
t.Fatalf("merge audit detail = %q, want it to name beta=1 and the unset", rows[0])
}
- if !strings.Contains(rows[1], "sandbox=on") {
+ if rows[0].TargetType != "app" || rows[0].Target != "titogo" || rows[0].Message != "open the beta to testers" {
+ t.Fatalf("merge audit structure = %+v", rows[0])
+ }
+ if !strings.Contains(rows[1].Detail, "sandbox=on") {
t.Fatalf("first audit detail = %q, want sandbox=on", rows[1])
}
// No box consumes features.json — the app reads it off the console —
@@ -272,7 +276,7 @@ func TestSettingsPageShowsFeatureFlags(t *testing.T) {
t.Fatal(err)
}
- status, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings/features"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -295,7 +299,7 @@ func TestSettingsPageShowsFeatureFlags(t *testing.T) {
// An app with none set says so, and still tells the member how one
// gets set.
freshApp(t, fx, "sprout")
- _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings/features"))
if !strings.Contains(body, "No feature flags") || !strings.Contains(body, "carlos features set --app sprout") {
t.Fatalf("an app with no flags is not told so:\n%s", body)
}
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 4d15885fc..017f47a61 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -547,6 +547,7 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
Host string `json:"host"`
Region string `json:"region"`
Channel string `json:"channel"`
+ Message string `json:"message"`
// The route SHAPE fields (route-records design §3, HA design §II).
// All optional, all defaulting to the exact route this door minted
// before they existed, and all gated by provision.ValidateShape
@@ -586,6 +587,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
if !readJSON(w, r, &req) {
return
}
+ message, ok := checkedActivityMessage(w, req.Message)
+ if !ok {
+ return
+ }
host := strings.TrimSpace(req.Host)
// Path-traversal defence stated as such (spec §11): this host becomes a
// bucket key segment here and, on the box, the <host>.sock and
@@ -798,7 +803,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
// (TTL 60) written HERE, after the record write and before the
// response, behind an interface the deployment configures (nil =
// wildcard-covered). Recorded, not built.
- s.audit(acct, app, principal, "instance-create", "created instance "+host+" (cli)")
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "instance-create", TargetType: "instance", Target: host, Message: message,
+ Detail: "created instance " + host + " (cli)",
+ })
// The record is what a box's reconcile leg mints a route from, so this is
// a box-consumed write (carlos-deploy §3): announce it, and the instance
// is serving seconds after signup instead of on the hour.
@@ -1216,6 +1224,10 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ message, ok := checkedActivityMessage(w, r.Header.Get(activityMessageHeader))
+ if !ok {
+ return
+ }
// The eighth destructive door, and the one with the shortest fuse:
// deleting a control-plane app's record is how the provisioning
// reconciler's removal phase is TOLD to retire the console's route, so a
@@ -1261,7 +1273,10 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
// idempotency makes "deleted" and "already gone" indistinguishable from
// the store, and the fact worth recording is that this principal asked
// for this host to stop existing.
- s.audit(acct, app, principal, "instance-delete", "deleted instance "+host+" (cli)")
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "instance-delete", TargetType: "instance", Target: host, Message: message,
+ Detail: "deleted instance " + host + " (cli)",
+ })
// Retiring a route is reconcile's work too, and the direction people want
// FAST: the announcement is what takes the route down in seconds.
s.bumpSerial(ctx)
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index 9b3981c63..6c767d9fb 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -1747,10 +1747,29 @@ func TestAPIInstanceDelete(t *testing.T) {
t.Fatalf("the manifest was destroyed by a traversal delete: %v", err)
}
- status, _, out := rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", token, "")
+ req, err := http.NewRequest(http.MethodDelete, u+"/acme.go.tito.io", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set(activityMessageHeader, "retire the old web copy")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ outBytes, err := io.ReadAll(res.Body)
+ res.Body.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, out := res.StatusCode, string(outBytes)
if status != http.StatusOK || !strings.Contains(out, "deleted") {
t.Fatalf("delete = %d %s, want 200 deleted", status, out)
}
+ events, err := fx.st.Events(OpsAccountID, "titogo", 1)
+ if err != nil || len(events) != 1 || events[0].Message != "retire the old web copy" {
+ t.Fatalf("delete activity = %+v, %v", events, err)
+ }
if _, _, err := fx.rel.Get(context.Background(),
provision.RecordKey("bab", "titogo", "acme.go.tito.io")); err == nil {
t.Fatalf("the record survived its delete")
diff --git a/internal/console/apirestartscope.go b/internal/console/apirestartscope.go
index 6ce6f3fb2..b0a67ee98 100644
--- a/internal/console/apirestartscope.go
+++ b/internal/console/apirestartscope.go
@@ -103,6 +103,7 @@ func (s *Server) handleAPIRestartScope(w http.ResponseWriter, r *http.Request) {
Host string `json:"host"`
Environment string `json:"environment"`
DryRun bool `json:"dry_run"`
+ Message string `json:"message"`
}
// STRICT, and that is a safety property rather than pedantry: a key this
// door does not declare — `"hosts"` for `"host"`, a field a future client
@@ -112,6 +113,10 @@ func (s *Server) handleAPIRestartScope(w http.ResponseWriter, r *http.Request) {
if !readJSONStrictLimit(w, r, &req, restartScopeMaxBytes) {
return
}
+ message, ok := checkedActivityMessage(w, req.Message)
+ if !ok {
+ return
+ }
host := strings.TrimSpace(req.Host)
envName := strings.TrimSpace(req.Environment)
if host != "" && !provision.ValidHostShape(host) {
@@ -217,7 +222,17 @@ func (s *Server) handleAPIRestartScope(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again")
return
}
- s.audit(acct, app, addr, "restart", restartAuditWords(scope))
+ targetType := "instance"
+ if scope.Kind == release.RestartScopeEnvironment {
+ targetType = "environment"
+ }
+ s.auditActivity(acct, app, addr, Activity{
+ Action: "restart",
+ TargetType: targetType,
+ Target: scope.Value,
+ Message: message,
+ Detail: restartAuditWords(scope),
+ })
// The request is in the bucket; this is what makes boxes LOOK at it — they
// read restart requests only when the serial moves.
s.bumpSerial(ctx)
diff --git a/internal/console/apirestartscope_test.go b/internal/console/apirestartscope_test.go
index d6f31486e..30745ac74 100644
--- a/internal/console/apirestartscope_test.go
+++ b/internal/console/apirestartscope_test.go
@@ -150,6 +150,37 @@ func TestAPIRestartScopeEnvironmentAddressesOnlyThatBundle(t *testing.T) {
}
}
+func TestAPIRestartScopeRecordsMessageAndTarget(t *testing.T) {
+ fx, token := scopeFixture(t, "code-scope-message")
+ status, _, out := postScope(t, fx, token, `{"environment":"relay","message":" cycle the relay workers "}`)
+ if status != http.StatusOK {
+ t.Fatalf("restart scope = %d %s, want 200", status, out)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 1)
+ if err != nil || len(events) != 1 {
+ t.Fatalf("Events = %+v, %v", events, err)
+ }
+ got := events[0]
+ if got.Action != "restart" || got.TargetType != "environment" || got.Target != "relay" ||
+ got.Message != "cycle the relay workers" || got.Detail != "requested a restart of environment relay (cli)" {
+ t.Fatalf("activity = %+v", got)
+ }
+}
+
+func TestAPIRestartScopeRejectsInvalidMessageBeforeWriting(t *testing.T) {
+ fx, token := scopeFixture(t, "code-scope-message-invalid")
+ status, _, out := postScope(t, fx, token, `{"host":"bill.go.tito.io","message":"first\nsecond"}`)
+ if status != http.StatusBadRequest {
+ t.Fatalf("restart scope = %d %s, want 400", status, out)
+ }
+ if len(scopeLog(t, fx, "titogo").Requests) != 0 || restartGenEtag(t, fx, "titogo") != "" {
+ t.Fatal("an invalid message still wrote a restart request")
+ }
+ if events, err := fx.st.Events(OpsAccountID, "titogo", 10); err != nil || len(events) != 0 {
+ t.Fatalf("activity after invalid message = %+v, %v", events, err)
+ }
+}
+
// TestAPIRestartScopeLeavesTheAppWideObjectAlone is the additive property at
// the door: a narrowed request must not move the object every OLD box watches,
// or the fleet's un-upgraded boxes would each cycle the whole app.
diff --git a/internal/console/appdomains.go b/internal/console/appdomains.go
index ec23703c9..eee8eaa97 100644
--- a/internal/console/appdomains.go
+++ b/internal/console/appdomains.go
@@ -17,7 +17,6 @@ import (
"context"
"crypto/subtle"
"net/http"
- "net/url"
"strings"
"time"
)
@@ -47,6 +46,12 @@ func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) {
defer cancel()
res, ref := s.attachDomain(ctx, acct, app, host, "", channel, addr)
if ref != nil {
+ if ref.Status == http.StatusUnprocessableEntity {
+ s.renderAppSettingsForm(w, r, ref.Status, "domains", ref.Msg, map[string]string{
+ "domain": host, "channel": channel,
+ })
+ return
+ }
s.renderDoorRefusal(w, r, ref)
return
}
@@ -55,7 +60,7 @@ func (s *Server) handleAppDomainAttach(w http.ResponseWriter, r *http.Request) {
msg = "Attached " + host + " as the app's first route, following " + res.Channel + "."
}
s.setFlash(w, "ok", msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
}
// handleAppDomainDetach releases a host's claim. The typed-host confirm is
@@ -88,7 +93,7 @@ func (s *Server) handleAppDomainDetach(w http.ResponseWriter, r *http.Request) {
return
}
s.setFlash(w, "ok", "Detached "+host+".")
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), http.StatusSeeOther)
}
// renderDoorRefusal turns a doorRefusal from the Task 3 core into this
diff --git a/internal/console/appdomains_test.go b/internal/console/appdomains_test.go
index 2ddc2a2f0..522b74069 100644
--- a/internal/console/appdomains_test.go
+++ b/internal/console/appdomains_test.go
@@ -30,7 +30,7 @@ func TestAppDomainAttachHappyPathAliases(t *testing.T) {
status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
url.Values{"csrf": {csrf}, "host": {host}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("attach = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
}
claim, err := domains.GetClaim(context.Background(), fx.rel, host)
@@ -74,7 +74,7 @@ func TestAppDomainAttachLowercasesHost(t *testing.T) {
status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
url.Values{"csrf": {csrf}, "host": {mixed}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("attach with a mixed-case host = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
}
if _, err := domains.GetClaim(context.Background(), fx.rel, lower); err != nil {
@@ -156,7 +156,7 @@ func TestAppDomainDetachHappyPath(t *testing.T) {
status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("detach = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
}
claimAbsent(t, fx, host)
@@ -290,7 +290,7 @@ func TestAppDomainAttachStillConvergesTheCallersOwnDeploymentRoute(t *testing.T)
st, loc, body := postForm(t, c, fx.u("/apps/kass/domains/attach"),
url.Values{"csrf": {csrf}, "host": {"mine.example.com"}})
- if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if st != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("re-attach of the caller's own hostname = %d -> %q, want 303 -> settings:\n%s", st, loc, body)
}
}
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index c6af24e27..b4a7568c8 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -46,8 +46,34 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// appSettingsSections is the closed set accepted after /settings/. Keeping
+// this server-side means a typo gets the same 404 as any other unknown app
+// page, rather than rendering an empty settings shell.
+var appSettingsSections = map[string]bool{
+ "general": true,
+ "domains": true,
+ "pipeline": true,
+ "storage": true,
+ "email": true,
+ "features": true,
+ "advanced": true,
+ "delete": true,
+}
+
+func appSettingsURL(acct, app, section string) string {
+ base := "/a/" + url.PathEscape(acct) + "/apps/" + url.PathEscape(app) + "/settings"
+ if section == "" || section == "general" {
+ return base
+ }
+ return base + "/" + section
+}
+
// SettingsView is what the Settings tab knows beyond the shared AppView.
type SettingsView struct {
+ // NoBucket distinguishes a console deliberately running without a
+ // deployment store from a transient read failure.
+ NoBucket bool
+
// CanRename: nothing outside the console database knows this name yet.
// RenameBlocked carries the reason when it is false.
CanRename bool
@@ -76,19 +102,25 @@ type SettingsView struct {
// and a zero card when there is genuinely no store, which is what the
// "ask for one" line renders from.
Store *StoreCard
+ // StoreUnavailable distinguishes a failed read from a genuinely absent
+ // record, so the section remains present and does not claim there is no
+ // store during a transient failure.
+ StoreUnavailable bool
// Mail is the app's tenant email as this page sees it (issue #240) —
// object storage's sibling primitive, same declare -> provision ->
// deliver -> rotate shape, and the same error discipline as Store: nil
// on a read failure, a zero card when there is genuinely no record.
- Mail *MailCard
+ Mail *MailCard
+ MailUnavailable bool
// Features is the app's feature flags as the platform serves them
// (#347, apifeatures.go) — read-only here; the write is the audited,
// admin-gated CLI door. Same error discipline as Store: nil on a read
// failure (the page says nothing rather than "none set" about a record
// it could not parse), a zero card when there genuinely are none.
- Features *FeaturesCard
+ Features *FeaturesCard
+ FeaturesUnavailable bool
// Domains is this page's OWN Domains & TLS rows (settingsDomains): a
// copy of AppView.Domains with a placeholder row appended for any of
@@ -257,6 +289,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
}
sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName)
if s.cfg.Releases == nil {
+ sv.NoBucket = true
sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run."
return sv
}
@@ -273,6 +306,9 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
sv.Store = s.storeCard(ctx, acct, p.AppName)
sv.Mail = s.mailCard(ctx, acct, p.AppName)
sv.Features = s.featuresCard(ctx, acct, p.AppName)
+ sv.StoreUnavailable = sv.Store == nil
+ sv.MailUnavailable = sv.Mail == nil
+ sv.FeaturesUnavailable = sv.Features == nil
if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched {
sv.MismatchTarget = ra
if target, err := s.store.AccountBySqid(ra); err == nil && target != nil {
@@ -553,7 +589,7 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther)
}
// The control plane keeps its name (controlplane.go): the fleet's release
// keys, its channels and the units that exec them are all written in it.
@@ -569,15 +605,18 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
// the mux prefers the literal, so no console page could ever show it.
to := normalizeAppName(r.FormValue("to"))
if !release.ValidApp(to) {
- back("err", "App names are lowercase letters, digits and dashes.")
+ s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
+ "App names are lowercase letters, digits and dashes.", map[string]string{"rename": r.FormValue("to")})
return
}
if to == "new" {
- back("err", "That name is reserved.")
+ s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
+ "That name is reserved.", map[string]string{"rename": r.FormValue("to")})
return
}
if to == app {
- back("err", "That is already this app's name.")
+ s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general",
+ "That is already this app's name.", map[string]string{"rename": r.FormValue("to")})
return
}
if s.cfg.Releases == nil {
@@ -652,7 +691,7 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
}
s.audit(acct, to, addr, "rename", "renamed "+app+" to "+to)
s.setFlash(w, "ok", "Renamed "+app+" to "+to+".")
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(to)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, to, "general"), http.StatusSeeOther)
}
// renameAdvice maps RenameAppIn's refusals onto the sentence a person can
@@ -688,7 +727,7 @@ func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings",
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"),
http.StatusSeeOther)
}
if s.cfg.Releases == nil {
@@ -777,7 +816,7 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "delete"), http.StatusSeeOther)
}
// Outright, before the typed name and long before the passkey: the flag
// is the guard, and no gesture answers it (controlplane.go).
@@ -901,7 +940,7 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) {
// ownership gate and is entitled to know what went wrong.
fail := func(msg string) {
s.setFlash(w, "err", msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "storage"), http.StatusSeeOther)
}
// The sixth destructive door, and the one whose ceremony is closest to
// enough without being it: owner, CSRF, the typed BUCKET name and a
@@ -998,7 +1037,7 @@ func (s *Server) handleStoreDestroy(w http.ResponseWriter, r *http.Request) {
// bump that stands until the hourly floor comes round.
s.bumpSerial(ctx)
s.setFlash(w, "ok", flash)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "storage"), http.StatusSeeOther)
}
// storeConfirmWord is what a person must type to destroy a store: the BUCKET
@@ -1039,7 +1078,7 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "general"), http.StatusSeeOther)
}
// The control plane does not change accounts: its releases are read from
// one account's prefix by every box in the fleet (controlplane.go).
@@ -1047,7 +1086,11 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
back("err", msg)
return
}
- target, agreed, err := s.routeAccountFor(app)
+ target, agreed, err := s.routeAccountFor(addr, acct, app)
+ if errors.Is(err, errMoveTargetUnavailable) {
+ http.NotFound(w, r)
+ return
+ }
if err != nil {
back("err", "Could not read the routes — nothing was moved. Try again.")
return
@@ -1129,45 +1172,38 @@ func moveAdvice(err error, destName string) string {
return "Try again."
}
-// routeAccountFor reads what the ROUTES say the app's account is, and whether
-// they say it with one voice: the first non-platform route's stamped account,
-// plus agreed=false the moment any other non-platform route names a different
-// one. Both are read fresh at action time, so the move never trusts a stale
-// page.
-//
-// The Settings card only offers the move when AllMismatched (assembleSettings),
-// and under AllMismatched every non-platform route disagrees with the viewed
-// account — so the first route is provably also the first MISMATCHED one, and
-// the target here is the same account Assemble's mismatchedByApp[app][0].Account
-// is. But a target alone cannot police the door: outside that gate the first
-// route may be the ONLY one disagreeing, and moving to it would re-home the
-// record away from where most of the app's hosts actually deploy from. agreed
-// is what the handler refuses on — an app whose routes contradict each other
-// has no single account to move to, and the routes are the repair, not the
-// record. An app with no non-platform routes at all agrees vacuously (target
-// ""), which the handler's own nothing-to-move refusal then catches.
-func (s *Server) routeAccountFor(app string) (target string, agreed bool, err error) {
- reg, err := openRegistry(s.cfg.Registry)
- if err != nil {
- return "", false, err
- }
- routes, err := reg.List()
+var errMoveTargetUnavailable = errors.New("move target unavailable to this viewer")
+
+// routeAccountFor recomputes the viewer-safe diagnostic at action time. It
+// grants a target only when every relevant route points to one accessible
+// account which does not already claim its own same-named app.
+func (s *Server) routeAccountFor(address string, source *Account, app string) (target string, agreed bool, err error) {
+ review, all, err := s.appAccountReview(address, source, app)
if err != nil {
return "", false, err
}
- var seen bool
- for _, rt := range routes {
- if rt.App != app || rt.Platform {
- continue
+ if all {
+ target := review.Groups[0].Account.Sqid
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return "", false, err
}
- acct := release.RouteAccount(rt)
- if !seen {
- target, seen = acct, true
- continue
+ routes, err := reg.List()
+ if err != nil {
+ return "", false, err
}
- if acct != target {
- return target, false, nil
+ for _, rt := range routes {
+ if rt.App == app && !rt.Platform && release.RouteAccount(rt) != target {
+ return "", false, nil
+ }
}
+ return target, true, nil
+ }
+ if review.blocked {
+ return "", false, errMoveTargetUnavailable
+ }
+ if review.Count == 0 && !review.blocked {
+ return source.Sqid, true, nil
}
- return target, true, nil
+ return "", false, nil
}
diff --git a/internal/console/appsettings_gate_test.go b/internal/console/appsettings_gate_test.go
index b03ddcd34..340d742a2 100644
--- a/internal/console/appsettings_gate_test.go
+++ b/internal/console/appsettings_gate_test.go
@@ -22,14 +22,14 @@ func TestAppSettingsGateSetAndClear(t *testing.T) {
csrf := sessionToken(t, fx, c)
// The page shows the host as open, with a form.
- _, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ _, _, body := get(t, c, fx.u("/apps/titogo/settings/domains"))
if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") {
t.Fatalf("settings page lacks the gate section: %s", body)
}
status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"),
url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/domains") {
t.Fatalf("set = %d -> %q", status, loc)
}
rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io")
@@ -39,7 +39,7 @@ func TestAppSettingsGateSetAndClear(t *testing.T) {
if !strings.HasPrefix(rec.Gate, "$2a$10$") {
t.Fatalf("stored gate = %q", rec.Gate)
}
- _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings/domains"))
if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") {
t.Fatalf("after set: %s", body)
}
@@ -50,7 +50,7 @@ func TestAppSettingsGateSetAndClear(t *testing.T) {
// A short password is refused with the rule, and nothing changes.
postForm(t, c, fx.u("/apps/titogo/gate"),
url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}})
- _, _, body = get(t, c, fx.u("/apps/titogo/settings"))
+ _, _, body = get(t, c, fx.u("/apps/titogo/settings/domains"))
if !strings.Contains(body, "at least 8 characters") {
t.Fatalf("short password not refused on the page: %s", body)
}
diff --git a/internal/console/appsettings_sections_test.go b/internal/console/appsettings_sections_test.go
new file mode 100644
index 000000000..2e7369847
--- /dev/null
+++ b/internal/console/appsettings_sections_test.go
@@ -0,0 +1,120 @@
+package console
+
+import (
+ "bytes"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestAppSettingsSectionRoutes(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-settings-sections")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-sections-browser")
+
+ for _, section := range []string{"general", "domains", "storage", "email", "features", "advanced", "delete"} {
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings/"+section))
+ if status != http.StatusOK {
+ t.Errorf("GET settings/%s = %d, want 200: %s", section, status, body)
+ }
+ }
+ _, _, general := get(t, c, fx.u("/apps/titogo/settings"))
+ for _, want := range []string{
+ `aria-label="Settings sections"`, ">General</a>", ">Domains</a>",
+ ">Release pipeline</a>", ">Storage</a>", ">Email</a>",
+ ">Feature flags</a>", ">Advanced</a>", ">Delete</a>",
+ } {
+ if !strings.Contains(general, want) {
+ t.Errorf("Settings navigation missing %q", want)
+ }
+ }
+ if strings.Contains(general, "Domains &amp; TLS") {
+ t.Fatal("General still renders the Domains section")
+ }
+ _, _, domains := get(t, c, fx.u("/apps/titogo/settings/domains"))
+ if !strings.Contains(domains, "Domains &amp; TLS") || strings.Contains(domains, "<h2>Identity</h2>") {
+ t.Fatal("Domains does not render as an isolated section")
+ }
+ status, _, _ := get(t, c, fx.u("/apps/titogo/settings/not-a-section"))
+ if status != http.StatusNotFound {
+ t.Errorf("GET unknown settings section = %d, want 404", status)
+ }
+ assertNonMember404(t, fx, "/apps/titogo/settings/domains")
+}
+
+func TestAppSettingsURL(t *testing.T) {
+ if got := appSettingsURL("a b", "an/app", "general"); got != "/a/a%20b/apps/an%2Fapp/settings" {
+ t.Errorf("general URL = %q", got)
+ }
+ if got := appSettingsURL("bab", "kass", "storage"); got != "/a/bab/apps/kass/settings/storage" {
+ t.Errorf("storage URL = %q", got)
+ }
+}
+
+func TestPipelineSettingsCanonicalSectionURL(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-settings-pipeline-section")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-settings-pipeline-section-browser")
+
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings/pipeline"))
+ if status != http.StatusOK || !strings.Contains(body, "Release pipeline") {
+ t.Fatalf("GET settings/pipeline = %d: %s", status, body)
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/pipeline"))
+ if status != http.StatusOK || !strings.Contains(body, "Release pipeline") {
+ t.Fatalf("legacy GET pipeline = %d: %s", status, body)
+ }
+ csrf := sessionToken(t, fx, c)
+ status, _, body = postForm(t, c, fx.u("/apps/titogo/pipeline"), url.Values{
+ "csrf": {csrf}, "action": {"add"}, "ch.0.name": {"edge"},
+ })
+ if status != http.StatusOK || !strings.Contains(body, `aria-label="Settings sections"`) ||
+ !strings.Contains(body, `aria-current="page">Release pipeline</a>`) {
+ t.Fatalf("pipeline draft rerender lost Settings navigation = %d: %s", status, body)
+ }
+}
+
+func TestSettingsValidationRetainsTypedValues(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"), url.Values{
+ "csrf": {csrf}, "to": {"Still Typing"},
+ })
+ if status != http.StatusUnprocessableEntity || !strings.Contains(body, `value="Still Typing"`) {
+ t.Fatalf("rename refusal did not retain the typed name = %d: %s", status, body)
+ }
+
+ status, _, body = postForm(t, c, fx.u("/apps/sprout/domains/attach"), url.Values{
+ "csrf": {csrf}, "host": {"not a host"}, "channel": {"canary/draft"},
+ })
+ if status != http.StatusUnprocessableEntity || !strings.Contains(body, `value="not a host"`) ||
+ !strings.Contains(body, `value="canary/draft"`) {
+ t.Fatalf("domain refusal did not retain host and channel = %d: %s", status, body)
+ }
+}
+
+func TestBucketlessSettingsSectionsExplainConfiguration(t *testing.T) {
+ for section, want := range map[string]string{
+ "storage": "no deployment bucket on this host — this console cannot serve object storage",
+ "email": "no deployment bucket on this host — this console cannot serve email",
+ "features": "no deployment bucket on this host — this console cannot read features",
+ } {
+ p := page{
+ Title: "Settings", AppName: "sprout", ActiveTab: "settings", SettingsSection: section,
+ Account: &Account{Name: "paul@example.com", Sqid: "bab"},
+ Detail: &AppView{App: "sprout"}, Settings: &SettingsView{NoBucket: true},
+ }
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "app_settings", p); err != nil {
+ t.Fatalf("render %s: %v", section, err)
+ }
+ body := buf.String()
+ if !strings.Contains(body, want) || strings.Contains(body, "Please try again") {
+ t.Fatalf("bucketless %s section is not explicit: %s", section, body)
+ }
+ }
+}
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index f4b6edde1..b8f44ebcb 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -355,7 +355,7 @@ func TestAppDeleteRequiresTheExactName(t *testing.T) {
if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body)
}
- if loc != fx.path("/apps/kass/settings") {
+ if loc != fx.path("/apps/kass/settings/delete") {
t.Fatalf("mistyped delete redirected to %q, want the Settings tab", loc)
}
// The apostrophe in "app's" is HTML-escaped on the way out, so the
@@ -427,7 +427,7 @@ func TestAppDeleteRefusesAProductionApp(t *testing.T) {
status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/delete"),
url.Values{"csrf": {csrf}, "confirm": {"kass"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/delete") {
t.Fatalf("delete of a production app = %d -> %q:\n%s", status, loc, body)
}
if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is marked as production, so deleting is refused") {
@@ -549,7 +549,7 @@ func TestAppMoveConfirmsThenMovesTheRecord(t *testing.T) {
}
// Moving the record into an account the viewer does not belong to would strand
-// the app for them — refused, with the operator path named instead.
+// the app for them. The refusal must not disclose that account as a move target.
func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) {
fx := newConsoleFixture(t)
const destID = 4343
@@ -560,16 +560,13 @@ func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) {
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/move"),
+ status, _, body := postGated(t, fx, c, fx.u("/apps/kass/move"),
url.Values{"csrf": {csrf}, "confirmed": {"1"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
- t.Fatalf("move into a stranger account = %d -> %q:\n%s", status, loc, body)
+ ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
+ t.Fatalf("move into a stranger account = %d %q; unrouted = %d %q", status, body, ghostStatus, ghostBody)
}
notACard(t, body)
- if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
- "You are not a member of Third (cghi)") {
- t.Fatalf("no non-membership refusal flash:\n%s", page)
- }
if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err)
}
@@ -695,7 +692,8 @@ func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) {
// pass vacuously: both forms ARE eligible to render in this exact state.
owner := signInPaul(t, fx)
_, _, obody := get(t, owner, fx.u("/apps/sprout/settings"))
- if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(obody, `id="delete-confirm"`) {
+ _, _, odelete := get(t, owner, fx.u("/apps/sprout/settings/delete"))
+ if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(odelete, `id="delete-confirm"`) {
t.Fatalf("the owner is not offered both forms — the member checks below would be vacuous:\n%s", obody)
}
@@ -709,14 +707,16 @@ func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("member settings page = %d, want 200:\n%s", status, body)
}
- if !strings.Contains(body, "Domains") {
+ _, _, domainsBody := get(t, cm, fx.u("/apps/sprout/settings/domains"))
+ if !strings.Contains(domainsBody, "Domains &amp; TLS") {
t.Fatalf("a member cannot see the page at all:\n%s", body)
}
if strings.Contains(body, `id="rename-to"`) || strings.Contains(body, "/rename\"") {
t.Errorf("a member is offered the rename form:\n%s", body)
}
- if strings.Contains(body, `id="delete-confirm"`) || strings.Contains(body, "/delete\"") {
- t.Errorf("a member is offered the delete form:\n%s", body)
+ _, _, deleteBody := get(t, cm, fx.u("/apps/sprout/settings/delete"))
+ if strings.Contains(deleteBody, `id="delete-confirm"`) || strings.Contains(deleteBody, `action="/a/bab/apps/sprout/delete"`) {
+ t.Errorf("a member is offered the delete form:\n%s", deleteBody)
}
}
@@ -770,7 +770,7 @@ func TestStoreDestroyRefusesAnythingButTheBucketName(t *testing.T) {
if strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("confirm %q demanded a passkey before it was even checked (-> %q)", typed, loc)
}
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("confirm %q = %d -> %q, want the settings flash:\n%s", typed, status, loc, body)
}
if n := stashedActions(t, fx); n != 0 {
@@ -781,7 +781,7 @@ func TestStoreDestroyRefusesAnythingButTheBucketName(t *testing.T) {
t.Fatalf("a mistyped destroy touched the record: %+v", rec)
}
}
- if _, _, page := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(page, bucket) {
+ if _, _, page := get(t, c, fx.u("/apps/kass/settings/storage")); !strings.Contains(page, bucket) {
t.Fatalf("the refusal does not tell the owner what to type:\n%s", page)
}
}
@@ -807,7 +807,7 @@ func TestStoreDestroyDestroysTheEstateAndAnnounces(t *testing.T) {
estateStanding(t, fx, fake, "kass", bucket, "a destroy awaiting its passkey")
status, loc, body := completeStepUp(t, fx, c, loc)
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("resumed destroy = %d -> %q:\n%s", status, loc, body)
}
estateGone(t, fake, "a completed destroy")
@@ -871,7 +871,7 @@ func TestStoreDestroyLeavesTheEmailEstateAlone(t *testing.T) {
csrf := sessionToken(t, fx, c)
status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {bucket}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("store destroy = %d -> %q:\n%s", status, loc, body)
}
estateGone(t, fake, "a store destroy that must leave email alone")
@@ -899,7 +899,7 @@ func TestStoreDestroyOfADeclarationNeedsNoBackend(t *testing.T) {
status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {"kass"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("destroy of a declaration = %d -> %q:\n%s", status, loc, body)
}
if rec := storedRecord(t, fx, "kass"); rec != nil {
@@ -922,7 +922,7 @@ func TestStoreDestroyRefusesAnEstateWithNoBackend(t *testing.T) {
status, loc, body := postForm(t, c, fx.u("/apps/kass/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {bucket}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("backend-less destroy = %d -> %q:\n%s", status, loc, body)
}
if n := stashedActions(t, fx); n != 0 {
@@ -952,7 +952,7 @@ func TestStoreDestroyFailureLeavesTheRecordNamingTheEstate(t *testing.T) {
status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {bucket}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings/storage") {
t.Fatalf("failed destroy = %d -> %q:\n%s", status, loc, body)
}
estateStanding(t, fx, fake, "kass", bucket, "a failed destroy")
@@ -1004,7 +1004,7 @@ func TestSettingsPageShowsTheStoreCard(t *testing.T) {
keyID := storedRecord(t, fx, "kass").Provisioned.KeyID
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, body := get(t, c, fx.u("/apps/kass/settings/storage"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1027,7 +1027,7 @@ func TestSettingsPageShowsTheStoreCard(t *testing.T) {
// An app nobody has declared a store for gets the one line that tells them
// how to ask for one — and no destroy form for a store that isn't there.
freshApp(t, fx, "sprout")
- _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings/storage"))
if !strings.Contains(body, "carlos store create") {
t.Fatalf("an undeclared app is not told how to ask for a store:\n%s", body)
}
@@ -1054,7 +1054,7 @@ func TestSettingsPageOffersAttachFormNotBoxInstructions(t *testing.T) {
freshApp(t, fx, "sprout")
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1075,7 +1075,7 @@ func TestSettingsPageShowsAnUnroutedClaimAsAttaching(t *testing.T) {
const host = "docs.example.com"
fx, c, _ := domainAttachedFixture(t, "kass", host)
- status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, body := get(t, c, fx.u("/apps/kass/settings/domains"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1101,7 +1101,7 @@ func TestSettingsPageShowsChannelFieldWhenOnlyRouteIsPlatform(t *testing.T) {
App: "sprout", Channel: "stable", Platform: true})
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings/domains"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1137,6 +1137,10 @@ func TestAssembleSettingsListsRoutedDomainsWithoutABucket(t *testing.T) {
p := &page{AppName: "kass", Detail: detail}
sv := fx.srv.assembleSettings(context.Background(), p, acct)
+ if !sv.NoBucket || sv.StoreUnavailable || sv.MailUnavailable || sv.FeaturesUnavailable {
+ t.Fatalf("bucketless settings flags = NoBucket %v, unavailable %v/%v/%v", sv.NoBucket,
+ sv.StoreUnavailable, sv.MailUnavailable, sv.FeaturesUnavailable)
+ }
if len(sv.Domains) != 1 || sv.Domains[0].Domain != "kass.carlosapp.dev" {
t.Fatalf("Settings.Domains on a bucketless console = %+v, want kass's routed domain still listed", sv.Domains)
}
@@ -1393,7 +1397,7 @@ func TestSettingsPageShowsTheEmailCard(t *testing.T) {
})
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, body := get(t, c, fx.u("/apps/kass/settings/email"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
@@ -1415,7 +1419,7 @@ func TestSettingsPageShowsTheEmailCard(t *testing.T) {
// An app nobody has declared email for, on a console WITH a provisioner,
// gets the command that can actually run.
freshApp(t, fx, "sprout")
- _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings/email"))
if !strings.Contains(body, "carlos email enable --app sprout") {
t.Fatalf("an undeclared app is not told how to ask for email:\n%s", body)
}
@@ -1431,7 +1435,7 @@ func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) {
fx.srv.cfg.MailProvisioner = nil
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, body := get(t, c, fx.u("/apps/kass/settings/email"))
if status != http.StatusOK {
t.Fatalf("settings page = %d:\n%s", status, body)
}
diff --git a/internal/console/channelmove.go b/internal/console/channelmove.go
index 9dfd53c52..be2ec0b3d 100644
--- a/internal/console/channelmove.go
+++ b/internal/console/channelmove.go
@@ -184,7 +184,7 @@ func (s *Server) handleAPIInstanceChannel(w http.ResponseWriter, r *http.Request
jsonError(w, http.StatusBadGateway, "could not change the channel — try again")
return
}
- s.audit(acct, app, principal, "instance-channel", "moved "+host+" from "+from+" to "+to+" (cli)")
+ s.auditActivity(acct, app, principal, Activity{Action: "instance-channel", TargetType: "instance", Target: host, Detail: "moved " + host + " from " + from + " to " + to + " (cli)"})
// A box-consumed write: the reconcile pass converges the route's Channel
// column from this record, so announcing it is what makes the move take
// seconds instead of an hour.
@@ -221,9 +221,9 @@ func (s *Server) executePendingRouteChannel(ctx context.Context, acct *Account,
log.Printf("console: execute route-channel %s/%s/%s: %v", acct.Sqid, app, p.Host, err)
return "", errors.New("could not change the channel — try again")
}
- s.audit(acct, app, actor, "instance-channel", fmt.Sprintf(
+ s.auditActivity(acct, app, actor, Activity{Action: "instance-channel", TargetType: "instance", Target: p.Host, Detail: fmt.Sprintf(
"moved %s from %s to %s (proposed by %s, %d confirmations)",
- p.Host, p.From, p.To, pc.Proposer, len(pc.Confirms)))
+ p.Host, p.From, p.To, pc.Proposer, len(pc.Confirms))})
s.bumpSerial(ctx)
return "moved " + p.Host + " to " + p.To, nil
}
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go
index 2d2cf265b..2a097d02e 100644
--- a/internal/console/controlplane.go
+++ b/internal/console/controlplane.go
@@ -34,7 +34,6 @@ import (
"fmt"
"log"
"net/http"
- "net/url"
"runtime/debug"
"strings"
)
@@ -77,7 +76,7 @@ func (s *Server) controlPlaneRefusal(acct *Account, app, verb string) (msg strin
// caller has no page to be already looking at, and a browser flash pointing at
// an absolute URL costs nothing.
func (s *Server) appSettingsURL(acct *Account, app string) string {
- return s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
+ return s.cfg.Origin + appSettingsURL(acct.Sqid, app, "advanced")
}
// refuseControlPlane is controlPlaneRefusal for the JSON doors: it writes the
@@ -219,7 +218,7 @@ func (s *Server) handleAppControlPlaneClear(w http.ResponseWriter, r *http.Reque
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther)
}
// Fail CLOSED on an unreadable row, like every other reader of this flag:
// clearing what may already be clear is harmless, but a lookup error here
@@ -274,7 +273,7 @@ func (s *Server) handleAppControlPlaneSet(w http.ResponseWriter, r *http.Request
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "advanced"), http.StatusSeeOther)
}
cp, _, err := s.store.AppFlags(acct.ID, app)
if err != nil {
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
index ad3780db6..9e13d9fb4 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -55,7 +55,7 @@ func TestControlPlaneAppRefusesTrash(t *testing.T) {
// And WHERE to clear it. A CLI caller has no page in front of them, so a
// refusal that names the flag without naming the card strands exactly the
// operator it just stopped.
- if !strings.Contains(msg, "/a/bab/apps/console/settings") {
+ if !strings.Contains(msg, "/a/bab/apps/console/settings/advanced") {
t.Fatalf("refusal %q does not point at the settings card that can clear the flag", msg)
}
present, err := fx.st.HasAppIn(OpsAccountID, "console")
@@ -343,7 +343,7 @@ func TestOwnerClearsTheControlPlaneFlag(t *testing.T) {
csrf := sessionToken(t, fx, c)
// The page has to OFFER it, or the door might as well not exist.
- if _, _, page := get(t, c, fx.u("/apps/console/settings")); !strings.Contains(page, "/apps/console/control-plane/clear") {
+ if _, _, page := get(t, c, fx.u("/apps/console/settings/advanced")); !strings.Contains(page, "/apps/console/control-plane/clear") {
t.Fatalf("the settings page offers no way to clear the flag:\n%s", page)
}
status, loc, body := postGated(t, fx, c, fx.u("/apps/console/control-plane/clear"),
@@ -356,7 +356,7 @@ func TestOwnerClearsTheControlPlaneFlag(t *testing.T) {
t.Fatalf("AppFlags after a confirmed clear = %v %v, want false", cp, err)
}
// The card is state-driven: with the flag gone, so is the button.
- if _, _, page := get(t, c, fx.u("/apps/console/settings")); strings.Contains(page, "/apps/console/control-plane/clear") {
+ if _, _, page := get(t, c, fx.u("/apps/console/settings/advanced")); strings.Contains(page, "/apps/console/control-plane/clear") {
t.Fatalf("the clear form is still offered on an app that is no longer flagged:\n%s", page)
}
// And the verb that was refused now works — the whole point of the door.
@@ -439,7 +439,7 @@ func TestOwnerSetsTheControlPlaneFlagWithoutAPasskey(t *testing.T) {
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- if _, _, page := get(t, c, fx.u("/apps/titogo/settings")); !strings.Contains(page, "/apps/titogo/control-plane/set") {
+ if _, _, page := get(t, c, fx.u("/apps/titogo/settings/advanced")); !strings.Contains(page, "/apps/titogo/control-plane/set") {
t.Fatalf("the settings page offers no way to set the flag:\n%s", page)
}
status, loc, body := postForm(t, c, fx.u("/apps/titogo/control-plane/set"),
@@ -447,7 +447,7 @@ func TestOwnerSetsTheControlPlaneFlagWithoutAPasskey(t *testing.T) {
if status != http.StatusSeeOther {
t.Fatalf("set the flag = %d -> %q:\n%s", status, loc, body)
}
- if loc != fx.path("/apps/titogo/settings") {
+ if loc != fx.path("/apps/titogo/settings/advanced") {
t.Fatalf("set redirected to %q, want the settings tab — a Keymail redirect means setting grew a passkey it must not have", loc)
}
if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp {
@@ -492,7 +492,7 @@ func TestMemberCannotSetTheControlPlaneFlag(t *testing.T) {
signUp(t, fx, owner, "code-cp-set-owner-probe")
status, loc, _ = postForm(t, owner, fx.u("/apps/titogo/control-plane/set"),
url.Values{"csrf": {sessionToken(t, fx, owner)}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/advanced") {
t.Fatalf("the OWNER on the same URL = %d -> %q, want the handler's redirect — the member's 404 may just be a missing route", status, loc)
}
if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp {
@@ -519,7 +519,7 @@ func TestControlPlaneAppRefusesStoreDestroy(t *testing.T) {
if status != http.StatusSeeOther {
t.Fatalf("destroy a control-plane app's store = %d -> %q:\n%s", status, loc, body)
}
- if loc != fx.path("/apps/console/settings") {
+ if loc != fx.path("/apps/console/settings/storage") {
t.Fatalf("refusal redirected to %q, want the settings tab — a Keymail redirect means the guard ran too late", loc)
}
if rec := storedRecord(t, fx, "console"); rec == nil {
@@ -531,7 +531,7 @@ func TestControlPlaneAppRefusesStoreDestroy(t *testing.T) {
}
status, loc, body = postGated(t, fx, c, fx.u("/apps/console/store/destroy"),
url.Values{"csrf": {csrf}, "confirm": {"console"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/console/settings") {
+ if status != http.StatusSeeOther || loc != fx.path("/apps/console/settings/storage") {
t.Fatalf("destroy after clearing the flag = %d -> %q:\n%s", status, loc, body)
}
if rec := storedRecord(t, fx, "console"); rec != nil {
@@ -925,7 +925,7 @@ func TestControlPlaneAppRefusesInstanceDelete(t *testing.T) {
if !strings.Contains(out, "control plane") {
t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
}
- if !strings.Contains(out, "/a/bab/apps/console/settings") {
+ if !strings.Contains(out, "/a/bab/apps/console/settings/advanced") {
t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
}
// The record survived. This is the assertion the door exists for: the
@@ -1128,7 +1128,7 @@ func TestControlPlaneAppRefusesDomainDetach(t *testing.T) {
if !strings.Contains(out, "control plane") {
t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
}
- if !strings.Contains(out, "/a/bab/apps/kass/settings") {
+ if !strings.Contains(out, "/a/bab/apps/kass/settings/advanced") {
t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
}
claimOf(t, fx, host)
@@ -1181,7 +1181,7 @@ func TestControlPlaneAppRefusesBrowserDomainDetach(t *testing.T) {
if !strings.Contains(body, "control plane") {
t.Fatalf("browser refusal does not name the flag:\n%s", body)
}
- if !strings.Contains(body, "/a/bab/apps/kass/settings") {
+ if !strings.Contains(body, "/a/bab/apps/kass/settings/advanced") {
t.Fatalf("browser refusal does not point at the settings card that can clear the flag:\n%s", body)
}
claimOf(t, fx, host)
@@ -1192,7 +1192,7 @@ func TestControlPlaneAppRefusesBrowserDomainDetach(t *testing.T) {
}
if status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}); status != http.StatusSeeOther ||
- loc != fx.path("/apps/kass/settings") {
+ loc != fx.path("/apps/kass/settings/domains") {
t.Fatalf("browser detach once the flag is cleared = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
}
claimAbsent(t, fx, host)
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index 7e7646e71..d71f53f2f 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -94,7 +94,11 @@ type attachResult struct {
// channelFlag may both be "" — the kind is then inferred (alias if there is
// a route, static otherwise) and the channel is the one the host's own
// existing route follows, or failing that the app's sole shipped channel.
-func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string) (attachResult, *doorRefusal) {
+func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kindFlag, channelFlag, principal string, messages ...string) (attachResult, *doorRefusal) {
+ message := ""
+ if len(messages) != 0 {
+ message = messages[0]
+ }
if ref := s.bucketRefusal(); ref != nil {
return attachResult{}, ref
}
@@ -132,7 +136,10 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin
return attachResult{}, claimRefusal(host, err)
}
if changed {
- s.audit(acct, app, principal, "domain-attach", host+" → "+route)
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "domain-attach", TargetType: "instance", Target: route, Message: message,
+ Detail: host + " → " + route,
+ })
s.bumpSerial(ctx)
}
return attachResult{Claim: claim, Created: created}, nil
@@ -177,7 +184,10 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin
existing, gerr := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
if gerr == nil && attachWrote(*existing) {
if changed {
- s.audit(acct, app, principal, "domain-attach", host+" (first route, "+existing.Channel+", record already present)")
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "domain-attach", TargetType: "instance", Target: host, Message: message,
+ Detail: host + " (first route, " + existing.Channel + ", record already present)",
+ })
s.bumpSerial(ctx)
}
return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: existing.Channel}, nil
@@ -206,7 +216,10 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin
}
// The record wrote (a real change by definition), so this announces
// regardless of whether the claim half was the no-op of a retry.
- s.audit(acct, app, principal, "domain-attach", host+" (first route, "+channel+")")
+ s.auditActivity(acct, app, principal, Activity{
+ Action: "domain-attach", TargetType: "instance", Target: host, Message: message,
+ Detail: host + " (first route, " + channel + ")",
+ })
s.bumpSerial(ctx)
return attachResult{Claim: claim, Created: created, FirstRoute: true, Channel: channel}, nil
}
diff --git a/internal/console/errorsview.go b/internal/console/errorsview.go
index 7eb57ed32..4eea044a0 100644
--- a/internal/console/errorsview.go
+++ b/internal/console/errorsview.go
@@ -228,7 +228,7 @@ func (s *Server) handleAppErrorLevel(w http.ResponseWriter, r *http.Request) {
return
}
level := r.FormValue("level")
- back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/settings"
+ back := appSettingsURL(acct.Sqid, app, "general")
// The setting lives in the deployment bucket, so a console without one
// has nowhere to put it. Refused before the write rather than panicking
// inside it, the way the rename door refuses its own bucket check.
diff --git a/internal/console/mismatchcopy.go b/internal/console/mismatchcopy.go
new file mode 100644
index 000000000..dd9ee8385
--- /dev/null
+++ b/internal/console/mismatchcopy.go
@@ -0,0 +1,23 @@
+package console
+
+type MismatchCopy struct {
+ Summary string
+ Link string
+ Title string
+ Intro string
+ Partial string
+ Repairable string
+ Empty string
+ NoInstances string
+}
+
+var approvedMismatchCopy = MismatchCopy{
+ Summary: "{count} addresses need account review.",
+ Link: "Review account use",
+ Title: "Review account use",
+ Intro: "These addresses use another account’s releases. Review ownership before making changes.",
+ Partial: "This account also has its own instances. Review each address before changing its account.",
+ Repairable: "Review this app’s account in Settings before deciding whether to move it.",
+ Empty: "No addresses need account review.",
+ NoInstances: "No instances yet.",
+}
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index ccae56a0b..9a53152dd 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -615,6 +615,9 @@ func TestAppPageShowsPartialMismatchNotFullAlarm(t *testing.T) {
if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil {
t.Fatal(err)
}
+ if err := fx.st.AddMember(moved, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
// Only ONE of kass's two fixture routes moves (kass.carlosapp.dev);
// docs.kass.carlosapp.dev stays on ops — a partial mismatch, not a full
// one.
@@ -627,7 +630,7 @@ func TestAppPageShowsPartialMismatchNotFullAlarm(t *testing.T) {
if strings.Contains(body, "This app is not deploying") {
t.Errorf("a partial mismatch must not render the full-alarm copy:\n%s", body)
}
- if !strings.Contains(body, "kass.carlosapp.dev") || !strings.Contains(body, "(reads <code>"+sqid+"</code>)") {
- t.Errorf("the partial-mismatch banner does not name the disagreeing route and its account:\n%s", body)
+ if !strings.Contains(body, `/account-review`) {
+ t.Errorf("the partial-mismatch banner does not link to diagnostics:\n%s", body)
}
}
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go
index 7d6b94fca..a52c1b1d5 100644
--- a/internal/console/pendinghandlers.go
+++ b/internal/console/pendinghandlers.go
@@ -544,12 +544,12 @@ func (s *Server) executePendingPromote(ctx context.Context, acct *Account, app,
// flash must say what the confirmer approved, in the house voice the
// direct rollback door already uses — never "promoted vX" for a
// build nobody asked to promote.
- s.audit(acct, app, actor, "rollback", fmt.Sprintf("rolled back %s to %s (proposed by %s, %d confirmations)",
- p.Channel, p.Version, pc.Proposer, len(pc.Confirms)))
+ s.auditActivity(acct, app, actor, Activity{Action: "rollback", TargetType: "channel", Target: p.Channel, Detail: fmt.Sprintf("rolled back %s to %s (proposed by %s, %d confirmations)",
+ p.Channel, p.Version, pc.Proposer, len(pc.Confirms))})
return "Rolled back " + p.Channel + " to " + p.Version + tagSuffix(res) + ".", nil
}
- s.audit(acct, app, actor, "promote", fmt.Sprintf("promoted %s to %s (proposed by %s, %d confirmations)",
- p.Version, p.Channel, pc.Proposer, len(pc.Confirms)))
+ s.auditActivity(acct, app, actor, Activity{Action: "promote", TargetType: "channel", Target: p.Channel, Detail: fmt.Sprintf("promoted %s to %s (proposed by %s, %d confirmations)",
+ p.Version, p.Channel, pc.Proposer, len(pc.Confirms))})
return "Promoted " + p.Version + " to " + p.Channel + tagSuffix(res) + ".", nil
}
diff --git a/internal/console/pipelinepage.go b/internal/console/pipelinepage.go
index 06be92230..371f873b3 100644
--- a/internal/console/pipelinepage.go
+++ b/internal/console/pipelinepage.go
@@ -21,7 +21,6 @@ import (
"fmt"
"log"
"net/http"
- "net/url"
"strconv"
"strings"
@@ -219,6 +218,11 @@ func (s *Server) handlePipelineSettings(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ s.handlePipelineSettingsPage(w, r, p, acct)
+}
+
+func (s *Server) handlePipelineSettingsPage(w http.ResponseWriter, r *http.Request, p page, acct *Account) {
+ setAppSettingsSection(&p, "pipeline")
ctx, cancel := context.WithTimeout(r.Context(), pipelineStoreTimeout)
defer cancel()
pipe, err := release.GetPipeline(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, p.AppName)
@@ -239,6 +243,7 @@ func (s *Server) handlePipelineSettings(w http.ResponseWriter, r *http.Request)
// row, load a template) and for a save that failed, so the human's
// in-progress edit is never silently thrown away.
func (s *Server) renderPipelineSettings(w http.ResponseWriter, p page, rows []PipelineFormRow, errMsg string) {
+ setAppSettingsSection(&p, "pipeline")
p.PipelineRows = rows
p.PipelineIsDefault = pipelineIsDefault(rows)
if errMsg != "" {
@@ -323,7 +328,7 @@ func (s *Server) handlePipelineSettingsSave(w http.ResponseWriter, r *http.Reque
if !s.requireFreshAuth(w, r, addr, raw) {
return
}
- dest := p.Base + "/apps/" + url.PathEscape(app) + "/pipeline"
+ dest := appSettingsURL(acct.Sqid, app, "pipeline")
if _, n := guardedEdit(cur, channels); n > 0 {
kind, msg := s.proposeThenExecute(ctx, acct, app, kindPolicy, policyProposal(cur, channels), n, addr)
s.setFlash(w, kind, msg)
diff --git a/internal/console/server.go b/internal/console/server.go
index 66a238326..b8622d27e 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1113,6 +1113,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/account-review", s.handleAppAccountReview)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs)
@@ -1131,6 +1132,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/analytics/setting", s.handleAppAnalyticsSetting)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings/{section}", s.handleAppSettings)
// The pipeline settings page (pipelinepage.go, release pipelines v2 §5):
// GET renders the current (or default) channel list as an editable
// form; POST goes through the same guarded path the API PUT does.
@@ -1311,6 +1313,9 @@ type page struct {
Welcome bool // one-shot "account created" banner
StatusCopy StatusCopy
+ AccountReview AccountReview
+ MismatchCopy MismatchCopy
+
ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "settings"
ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings"
@@ -1339,6 +1344,7 @@ type page struct {
PromoteChannels []string
Instances *InstanceReport // app page: the app's self-report, nil when unpolled
Events []Event // app page: recent console actions, newest first
+ ActivityCopy activityCopy // app activity: reviewed labels and validation copy
CanPromote bool // a signing key is configured on this host
// Pending is the app page's pending-changes card (release pipelines v2
@@ -1356,6 +1362,11 @@ type page struct {
// templates (Edge → Production, Full ladder) are worth offering.
PipelineRows []PipelineFormRow
PipelineIsDefault bool
+ SettingsSection string // app Settings sub-page selected by the URL
+ SettingsRenameTo string // retained draft after a General form refusal
+ SettingsDomain string // retained Domains attach draft
+ SettingsChannel string // retained Domains attach draft
+ SettingsNav []SettingsSectionLink
// App page: how a canary channel gets a URL, rendered as commands the
// reader can actually run from where they are reading. Computed
@@ -1587,6 +1598,11 @@ type page struct {
ServiceSecret *ServiceSecretView
}
+type SettingsSectionLink struct {
+ Key, Label, Href string
+ Current bool
+}
+
type appLogsCopy struct {
Instance, AllInstances, Search, AllLevels, Follow, Pause, Widen, FollowError, Refreshed string
}
@@ -1614,6 +1630,7 @@ type appRow struct {
func (s *Server) render(w http.ResponseWriter, status int, name string, p page) {
p.StatusCopy = approvedStatusCopy
+ p.MismatchCopy = approvedMismatchCopy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := templates.ExecuteTemplate(w, name, p); err != nil {
@@ -2601,10 +2618,34 @@ func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (pa
p.ActiveTab = tab
p.ActiveNav = "apps"
p.Detail = &views[0]
+ if review, all, err := s.appAccountReview(addr, acct, app); err != nil {
+ log.Printf("console: account review %s/%s: %v", acct.Sqid, app, err)
+ } else {
+ p.AccountReview = review
+ p.AccountReview.NoInstances = review.Owned == 0 && hasPublishedChannel(p.Detail.Channels)
+ for _, group := range review.Groups {
+ for _, host := range group.Hosts {
+ p.Detail.Mismatched = append(p.Detail.Mismatched, MismatchedRoute{Host: host, Account: group.Account.Sqid})
+ }
+ }
+ p.Detail.AllMismatched = all
+ if all {
+ p.Detail.RouteAccount = review.Groups[0].Account.Sqid
+ }
+ }
s.applyPipelineView(r.Context(), p.Detail, acct)
return p, acct, true
}
+func hasPublishedChannel(channels []ChannelView) bool {
+ for _, channel := range channels {
+ if channel.Err == "" && channel.Version != "" {
+ return true
+ }
+ }
+ return false
+}
+
// applyPipelineView is the one place a single app's view becomes
// pipeline-aware (release pipelines v2 §2, §5) — deliberately not inside
// Assemble (AppView.Pipeline's own doc comment: the account home page calls
@@ -3061,9 +3102,31 @@ func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ p.ActivityCopy = activityText
if events, err := s.store.Events(acct.ID, p.AppName, activityCap); err != nil {
log.Printf("console: events %s: %v", p.AppName, err)
} else {
+ // Only link targets this account's assembled view proves still exist.
+ // Deleted instances and same-named routes owned by another account stay
+ // plain text; an activity link must never cross the membership boundary.
+ instances := make(map[string]bool)
+ for _, route := range p.Detail.Routes {
+ if route.Account == acct.Sqid {
+ instances[route.Host] = true
+ }
+ }
+ for i := range events {
+ if events[i].TargetType == "release" && events[i].Target != "" {
+ events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/releases#version-" + url.QueryEscape(events[i].Target)
+ }
+ if events[i].TargetType == "channel" && events[i].Target != "" {
+ events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/deployments"
+ }
+ if events[i].TargetType == "instance" && instances[events[i].Target] {
+ events[i].TargetURL = p.Base + "/apps/" + url.PathEscape(p.AppName) +
+ "/instances/" + url.PathEscape(events[i].Target)
+ }
+ }
p.Events = events
}
s.render(w, http.StatusOK, "app_activity", p)
@@ -3077,7 +3140,47 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- p.Settings = s.assembleSettings(r.Context(), &p, acct)
+ section := r.PathValue("section")
+ if section == "" {
+ section = "general"
+ }
+ if !appSettingsSections[section] {
+ http.NotFound(w, r)
+ return
+ }
+ if section == "pipeline" {
+ s.handlePipelineSettingsPage(w, r, p, acct)
+ return
+ }
+ setAppSettingsSection(&p, section)
+ s.populateAppSettings(r, &p, acct)
+ s.render(w, http.StatusOK, "app_settings", p)
+}
+
+func setAppSettingsSection(p *page, section string) {
+ p.SettingsSection = section
+ for _, item := range []struct{ key, label string }{
+ {"general", "General"},
+ {"domains", "Domains"},
+ {"pipeline", "Release pipeline"},
+ {"storage", "Storage"},
+ {"email", "Email"},
+ {"features", "Feature flags"},
+ {"advanced", "Advanced"},
+ {"delete", "Delete"},
+ } {
+ href := p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/settings"
+ if item.key != "general" {
+ href += "/" + item.key
+ }
+ p.SettingsNav = append(p.SettingsNav, SettingsSectionLink{
+ Key: item.key, Label: item.label, Href: href, Current: item.key == section,
+ })
+ }
+}
+
+func (s *Server) populateAppSettings(r *http.Request, p *page, acct *Account) {
+ p.Settings = s.assembleSettings(r.Context(), p, acct)
// The Errors log level this app resolves to, and where it comes from —
// the one setting the tab itself cannot change. A failed read is said
// rather than defaulted (see ErrLevelUnread), and never fails a page
@@ -3091,7 +3194,20 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
// The analytics switch is the OTHER half of that same resolved object —
// one settings file, one read — under the name its own section uses.
p.AnalyticsSetting, p.AnalyticsUnread = p.ErrLevelSetting, p.ErrLevelUnread
- s.render(w, http.StatusOK, "app_settings", p)
+}
+
+func (s *Server) renderAppSettingsForm(w http.ResponseWriter, r *http.Request, status int, section, msg string, drafts map[string]string) {
+ p, acct, ok := s.appPage(w, r, "settings")
+ if !ok {
+ return
+ }
+ setAppSettingsSection(&p, section)
+ p.FlashErr = msg
+ p.SettingsRenameTo = drafts["rename"]
+ p.SettingsDomain = drafts["domain"]
+ p.SettingsChannel = drafts["channel"]
+ s.populateAppSettings(r, &p, acct)
+ s.render(w, status, "app_settings", p)
}
// activityCap bounds the activity tab's own listing.
@@ -3105,6 +3221,14 @@ func (s *Server) audit(acct *Account, app, addr, action, detail string) {
}
}
+// auditActivity records server-derived structure separately from optional
+// caller context. Authorisation and mutation must already have succeeded.
+func (s *Server) auditActivity(acct *Account, app, addr string, activity Activity) {
+ if err := s.store.RecordActivity(acct.ID, app, addr, activity, time.Now()); err != nil {
+ log.Printf("console: audit %s %s: %v", activity.Action, app, err)
+ }
+}
+
// ensureAccountManifest is the view-time backfill for account identity: write
// accounts/<sqid>/account.json only when none exists. An existing manifest is
// what every box has already materialized — a page view must never clobber
@@ -3572,7 +3696,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
back("ok", channel+" already serves "+version+tagSuffix(res)+" — nothing to do.")
return
}
- s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel)
+ s.auditActivity(acct, app, addr, Activity{Action: "promote", TargetType: "channel", Target: channel, Detail: "promoted " + version + " to " + channel})
msg := "Promoted " + version + " to " + channel + tagSuffix(res) + "."
// Whether this app bakes at all is the production flag's business (bake
// controls spec §2): a non-production app's boxes adopt a held channel
@@ -3798,7 +3922,7 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
back("err", "Rollback failed: "+err.Error())
return
}
- s.audit(acct, app, addr, "rollback", "rolled back "+channel+" to "+target)
+ s.auditActivity(acct, app, addr, Activity{Action: "rollback", TargetType: "channel", Target: channel, Detail: "rolled back " + channel + " to " + target})
s.bumpSerial(ctx)
back("ok", "Rolled back "+channel+" to "+target+".")
}
@@ -4080,7 +4204,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
back("err", "Creating the canary failed: "+err.Error())
return
}
- s.audit(acct, app, addr, "canary-create", "created canary "+name+" at "+version)
+ s.auditActivity(acct, app, addr, Activity{Action: "canary-create", TargetType: "channel", Target: "canary/" + name, Detail: "created canary " + name + " at " + version})
// A channel pointer like any other: a route already following canary/<name>
// (attach-then-create is a real order) adopts it on the announced pass.
s.bumpSerial(ctx)
@@ -4218,7 +4342,7 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
back("err", "Retiring the canary failed: "+err.Error())
return
}
- s.audit(acct, app, addr, "canary-retire", "retired canary "+name)
+ s.auditActivity(acct, app, addr, Activity{Action: "canary-retire", TargetType: "channel", Target: "canary/" + name, Detail: "retired canary " + name})
back("ok", "Retired canary "+name+" — its channel pointer is gone; shipped releases are untouched.")
}
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index 700346616..cac0c3935 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -684,6 +684,9 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil {
t.Fatal(err)
}
+ if err := fx.st.AddMember(moved, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
for _, host := range []string{"kass.carlosapp.dev", "docs.kass.carlosapp.dev"} {
if err := reg.SetAccount(host, sqid); err != nil {
t.Fatal(err)
@@ -692,23 +695,17 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
reg.Close()
_, _, body = get(t, c, fx.u("/apps/kass"))
- if !strings.Contains(body, "not deploying") {
+ if !strings.Contains(body, `/apps/kass/account-review`) {
t.Errorf("no warning on a half-migrated app:\n%s", body)
}
- if !strings.Contains(body, `/apps/kass/settings"`) {
- t.Errorf("the warning does not link to the Settings tab, where the fix now lives:\n%s", body)
- }
// The promote/rollback/canary forms live on the releases tab, not the
// overview — a deep link there must not promote blind. Same warning,
// same fix, on the page that actually hosts the mutations.
_, _, body = get(t, c, fx.u("/apps/kass/releases"))
- if !strings.Contains(body, "not deploying") {
+ if !strings.Contains(body, `/apps/kass/account-review`) {
t.Errorf("no warning on the releases tab of a half-migrated app:\n%s", body)
}
- if !strings.Contains(body, `/apps/kass/settings"`) {
- t.Errorf("the releases tab warning does not link to the Settings tab, where the fix now lives:\n%s", body)
- }
// Settings itself holds the actual repair. paul owns the target account
// here (CreateAccount above makes its creator the owner), so he can reach
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index c81c8a013..a5aa219be 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -272,7 +272,7 @@ func TestConsoleE2E(t *testing.T) {
}
// Settings tab: the app's domains.
- status, _, sbody := get(t, c, fx.u("/apps/kass/settings"))
+ status, _, sbody := get(t, c, fx.u("/apps/kass/settings/domains"))
if status != 200 {
t.Fatalf("/apps/kass/settings = %d", status)
}
@@ -1343,7 +1343,6 @@ func TestRecentActivity(t *testing.T) {
"paul@keymail.dev",
"promoted v3 to edge", "rolled back stable to v1",
"created canary rc at v1", "retired canary rc",
- "CLI promotions don",
} {
if !strings.Contains(body, want) {
t.Fatalf("activity missing %q in:\n%s", want, body)
@@ -1382,7 +1381,7 @@ func TestActivityTab(t *testing.T) {
if status != 200 {
t.Fatalf("GET activity tab = %d", status)
}
- for _, want := range []string{`aria-current="page">Activity</a>`, "CLI promotions don"} {
+ for _, want := range []string{`aria-current="page">Activity</a>`} {
if !strings.Contains(body, want) {
t.Fatalf("activity tab missing %q", want)
}
@@ -1406,12 +1405,16 @@ func TestAppSettingsTab(t *testing.T) {
t.Fatalf("GET app settings = %d", status)
}
for _, want := range []string{
- `aria-current="page">Settings</a>`, "<h2>Identity</h2>", "Domains &amp; TLS",
+ `aria-current="page">Settings</a>`, "<h2>Identity</h2>",
} {
if !strings.Contains(body, want) {
t.Fatalf("app settings missing %q", want)
}
}
+ _, _, domains := get(t, c, fx.u("/apps/kass/settings/domains"))
+ if !strings.Contains(domains, "Domains &amp; TLS") {
+ t.Fatal("app Domains settings section is missing its heading")
+ }
assertNonMember404(t, fx, "/apps/kass/settings")
}
@@ -1425,7 +1428,7 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
t.Fatal("keyless console is missing the disabled note")
}
// The Domains section is a read — keyless (read-only) mode still renders it.
- if _, _, sbody := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") {
+ if _, _, sbody := get(t, c, fx.u("/apps/kass/settings/domains")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") {
t.Fatal("keyless console is missing the Domains section")
}
_, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
@@ -1671,7 +1674,7 @@ func TestAppPageRendersDomains(t *testing.T) {
fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance",
Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ _, _, body := get(t, c, fx.u("/apps/kass/settings/domains"))
for _, want := range []string{
"Domains", "kass.bab.carlosapp.dev", "platform", "pointing",
"kass.carlosapp.dev", "custom", "—",
diff --git a/internal/console/statuscopy_test.go b/internal/console/statuscopy_test.go
index e5ebf4a3b..eaa0321ee 100644
--- a/internal/console/statuscopy_test.go
+++ b/internal/console/statuscopy_test.go
@@ -63,7 +63,7 @@ func TestStaticOnlyPagesDoNotClaimAMissingVersionReport(t *testing.T) {
t.Fatal(err)
}
body := out.String()
- if !strings.Contains(body, approvedStatusCopy.NoInstances) || strings.Contains(body, approvedStatusCopy.NoVersion) {
+ if !strings.Contains(body, "<p>—</p>") || strings.Contains(body, approvedStatusCopy.NoInstances) || strings.Contains(body, approvedStatusCopy.NoVersion) {
t.Errorf("%s rendered the wrong non-reportable state", name)
}
}
diff --git a/internal/console/store.go b/internal/console/store.go
index a6521b7b5..a1099538a 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -226,6 +226,11 @@ func (s *Store) migrate() error {
// ops, which is where every pre-accounts app lived — so no history is
// lost and none of it lands in somebody else's account.
`ALTER TABLE audit ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1;`,
+ // Structured activity is additive to the original prose-only detail.
+ // Empty fields mean an old row; Detail remains its lossless fallback.
+ `ALTER TABLE audit ADD COLUMN target_type TEXT NOT NULL DEFAULT '';`,
+ `ALTER TABLE audit ADD COLUMN target TEXT NOT NULL DEFAULT '';`,
+ `ALTER TABLE audit ADD COLUMN message TEXT NOT NULL DEFAULT '';`,
// Trash state for apps (30-day cooloff; the row IS the name
// reservation, so it survives trash and dies at purge) and tombstone
// MEMORY for accounts: a marked account row is never re-materialized,
@@ -1822,12 +1827,20 @@ func (s *Store) TakeConfirmation(rawToken string, now time.Time) (string, bool)
// ---- audit (recent activity) ------------------------------------------------
// Event is one audit row: something an owner did to an app through the
-// console. The audit is the console's own record of its own mutations —
-// platform state stays in the registry and the bucket, and promotions made
-// with the CLI never appear here.
+// console, in the browser or through its API. Platform state stays in the
+// registry and the bucket; direct-bucket mutations do not enter this trail.
type Event struct {
- App, Address, Action, Detail string
- CreatedAt time.Time
+ App, Address, Action, TargetType, Target, Message, Detail string
+ TargetURL string
+ CreatedAt time.Time
+}
+
+// Activity is the structured half of an audit row. Action and target are
+// chosen by the server after it has authorised and applied the mutation;
+// Message is optional context supplied by the person making it. Detail is
+// retained for rows and callers which predate the structured shape.
+type Activity struct {
+ Action, TargetType, Target, Message, Detail string
}
// RecordEvent appends one audit row. Callers treat it as best-effort:
@@ -1838,15 +1851,24 @@ type Event struct {
// other's actions — including the other account's member addresses, which is
// a membership leak dressed up as a convenience.
func (s *Store) RecordEvent(accountID int64, app, address, action, detail string, now time.Time) error {
- _, err := s.db.Exec(`INSERT INTO audit (account_id, app, address, action, detail, created_at)
-VALUES (?, ?, ?, ?, ?, ?)`, accountID, app, address, action, detail, now.Unix())
+ return s.RecordActivity(accountID, app, address, Activity{Action: action, Detail: detail}, now)
+}
+
+// RecordActivity appends a structured audit row. The account and app remain
+// separate arguments so callers cannot accidentally move either into
+// user-controlled metadata.
+func (s *Store) RecordActivity(accountID int64, app, address string, activity Activity, now time.Time) error {
+ _, err := s.db.Exec(`INSERT INTO audit
+ (account_id, app, address, action, target_type, target, message, detail, created_at)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, accountID, app, address, activity.Action,
+ activity.TargetType, activity.Target, activity.Message, activity.Detail, now.Unix())
return err
}
// Events returns one account's audit rows for an app, newest first, capped at
// limit.
func (s *Store) Events(accountID int64, app string, limit int) ([]Event, error) {
- rows, err := s.db.Query(`SELECT app, address, action, detail, created_at
+ rows, err := s.db.Query(`SELECT app, address, action, target_type, target, message, detail, created_at
FROM audit WHERE account_id = ? AND app = ? ORDER BY id DESC LIMIT ?`, accountID, app, limit)
if err != nil {
return nil, err
@@ -1856,7 +1878,8 @@ FROM audit WHERE account_id = ? AND app = ? ORDER BY id DESC LIMIT ?`, accountID
for rows.Next() {
var e Event
var ts int64
- if err := rows.Scan(&e.App, &e.Address, &e.Action, &e.Detail, &ts); err != nil {
+ if err := rows.Scan(&e.App, &e.Address, &e.Action, &e.TargetType, &e.Target,
+ &e.Message, &e.Detail, &ts); err != nil {
return nil, err
}
e.CreatedAt = time.Unix(ts, 0).UTC()
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
index c78ba5c52..046880143 100644
--- a/internal/console/store_test.go
+++ b/internal/console/store_test.go
@@ -276,6 +276,31 @@ func TestAuditEvents(t *testing.T) {
}
}
+func TestStructuredActivityRoundTrip(t *testing.T) {
+ s := openTestStore(t)
+ now := time.Date(2026, 9, 8, 9, 30, 0, 0, time.UTC)
+ want := Activity{
+ Action: "instance-create", TargetType: "instance",
+ Target: "web.example.test", Message: "add the public web copy",
+ Detail: "created instance web.example.test (cli)",
+ }
+ if err := s.RecordActivity(OpsAccountID, "kass", "paul@keymail.dev", want, now); err != nil {
+ t.Fatal(err)
+ }
+ events, err := s.Events(OpsAccountID, "kass", 1)
+ if err != nil || len(events) != 1 {
+ t.Fatalf("Events = %+v, %v", events, err)
+ }
+ got := events[0]
+ if got.Action != want.Action || got.TargetType != want.TargetType || got.Target != want.Target ||
+ got.Message != want.Message || got.Detail != want.Detail {
+ t.Fatalf("event = %+v, want activity %+v", got, want)
+ }
+ if !got.CreatedAt.Equal(now) {
+ t.Fatalf("CreatedAt = %v, want %v", got.CreatedAt, now)
+ }
+}
+
func TestOperatorRoundTrip(t *testing.T) {
s := openTestStore(t)
if op, err := s.IsOperator("paul@keymail.dev"); err != nil || op {
diff --git a/internal/console/templates/account_review.html b/internal/console/templates/account_review.html
new file mode 100644
index 000000000..717f81702
--- /dev/null
+++ b/internal/console/templates/account_review.html
@@ -0,0 +1,24 @@
+{{define "accountreviewbanner"}}{{if .AccountReview.Count}}
+<p class="banner">{{.AccountReview.SummaryText}}
+ <a href="{{.Nav}}/apps/{{.AppName}}/account-review">{{.MismatchCopy.Link}}</a>
+</p>
+{{else if .AccountReview.NoInstances}}
+<p class="banner">{{.MismatchCopy.NoInstances}}
+ <a href="{{.Nav}}/apps/{{.AppName}}/instances">Instances</a>
+</p>
+{{end}}{{end}}
+
+{{define "app_account_review"}}{{template "top" .}}
+{{template "appnav" .}}
+<h2>{{.MismatchCopy.Title}}</h2>
+<p>{{.MismatchCopy.Intro}}</p>
+{{if .AccountReview.Groups}}
+ {{range .AccountReview.Groups}}
+ <h3>{{.Account.Name}} (<code>{{.Account.Sqid}}</code>)</h3>
+ <ul>{{range .Hosts}}<li><code>{{.}}</code></li>{{end}}</ul>
+ {{end}}
+ {{if .Detail.AllMismatched}}<p>{{.MismatchCopy.Repairable}}</p>{{else if .AccountReview.Owned}}<p>{{.MismatchCopy.Partial}}</p>{{end}}
+{{else}}
+<p>{{.MismatchCopy.Empty}}</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_activity.html b/internal/console/templates/app_activity.html
index 10c8ea993..e4e0a00a8 100644
--- a/internal/console/templates/app_activity.html
+++ b/internal/console/templates/app_activity.html
@@ -7,12 +7,15 @@
{{if .Events}}
<div class="tablewrap">
<table>
- <tr><th>When</th><th>Who</th><th>What</th></tr>
+ <tr><th>When</th><th>Who</th><th>{{.ActivityCopy.Description}}</th><th>What</th></tr>
{{range .Events}}
- <tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Address}}</td><td>{{.Detail}}</td></tr>
+ <tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Address}}</td>
+ <td>{{if .Message}}{{.Message}}{{else}}—{{end}}</td>
+ <td>{{if .TargetURL}}<a href="{{.TargetURL}}">{{.Detail}}</a>{{else}}{{.Detail}}{{end}}</td>
+ </tr>
{{end}}
</table>
</div>
{{else}}<p class="small">Nothing yet — promotes, rollbacks and canaries made in this console will show here.</p>{{end}}
-<p class="small">Actions taken through this console only — CLI promotions don't appear here.</p>
+<p class="small">{{.ActivityCopy.Scope}}</p>
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 7d01bbfe6..a97a68432 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -2,34 +2,15 @@
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
-{{if .Detail.AccountMismatch}}
-{{if .Detail.AllMismatched}}
-<p class="banner error">
- <strong>This app is not deploying.</strong> Every one of its addresses reads
- releases from another account, but this page writes them to
- <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Affected: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
- Finish the move on this app's
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
- <span class="small">If it is the routes that are in the wrong account, move
- those back instead — whichever of the two is right, they have to agree.</span>
-</p>
-{{else}}
-<p class="banner">
- {{len .Detail.Mismatched}} address{{if gt (len .Detail.Mismatched) 1}}es{{end}} on this app
- read{{if eq (len .Detail.Mismatched) 1}}s{{end}} releases from another account and won't
- follow promotes made here: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
- The rest deploy normally. Fix on
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
-</p>
-{{end}}
-{{end}}
+{{template "accountreviewbanner" .}}
{{with .Overview}}
<section class="pagehead" aria-label="App status">
<p><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></p>
<h2>{{$.StatusCopy.ObservedLabel}}</h2>
- {{if not .Deployment.HasReportable}}
+ {{if not .Deployment.HasRoutes}}
<p>{{$.StatusCopy.NoInstances}}</p>
+ {{else if not .Deployment.HasReportable}}
+ <p>—</p>
{{else if not .Deployment.HasReportedVersion}}
<p>{{$.StatusCopy.NoVersion}}</p>
{{else}}
@@ -56,7 +37,7 @@
<p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p>
<p class="tile-detail">{{if .CPUSum}}CPU · memory, from the copies responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p>
</a>
- <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings/domains">
<h2>Web addresses</h2>
<p class="tile-value">{{.DomainTotal}}</p>
<p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}DNS &amp; certs OK{{end}}</p>
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 755c7251d..f703e26ab 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -2,34 +2,15 @@
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
-{{if .Detail.AccountMismatch}}
-{{if .Detail.AllMismatched}}
-<p class="banner error">
- <strong>This app is not deploying.</strong> Every one of its addresses reads
- releases from another account, but this page writes them to
- <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Affected: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
- Finish the move on this app's
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
- <span class="small">If it is the routes that are in the wrong account, move
- those back instead — whichever of the two is right, they have to agree.</span>
-</p>
-{{else}}
-<p class="banner">
- {{len .Detail.Mismatched}} address{{if gt (len .Detail.Mismatched) 1}}es{{end}} on this app
- read{{if eq (len .Detail.Mismatched) 1}}s{{end}} releases from another account and won't
- follow promotes made here: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
- The rest deploy normally. Fix on
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
-</p>
-{{end}}
-{{end}}
+{{template "accountreviewbanner" .}}
{{with .ReleaseSummary}}
<section class="pagehead" aria-label="Release status">
<h2>{{$.StatusCopy.ObservedLabel}}</h2>
- {{if not .Deployment.HasReportable}}
+ {{if not .Deployment.HasRoutes}}
<p>{{$.StatusCopy.NoInstances}}</p>
+ {{else if not .Deployment.HasReportable}}
+ <p>—</p>
{{else if not .Deployment.HasReportedVersion}}
<p>{{$.StatusCopy.NoVersion}}</p>
{{else}}
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index f6cbb790b..f1cd232d1 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -3,6 +3,12 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
+<div class="settings-layout">
+<nav class="settings-nav" aria-label="Settings sections">
+ {{range .SettingsNav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}
+</nav>
+<div class="settings-content">
+{{if eq .SettingsSection "general"}}
<h2>Identity</h2>
<div class="tablewrap">
<table>
@@ -10,7 +16,7 @@
<tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr>
</table>
</div>
-<p class="small"><a href="{{.Base}}/apps/{{.Detail.App}}/pipeline">Release pipeline</a> — the channels a
+<p class="small"><a href="{{.Nav}}/apps/{{.Detail.App}}/settings/pipeline">Release pipeline</a> — the channels a
release moves through, and what each one takes to promote onto.</p>
{{if .IsOwner}}
{{if .Settings.ControlPlane}}
@@ -20,8 +26,8 @@ refused while the flag is set, and a service credential cannot promote it.</p>
{{else if .Settings.CanRename}}
<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/rename" class="settings-row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
- <label class="visually-hidden" for="rename-to">New name</label>
- <input type="text" id="rename-to" name="to" placeholder="new-name"
+ <label for="rename-to">New name</label>
+ <input type="text" id="rename-to" name="to" value="{{.SettingsRenameTo}}" placeholder="new-name"
required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">
<button type="submit" class="btn-sm">Rename</button>
</form>
@@ -33,6 +39,8 @@ bucket keys and stays.</p>
{{end}}
{{end}}
+{{end}}
+{{if eq .SettingsSection "domains"}}
<h2>Domains &amp; TLS</h2>
{{if .Settings.Domains}}
<div class="tablewrap">
@@ -70,11 +78,11 @@ bucket keys and stays.</p>
at its platform domain; attaching adds addresses, it never removes one.</p>{{end}}
<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/domains/attach" class="settings-row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
- <label class="visually-hidden" for="attach-host">Domain</label>
- <input type="text" id="attach-host" name="host" placeholder="www.example.com" required>
+ <label for="attach-host">Domain</label>
+ <input type="text" id="attach-host" name="host" value="{{.SettingsDomain}}" placeholder="www.example.com" required>
{{if .Settings.NeedsChannel}}
- <label class="visually-hidden" for="attach-channel">Channel</label>
- <input type="text" id="attach-channel" name="channel" placeholder="channel (e.g. stable)">
+ <label for="attach-channel">Channel</label>
+ <input type="text" id="attach-channel" name="channel" value="{{.SettingsChannel}}" placeholder="channel (e.g. stable)">
{{end}}
<button type="submit" class="btn-sm">Attach domain</button>
</form>
@@ -122,6 +130,8 @@ page asking for it and stay signed in for a week. It is set per instance.</p>
</div>
{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}}
+{{end}}
+{{if eq .SettingsSection "general"}}
{{if and .Settings .Settings.MismatchTarget}}
<h2>Account</h2>
<p>This app's hosts read releases from account
@@ -140,6 +150,9 @@ console does not know{{else}}You are not a member of that account{{end}} —
the operator path is <code>console migrate-app --app {{.Detail.App}} --to
{{.Settings.MismatchTarget}}</code> on the console's box.</p>
{{end}}
+{{else if .AccountReview.NoInstances}}
+<h2>Account</h2>
+<p>{{.MismatchCopy.NoInstances}} <a href="{{.Nav}}/apps/{{.Detail.App}}/instances">Instances</a></p>
{{else}}{{if .IsOwner}}
<h2>Account</h2>
<p class="small">This app deploys from {{.Account.Name}}. Moving an app to a
@@ -147,6 +160,10 @@ different account moves its bucket objects and re-stamps its routes —
<code>carlos accounts migrate</code>, an operator act; once run, this page
offers the final console-record step.</p>
{{end}}{{end}}
+{{end}}
+{{if eq .SettingsSection "storage"}}
+{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot serve object storage</p>{{end}}
+{{if .Settings.StoreUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
{{if .Settings}}{{with .Settings.Store}}
<h2>Object storage</h2>
{{if or .Declared .Granted .Provisioned}}
@@ -203,6 +220,8 @@ credentials — an operator of this deployment has to tear it down.</p>
and delivers its credentials into this app's config.</p>
{{end}}
{{end}}{{end}}
+{{end}}
+{{if eq .SettingsSection "general"}}
<h2>Errors</h2>
{{if .ErrLevelUnread}}
<p class="small">This app&rsquo;s Errors log level could not be read just now, so it is not shown
@@ -252,6 +271,10 @@ JavaScript, no cookie, no stored IP. A visitor is a salted hash that changes eve
joined across days. Whether that needs consent where your visitors are is your call, which is why it
is off until you turn it on.</p>
+{{end}}
+{{if eq .SettingsSection "features"}}
+{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot read features</p>{{end}}
+{{if .Settings.FeaturesUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
{{if .Settings}}{{with .Settings.Features}}
<h2>Feature flags</h2>
{{if .Flags}}
@@ -273,6 +296,10 @@ lands on its next poll — no restart.</p>
<code>carlos features set --app {{$.Detail.App}} key=value</code>
(<code>key=</code> clears); every change is audited under Activity.</p>
{{end}}{{end}}
+{{end}}
+{{if eq .SettingsSection "email"}}
+{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot serve email</p>{{end}}
+{{if .Settings.MailUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
{{if .Settings}}{{with .Settings.Mail}}
<h2>Email</h2>
{{if or .Declared .Granted .Provisioned .Standalone}}
@@ -337,6 +364,8 @@ none on the record; it was handed over once, at creation.</p>
{{end}}
{{end}}
{{end}}{{end}}
+{{end}}
+{{if eq .SettingsSection "advanced"}}
{{if and .Settings .IsOwner}}
<h2 class="danger-head">Control plane</h2>
@@ -367,6 +396,8 @@ exactly as before.</p>
this same card.</p>
{{end}}
{{end}}
+{{end}}
+{{if eq .SettingsSection "delete"}}
{{if .IsOwner}}
<h2 class="danger-head">Delete this app</h2>
@@ -384,4 +415,7 @@ blocks deletion — turn the safety delay off on the Versions tab first.{{end}}<
</form>
<p class="small">Confirms with your passkey.</p>
{{end}}
+{{end}}
+</div>
+</div>
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 2c8be0b8b..69a2b123c 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -129,6 +129,21 @@
/* Settings: an input-and-button row, and the deletion section's heading
in the ink that says be careful. */
.settings-row { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin: 0 0 0.75rem; }
+ .settings-layout {
+ display: grid; grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
+ gap: 2.5rem; align-items: start;
+ }
+ .settings-nav { display: flex; flex-direction: column; gap: 0.2rem; }
+ .settings-nav a {
+ display: block; padding: 0.45rem 0.65rem; border-radius: 0.35rem;
+ color: var(--muted); text-decoration: none;
+ }
+ .settings-nav a:hover { color: var(--ink); background: var(--accent-soft); }
+ .settings-nav a[aria-current="page"] {
+ color: var(--ink); background: var(--accent-soft); font-weight: 600;
+ }
+ .settings-content { min-width: 0; }
+ .settings-content > h2:first-child { margin-top: 0; }
.danger-head { color: var(--bad); }
p { max-width: 40rem; margin: 0 0 1rem; }
.lede { font-size: 1.15rem; color: var(--muted); }
@@ -336,6 +351,12 @@
/* Narrow screens: the same path, turned down the page. The connector turns
with it — an arrow still pointing the way the version travels. */
@media (max-width: 46rem) {
+ .settings-layout { display: block; }
+ .settings-nav {
+ flex-direction: row; overflow-x: auto; margin: 0 0 1.5rem;
+ padding-bottom: 0.25rem;
+ }
+ .settings-nav a { white-space: nowrap; }
.flow-line { display: block; }
.flow-stage + .flow-stage { margin-top: 1.6rem; }
.flow-stage + .flow-stage::before {
diff --git a/internal/console/templates/pipeline.html b/internal/console/templates/pipeline.html
index 1fd9f6a38..dea50e7d6 100644
--- a/internal/console/templates/pipeline.html
+++ b/internal/console/templates/pipeline.html
@@ -3,6 +3,11 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
+<div class="settings-layout">
+<nav class="settings-nav" aria-label="Settings sections">
+ {{range .SettingsNav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}}
+</nav>
+<div class="settings-content">
<h2>Release pipeline</h2>
<p class="small">The ordered list of channels a release moves through — the first is where
<code>carlos deploy</code> lands. Each channel can ask for a passkey and a number of members
@@ -57,4 +62,6 @@ from scratch with the row below. Nothing is written until you press Save.</p>
bypasses the order but never the rules. An edit that touches a channel the SAVED pipeline
already guards with change approvals becomes a proposal here too, at the quorum that channel
already demands.</p>
+</div>
+</div>
{{template "bottom" .}}{{end}}
diff --git a/internal/console/view.go b/internal/console/view.go
index 12dffc493..7b25d9f20 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -106,19 +106,11 @@ func (v AppView) RollbackChannels() []ChannelView {
return out
}
-// MismatchedRoute is one route whose stamped account disagrees with the
-// account whose page is being viewed: host plus where it actually reads
-// releases from. The banner names these — a warning that says WHICH routes
-// is a diagnostic; one that says "this app" is an alarm, and on tito
-// (2026-08-10) it was a false one.
+// MismatchedRoute is a viewer-safe account-review candidate assembled by the
+// server after authorization. Assemble itself exposes owned routes only.
type MismatchedRoute struct{ Host, Account string }
-// AccountMismatch reports the half-migrated state that would otherwise be
-// silent: `carlos accounts migrate` re-stamped this app's routes onto another
-// account, and `console migrate-app` was never run, so the console promotes
-// into one account's bucket keys while every host reads another's. A promote
-// then flashes "Promoted" and deploys nothing, forever, with no error
-// anywhere. The page has to say so.
+// AccountMismatch reports whether the server found account-review candidates.
func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 }
// DomainView is one row of the app page's Domains table — the product
@@ -540,58 +532,22 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
if err != nil {
return nil, err
}
- // byApp is keyed on app NAME with no account filter, so two accounts that
- // happen to name an app the same way share these buckets — pre-existing
- // (Host/Version/Channel/Status already cross that boundary, which is why
- // the RouteAccount mismatch machinery below exists) and gated: the app
- // page runs session → membership → HasAppIn → byte-identical 404 before
- // reaching here, so this is not a non-member leak. Recorded because the
- // blast radius grew rather than the gate weakening (2026-08-02 review,
- // MINOR 8): the Environment chip now shows one tenant's bundle NAMES to
- // the other, and prodWiredCanaryHosts' stable-environment set reads
- // across the same boundary, so one tenant's stable route can make
- // another's canary badge prod-wired. Capabilities joined that list
- // (2026-08-03 review, I4): Caps is the ladder's "granted: host-role,
- // port:25" line, which says which privileged ports and which box
- // authority an operator has handed one tenant's route — the most
- // sensitive thing this bleed has carried so far. The fix is at this
- // source — filter by release.RouteAccount(rt) == acct — not further
- // downstream.
- //
- // That whole-view filter stays a separate change, because it also decides
- // what the half-migrated AccountMismatch banner reads from. Until then,
- // Caps ALONE is scoped here, at the one place a RouteView is built: a
- // route belonging to another account arrives with Caps blank, so the
- // ladder renders no granted line for it (ladder.go copies this field
- // verbatim, and `{{if .Caps}}` in ladder.html renders nothing for ""),
- // while every other cross-account field keeps behaving exactly as it did.
- // Scoping it HERE rather than in handleApp is not a style choice: Ladder
- // is assembled inside this function, so a post-Assemble blank in the
- // handler would leave every LadderRoute's copy untouched — which is the
- // only place Caps renders at all.
+ // Routes are scoped here, before any downstream view is assembled. App names
+ // are account-local, so grouping by name first would mix legitimate sibling
+ // apps and leak their host, environment, capability and health details into
+ // one another's pages. Account-review candidates are derived separately by
+ // the server, after it knows the viewer's memberships.
byApp := map[string][]RouteView{}
domainsByApp := map[string][]DomainView{}
- // What the ROUTES say each app's account is, at route granularity. A
- // disagreement with the account being viewed is the half-migrated state
- // (see AccountMismatch); platform-minted routes are excluded because
- // they are aliases that reconciliation re-stamps on its own schedule, so
- // a lagging one is noise rather than a stuck deploy. routableByApp is
- // the denominator AllMismatched needs: every non-platform route counted,
- // so "all of them mismatch" can be told apart from "some of them".
- mismatchedByApp := map[string][]MismatchedRoute{}
- routableByApp := map[string]int{}
for _, rt := range routes {
+ rtAcct := release.RouteAccount(rt)
+ if rtAcct != acct {
+ continue
+ }
ch := rt.Channel
if ch == "" {
ch = "stable" // registry contract: empty channel means stable
}
- if rt.App != "" && !rt.Platform {
- routableByApp[rt.App]++
- if a := release.RouteAccount(rt); a != acct {
- mismatchedByApp[rt.App] = append(mismatchedByApp[rt.App], MismatchedRoute{Host: rt.Host, Account: a})
- }
- }
- rtAcct := release.RouteAccount(rt)
// The scoped guard described above: grants render only on rows the
// viewing account owns. Fails closed — a route whose stamped account
// disagrees with the account being viewed (the half-migrated state)
@@ -627,11 +583,6 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
}
seen[app] = true
channels := assembleChannels(ctx, s, pub, acct, app, now)
- mm := mismatchedByApp[app]
- mismatch := ""
- if len(mm) > 0 {
- mismatch = mm[0].Account
- }
pv := assembleProduction(ctx, s, pub, acct, app, channels, byApp[app], now)
if pv.Err == "" && !pv.On {
// No production flag (or off): nothing bakes, so no estimate. An
@@ -656,11 +607,8 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
// AppView.Pipeline's doc comment — so appPage (server.go) is
// where a single app's view gets pipeline-aware, and rebuilds
// Ladder from the corrected Channels once it has one.
- Ladder: assembleLadder(channels, byApp[app], nil, now),
- Production: pv,
- RouteAccount: mismatch,
- Mismatched: mm,
- AllMismatched: len(mm) > 0 && len(mm) == routableByApp[app],
+ Ladder: assembleLadder(channels, byApp[app], nil, now),
+ Production: pv,
})
}
return out, nil
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index efad811c0..3465c9795 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -344,17 +344,14 @@ func TestMismatchListsRoutes(t *testing.T) {
t.Fatal(err)
}
v := views[0]
- if len(v.Mismatched) != 1 || v.Mismatched[0].Host != "one.mismatchlist.test" || v.Mismatched[0].Account != "otheracct" {
- t.Fatalf("Mismatched = %+v, want exactly [{one.mismatchlist.test otheracct}]", v.Mismatched)
+ if len(v.Routes) != 2 {
+ t.Fatalf("Routes = %+v, want only the viewed account's two routes", v.Routes)
}
if v.AllMismatched {
t.Fatalf("AllMismatched = true, want false (2 of 3 routes agree)")
}
- if !v.AccountMismatch() {
- t.Fatal("AccountMismatch() = false, want true")
- }
- if v.RouteAccount != "otheracct" {
- t.Fatalf("RouteAccount = %q, want otheracct (first mismatched account)", v.RouteAccount)
+ if v.AccountMismatch() || v.RouteAccount != "" {
+ t.Fatalf("Assemble leaked cross-account diagnostics: %+v", v)
}
}
@@ -384,11 +381,8 @@ func TestMismatchAllRoutes(t *testing.T) {
t.Fatal(err)
}
v := views[0]
- if len(v.Mismatched) != 3 {
- t.Fatalf("Mismatched = %+v, want all 3 routes", v.Mismatched)
- }
- if !v.AllMismatched {
- t.Fatal("AllMismatched = false, want true (every route disagrees)")
+ if len(v.Routes) != 0 || v.AccountMismatch() || v.AllMismatched {
+ t.Fatalf("foreign routes leaked into view: %+v", v)
}
}
diff --git a/mail_cli.go b/mail_cli.go
index 891d80c11..1871adf4d 100644
--- a/mail_cli.go
+++ b/mail_cli.go
@@ -462,7 +462,7 @@ func (s *apiSession) emailCredentialsCreate(ctx context.Context, a apiAcct, app,
}
func (s *apiSession) emailCredentialsRevoke(ctx context.Context, a apiAcct, app, name string) (apiEmailRevokeReply, error) {
- status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/email/credentials/"+pathEscape(name)))
+ status, body, err := s.deleteJSON(ctx, apiPath(a, app, "/email/credentials/"+pathEscape(name)), "")
if err != nil {
return apiEmailRevokeReply{}, err
}