Merge current console changes into restart controls
52 files changed,
+1953
−341
apimode.go+7 −4canary.go+1 −1instances_cli.go+1 −1instances_cli_test.go+20 −0internal/console/accountreview.go+99 −0internal/console/accountreview_test.go+283 −0internal/console/activity.go+45 −0internal/console/activity_test.go+86 −0internal/console/analyticsview.go+1 −1internal/console/apicli.go+45 −6internal/console/apiconfig.go+9 −2internal/console/apidomains.go+10 −2internal/console/apidomains_test.go+40 −1internal/console/apifeatures.go+11 −3internal/console/apifeatures_test.go+11 −7internal/console/apiinstances.go+17 −2internal/console/apiinstances_test.go+20 −1internal/console/apilogs_test.go+169 −0internal/console/apirestartscope.go+16 −1internal/console/apirestartscope_test.go+31 −0internal/console/appdomains.go+8 −3internal/console/appdomains_test.go+4 −4internal/console/appsettings.go+83 −47internal/console/appsettings_gate_test.go+4 −4internal/console/appsettings_sections_test.go+120 −0internal/console/appsettings_test.go+33 −29internal/console/channelmove.go+3 −3internal/console/controlplane.go+3 −4internal/console/controlplane_test.go+12 −12internal/console/domainattach.go+17 −4internal/console/errorsview.go+1 −1internal/console/logsview_test.go+31 −0internal/console/mismatchcopy.go+23 −0internal/console/overview_test.go+5 −2internal/console/pendinghandlers.go+4 −4internal/console/pipelinepage.go+7 −2internal/console/server.go+318 −19internal/console/server_account_test.go+5 −8internal/console/server_test.go+9 −6internal/console/store.go+32 −9internal/console/store_test.go+25 −0internal/console/templates/account_review.html+24 −0internal/console/templates/app_activity.html+6 −3internal/console/templates/app_logs.html+155 −13internal/console/templates/app_overview.html+2 −23internal/console/templates/app_releases.html+1 −22internal/console/templates/app_settings.html+41 −7internal/console/templates/layout.html+27 −1internal/console/templates/pipeline.html+7 −0internal/console/view.go+14 −66internal/console/view_test.go+6 −12mail_cli.go+1 −1
CI passed — run details
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..6dc05deca |
| --- /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, 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, 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 <script>alert(1)</script>", "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 3c52de501..fb43a2099 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 := s.requestAppRestart(ctx, 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/apilogs_test.go b/internal/console/apilogs_test.go| index 5de42866c..4deb40d38 100644 |
| --- a/internal/console/apilogs_test.go |
| +++ b/internal/console/apilogs_test.go |
| @@ -4,12 +4,15 @@ import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| + "io" |
| "net/http" |
| + "net/url" |
| "strings" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -44,6 +47,24 @@ func seedLogChunks(t *testing.T, fx *consoleFixture, now time.Time) []applog.Rec |
| return recs |
| } |
| +func seedManyLogRecords(t *testing.T, fx *consoleFixture, now time.Time, n int) []applog.Record { |
| + t.Helper() |
| + recs := make([]applog.Record, n) |
| + for i := range recs { |
| + recs[i] = applog.Record{TS: now.Add(time.Duration(i) * time.Millisecond), Box: "box-many", |
| + Host: "titogo.example.com", Stream: applog.StreamApp, Msg: fmt.Sprintf("line %d", i), Seq: uint64(1000 + i)} |
| + } |
| + body, err := applog.EncodeChunk(recs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := applog.ChunkKey(release.OpsAccount, "titogo", "box-many", applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return recs |
| +} |
| + |
| // TestAPILogsTailFiltersAndFollow drives the endpoint end to end: default |
| // tail, stream/level/grep/field filters, and the cursor follow loop. |
| func TestAPILogsTailFiltersAndFollow(t *testing.T) { |
| @@ -151,10 +172,127 @@ func TestAPILogsRefusals(t *testing.T) { |
| _ = fmt.Sprintf("%v", resp) |
| } |
| +func TestAppLogsDataUsesBrowserSessionAndFilters(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-logs-browser-data") |
| + seedLogChunks(t, fx, time.Now().UTC()) |
| + fx.setUser("alice@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-logs-browser-data-signin") |
| + |
| + status, _, body := get(t, c, fx.u("/apps/titogo/logs/data?host=titogo.example.com&level=error")) |
| + if status != http.StatusOK { |
| + t.Fatalf("browser logs data = %d: %s", status, body) |
| + } |
| + var resp appLogsDataResponse |
| + if err := json.Unmarshal([]byte(body), &resp); err != nil { |
| + t.Fatalf("browser logs JSON: %v: %s", err, body) |
| + } |
| + if len(resp.Lines) != 1 || !strings.Contains(resp.Lines[0].Msg, "wake failed") || resp.Lines[0].Tone != "error" { |
| + t.Fatalf("filtered browser lines = %+v", resp.Lines) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs/data?host=deleted.example.com")) |
| + if status != http.StatusOK || !strings.Contains(body, `"lines":[]`) { |
| + t.Fatalf("historical host filter broadened = %d: %s", status, body) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs/data?text=wake%5B")) |
| + if status != http.StatusOK || !strings.Contains(body, `"lines":[]`) { |
| + t.Fatalf("browser text search was treated as a regular expression = %d: %s", status, body) |
| + } |
| + assertNonMember404(t, fx, "/apps/titogo/logs/data") |
| +} |
| + |
| +func TestAppLogsDataCursorPaginationAndRefusals(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-logs-browser-cursor") |
| + now := time.Now().UTC() |
| + recs := seedLogChunks(t, fx, now) |
| + fx.setUser("alice@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-logs-browser-cursor-signin") |
| + |
| + read := func(query string) (int, http.Header, appLogsDataResponse, string) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, fx.u("/apps/titogo/logs/data?"+query), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + res, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + b, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, headers, body := res.StatusCode, res.Header, string(b) |
| + var out appLogsDataResponse |
| + if status == http.StatusOK { |
| + if err := json.Unmarshal([]byte(body), &out); err != nil { |
| + t.Fatalf("decode logs data: %v: %s", err, body) |
| + } |
| + } |
| + return status, headers, out, body |
| + } |
| + |
| + status, headers, out, body := read("cursor=" + url.QueryEscape(applog.EncodeCursor(recs[0]))) |
| + if status != http.StatusOK || len(out.Lines) != 1 || !strings.Contains(out.Lines[0].Msg, "wake failed") { |
| + t.Fatalf("cursor read = %d %+v: %s", status, out, body) |
| + } |
| + if headers.Get("Cache-Control") != "no-store" { |
| + t.Fatalf("logs data Cache-Control = %q", headers.Get("Cache-Control")) |
| + } |
| + last := applog.EncodeCursor(recs[1]) |
| + _, _, out, _ = read("cursor=" + url.QueryEscape(last)) |
| + if len(out.Lines) != 0 || out.Cursor != last || out.Gap { |
| + t.Fatalf("empty cursor read = %+v", out) |
| + } |
| + expired := applog.EncodeCursor(applog.Record{TS: now.Add(-2 * time.Hour), Box: "old", Seq: 1}) |
| + _, _, out, _ = read("since=1h&cursor=" + url.QueryEscape(expired)) |
| + if !out.Gap || len(out.Lines) != 0 { |
| + t.Fatalf("expired cursor did not report a gap: %+v", out) |
| + } |
| + _, _, out, _ = read("cursor=not-a-cursor") |
| + if !out.Gap { |
| + t.Fatalf("invalid cursor did not report a gap: %+v", out) |
| + } |
| + |
| + seedManyLogRecords(t, fx, now.Add(-2*time.Minute), appLogsPageCap+1) |
| + _, _, out, _ = read("limit=999") |
| + if len(out.Lines) != appLogsPageCap || !out.Truncated || out.Gap { |
| + t.Fatalf("bounded page = %d lines, truncated=%v gap=%v", len(out.Lines), out.Truncated, out.Gap) |
| + } |
| + for _, query := range []string{"since=bad", "level=loud"} { |
| + status, _, _, _ := read(query) |
| + if status != http.StatusBadRequest { |
| + t.Errorf("%s = %d, want 400", query, status) |
| + } |
| + } |
| + fx.srv.cfg.Releases = nil |
| + status, headers, _, _ = read("") |
| + if status != http.StatusServiceUnavailable || headers.Get("Cache-Control") != "no-store" { |
| + t.Fatalf("bucketless logs data = %d, Cache-Control %q", status, headers.Get("Cache-Control")) |
| + } |
| +} |
| + |
| // TestAppLogsPage: the console tab renders the same timeline — filters as |
| // GET params, member-gated like every app page, honest empty state. |
| func TestAppLogsPage(t *testing.T) { |
| fx, _ := instanceFixture(t, "code-logs-page") |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range []registry.Route{ |
| + {Host: "legacy.example.com", Kind: "instance", App: "titogo", Account: "bab"}, |
| + {Host: "foreign.example.com", Kind: "instance", App: "titogo", Account: "other"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| now := time.Now().UTC() |
| seedLogChunks(t, fx, now) |
| fx.setUser("paul@keymail.dev") |
| @@ -170,11 +308,42 @@ func TestAppLogsPage(t *testing.T) { |
| t.Errorf("page missing %q", want) |
| } |
| } |
| + for _, want := range []string{"All instances", "Search logs", "All levels", "Follow live", "Pause"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("page missing approved copy %q", want) |
| + } |
| + } |
| + if !strings.Contains(body, `option value="legacy.example.com"`) || strings.Contains(body, `option value="foreign.example.com"`) { |
| + t.Fatalf("instance filter did not include only the account's legacy routes") |
| + } |
| // Stream filter narrows. |
| status, _, body = get(t, c, fx.u("/apps/titogo/logs?stream=platform")) |
| if status != http.StatusOK || strings.Contains(body, "listening on socket") || !strings.Contains(body, "wake failed") { |
| t.Fatalf("stream filter page: %d", status) |
| } |
| + // Instance, severity and text filters compose on the browser page too. |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?host=titogo.example.com&level=error&text=wake")) |
| + if status != http.StatusOK || strings.Contains(body, "listening on socket") || !strings.Contains(body, "wake failed") { |
| + t.Fatalf("composed browser filters: %d", status) |
| + } |
| + if !strings.Contains(body, `option value="titogo.example.com" selected`) { |
| + t.Fatalf("browser host filter was not retained: %s", body) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?host=deleted.example.com")) |
| + if status != http.StatusOK || strings.Contains(body, "wake failed") { |
| + t.Fatalf("historical browser host broadened: %d", status) |
| + } |
| + stamp := url.QueryEscape(now.Add(-30 * time.Minute).Format(time.RFC3339)) |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?since="+stamp+"&grep=wake&follow=1")) |
| + if status != http.StatusOK || !strings.Contains(body, `selected>`+now.Add(-30*time.Minute).Format(time.RFC3339)+`</option>`) || |
| + !strings.Contains(body, `type="hidden" name="grep" value="wake"`) || |
| + !strings.Contains(body, `name="text" value=""`) || !strings.Contains(body, `setFollowing(true); poll();`) { |
| + t.Fatalf("non-preset/legacy/follow state was not retained = %d: %s", status, body) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?cursor=hand-written")) |
| + if status != http.StatusOK || !strings.Contains(body, `<option value="1h" selected>hour</option>`) { |
| + t.Fatalf("page cursor without since lost the bounded default window = %d: %s", status, body) |
| + } |
| // Window with nothing in it: the honest empty state, still a 200. |
| status, _, body = get(t, c, fx.u("/apps/titogo/logs?since=1ns")) |
| if status != http.StatusOK || !strings.Contains(body, "No log lines in this window") { |
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 & TLS") { |
| + t.Fatal("General still renders the Domains section") |
| + } |
| + _, _, domains := get(t, c, fx.u("/apps/titogo/settings/domains")) |
| + if !strings.Contains(domains, "Domains & 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 & 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/logsview_test.go b/internal/console/logsview_test.go| index 2480b63cd..c5a3e3b33 100644 |
| --- a/internal/console/logsview_test.go |
| +++ b/internal/console/logsview_test.go |
| @@ -130,3 +130,34 @@ func TestAppLogsPageIsATerminalPane(t *testing.T) { |
| t.Fatalf("app output not rendered at all (escaped form absent) in:\n%s", html) |
| } |
| } |
| + |
| +func TestAppLogsEmptyStateKeepsTerminalAndFollowControls(t *testing.T) { |
| + p := page{ |
| + Title: "Logs", CSRF: "tok", AppName: "taskmonger", ActiveTab: "logs", |
| + Account: &Account{Name: "tom@keymail.dev", Sqid: "bdk"}, Base: "/a/bdk", Nav: "/a/bdk", |
| + LogSince: "1h", LogWidenURL: "/a/bdk/apps/taskmonger/logs?since=24h", |
| + } |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "app_logs", p); err != nil { |
| + t.Fatalf("render empty app_logs: %v", err) |
| + } |
| + html := buf.String() |
| + for _, want := range []string{`class="term"`, `id="log-empty"`, `id="log-follow"`, `id="log-pause"`, `logs/data`} { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("empty Logs view missing %q:\n%s", want, html) |
| + } |
| + } |
| + for _, want := range []string{ |
| + "textContent = value", `querySelectorAll(".tl").length > 200`, "request.abort()", |
| + "var activeQuery = new URLSearchParams(new FormData(form))", |
| + "if (!following || thisGeneration !== generation) return", |
| + `form.addEventListener("input"`, `q.set("follow", "1")`, "location.assign", |
| + } { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("Logs follow script missing safety %q", want) |
| + } |
| + } |
| + if strings.Contains(html, "innerHTML") { |
| + t.Fatal("Logs follow script writes live records through innerHTML") |
| + } |
| +} |
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 02ab3834c..8e7ec1e3d 100644 |
| --- a/internal/console/overview_test.go |
| +++ b/internal/console/overview_test.go |
| @@ -583,6 +583,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. |
| @@ -595,7 +598,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 95e6a2aa9..6b865bf20 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -22,6 +22,7 @@ import ( |
| "os" |
| "regexp" |
| "slices" |
| + "sort" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -1112,11 +1113,13 @@ 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("POST /a/{sqid}/apps/{app}/restart", s.handleAppRestart) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/instances/{host}/restart", s.handleInstanceRestart) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs) |
| + s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs/data", s.handleAppLogsData) |
| // The Errors tab and its two setting doors (errorsview.go): the tab is |
| // member-readable like every other app tab; the level is the owner's, |
| // at app and account level, and both POSTs go through the console gate |
| @@ -1131,6 +1134,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 +1315,9 @@ type page struct { |
| Welcome bool // one-shot "account created" banner |
| BrowserRestartCopy BrowserRestartCopy |
| + 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 +1346,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 +1364,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 |
| @@ -1461,11 +1474,20 @@ type page struct { |
| // App Logs (apilogs.go's sibling page): the merged timeline plus the |
| // filter state the form re-renders with. Empty LogRows with LogsErr "" |
| // is the normal nothing-logged-yet state, never an error. |
| - LogRows []applog.Record |
| - LogStream string |
| - LogSince string |
| - LogTruncated bool |
| - LogsErr string |
| + LogRows []applog.Record |
| + LogStream string |
| + LogSince string |
| + LogHost string |
| + LogLevel string |
| + LogGrep string |
| + LogLegacyGrep string |
| + LogFollow bool |
| + LogHosts []string |
| + LogCopy appLogsCopy |
| + LogWidenURL string |
| + LogCursor string |
| + LogTruncated bool |
| + LogsErr string |
| // App Errors (errorsview.go's page, apierrors.go's sibling): the |
| // assembled window plus the filter state the form re-renders with. Empty |
| @@ -1579,6 +1601,15 @@ 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 |
| +} |
| + |
| // IsOwner is the template's one permission question. |
| func (p page) IsOwner() bool { return p.Role == RoleOwner } |
| @@ -1602,6 +1633,7 @@ type appRow struct { |
| func (s *Server) render(w http.ResponseWriter, status int, name string, p page) { |
| p.BrowserRestartCopy = approvedBrowserRestartCopy |
| + 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 { |
| @@ -2589,10 +2621,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 |
| @@ -2864,19 +2920,64 @@ func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| q := r.URL.Query() |
| - p.LogStream = q.Get("stream") |
| - if p.LogStream != "" && !applog.ValidStream(p.LogStream) { |
| - p.LogStream = "" |
| + p.LogCopy = appLogsCopy{ |
| + Instance: "Instances", AllInstances: "All instances", Search: "Search logs", |
| + AllLevels: "All levels", Follow: "Follow live", Pause: "Pause", |
| + Widen: "Show the last 24 hours", |
| + FollowError: "Live updates failed. Follow live again to retry.", Refreshed: "Updated", |
| } |
| + p.LogStream = q.Get("stream") |
| p.LogSince = q.Get("since") |
| - if _, err := time.ParseDuration(p.LogSince); err != nil { |
| + if p.LogSince == "" { |
| p.LogSince = "1h" |
| } |
| - f := applog.Filter{Stream: p.LogStream} |
| - d, _ := time.ParseDuration(p.LogSince) |
| - f.Since = time.Now().Add(-d) |
| - |
| + p.LogHost = q.Get("host") |
| + p.LogLevel = q.Get("level") |
| + p.LogGrep = q.Get("text") |
| + p.LogLegacyGrep = q.Get("grep") |
| + p.LogFollow = q.Get("follow") == "1" |
| + widen, _ := url.ParseQuery(q.Encode()) |
| + widen.Set("since", "24h") |
| + widen.Del("cursor") |
| + widen.Del("follow") |
| + p.LogWidenURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/logs?" + widen.Encode() |
| + now := time.Now() |
| + f, _, bad := browserLogsFilterFromQuery(r, now) |
| + // Cursor is a data-endpoint concern. A hand-written page URL carrying one |
| + // must not turn the page's normal one-hour default into an all-history read. |
| + if f.Since.IsZero() { |
| + f.Since = now.Add(-logsDefaultWindow) |
| + } |
| + if bad != "" { |
| + p.LogsErr = bad |
| + } |
| + seenHosts := map[string]bool{} |
| + if p.LogHost != "" { |
| + seenHosts[p.LogHost] = true |
| + p.LogHosts = append(p.LogHosts, p.LogHost) |
| + } |
| + for _, rt := range p.Detail.Routes { |
| + if rt.Account == acct.Sqid && !seenHosts[rt.Host] { |
| + seenHosts[rt.Host] = true |
| + p.LogHosts = append(p.LogHosts, rt.Host) |
| + } |
| + } |
| if s.cfg.Releases != nil { |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, p.AppName, instanceListMax) |
| + cancel() |
| + if err == nil { |
| + for _, rec := range recs { |
| + if !seenHosts[rec.Host] { |
| + seenHosts[rec.Host] = true |
| + p.LogHosts = append(p.LogHosts, rec.Host) |
| + } |
| + } |
| + } |
| + } |
| + sort.Strings(p.LogHosts) |
| + |
| + if s.cfg.Releases != nil && bad == "" { |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, p.AppName, f, logsCollectCap) |
| @@ -2890,19 +2991,156 @@ func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) { |
| p.LogTruncated = true |
| } |
| p.LogRows = recs |
| + if n := len(recs); n > 0 { |
| + p.LogCursor = applog.EncodeCursor(recs[n-1]) |
| + } |
| } |
| s.render(w, http.StatusOK, "app_logs", p) |
| } |
| +type appLogsDataLine struct { |
| + Time string `json:"time"` |
| + Stream string `json:"stream"` |
| + Host string `json:"host,omitempty"` |
| + Msg string `json:"msg"` |
| + Fields string `json:"fields,omitempty"` |
| + Tone string `json:"tone,omitempty"` |
| +} |
| + |
| +type appLogsDataResponse struct { |
| + Lines []appLogsDataLine `json:"lines"` |
| + Truncated bool `json:"truncated"` |
| + Gap bool `json:"gap,omitempty"` |
| + Cursor string `json:"cursor,omitempty"` |
| +} |
| + |
| +// handleAppLogsData is the browser's follow endpoint. It uses the session and |
| +// app-membership gate, then the API reader's filter/cursor semantics; browser |
| +// JavaScript never receives or manufactures a bearer credential. |
| +func (s *Server) handleAppLogsData(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + _, _, acct, _, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + now := time.Now() |
| + f, limit, bad := browserLogsFilterFromQuery(r, now) |
| + if bad != "" { |
| + jsonError(w, http.StatusBadRequest, bad) |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, "could not read the logs") |
| + return |
| + } |
| + cursorText := r.URL.Query().Get("cursor") |
| + cur, hasCursor := applog.DecodeCursor(cursorText) |
| + if cursorText != "" && !hasCursor { |
| + writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true}) |
| + return |
| + } |
| + if hasCursor { |
| + if !f.Since.IsZero() && cur.TS.Before(f.Since) { |
| + writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true}) |
| + return |
| + } |
| + cursorSince := cur.TS.Add(-time.Second) |
| + if f.Since.IsZero() || cursorSince.After(f.Since) { |
| + f.Since = cursorSince |
| + } |
| + } |
| + limit = min(limit, appLogsPageCap) |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap) |
| + if err != nil { |
| + log.Printf("console: app logs data %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the logs") |
| + return |
| + } |
| + resp := appLogsDataResponse{Gap: trimmed && hasCursor} |
| + if hasCursor { |
| + var after []applog.Record |
| + for _, rec := range recs { |
| + if cur.Before(rec) { |
| + after = append(after, rec) |
| + } |
| + } |
| + if len(after) > limit { |
| + after = after[:limit] |
| + resp.Truncated = true |
| + } |
| + recs = after |
| + } else if len(recs) > limit { |
| + recs = recs[len(recs)-limit:] |
| + resp.Truncated = true |
| + } |
| + for _, line := range logLines(recs) { |
| + resp.Lines = append(resp.Lines, appLogsDataLine{ |
| + Time: line.Time, Stream: line.Stream, Host: line.Host, |
| + Msg: line.Msg, Fields: line.Fields, Tone: line.Tone, |
| + }) |
| + } |
| + if n := len(recs); n > 0 { |
| + resp.Cursor = applog.EncodeCursor(recs[n-1]) |
| + } else { |
| + resp.Cursor = cursorText |
| + } |
| + if resp.Lines == nil { |
| + resp.Lines = []appLogsDataLine{} |
| + } |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
| + |
| +// browserLogsFilterFromQuery keeps the CLI's filter parser as the one source |
| +// of validation while making the console's search box literal. A stale |
| +// console URL carrying grep continues to use its original regular expression. |
| +func browserLogsFilterFromQuery(r *http.Request, now time.Time) (applog.Filter, int, string) { |
| + text := r.URL.Query().Get("text") |
| + if text == "" { |
| + return logsFilterFromQuery(r, now) |
| + } |
| + r2 := r.Clone(r.Context()) |
| + u := *r.URL |
| + q := u.Query() |
| + q.Set("grep", regexp.QuoteMeta(text)) |
| + q.Del("text") |
| + u.RawQuery = q.Encode() |
| + r2.URL = &u |
| + return logsFilterFromQuery(r2, now) |
| +} |
| + |
| // handleAppActivity is the console's own audit trail for this app. |
| func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) { |
| p, acct, ok := s.appPage(w, r, "activity") |
| 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) |
| @@ -2916,7 +3154,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 |
| @@ -2930,7 +3208,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. |
| @@ -2944,6 +3235,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 |
| @@ -3411,7 +3710,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 |
| @@ -3637,7 +3936,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+".") |
| } |
| @@ -3919,7 +4218,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) |
| @@ -4057,7 +4356,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 d67a229a1..15b06a5e2 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 & 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 & 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/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_logs.html b/internal/console/templates/app_logs.html| index cd499df72..f4609771d 100644 |
| --- a/internal/console/templates/app_logs.html |
| +++ b/internal/console/templates/app_logs.html |
| @@ -5,7 +5,13 @@ |
| <h2>Logs</h2> |
| {{if .LogsErr}}<p class="banner error">{{.LogsErr}}</p>{{end}} |
| -<form method="get" class="small"> |
| +<form method="get" class="small" id="logs-filter" data-url="{{.Base}}/apps/{{.AppName}}/logs/data" data-cursor="{{.LogCursor}}" data-follow-error="{{.LogCopy.FollowError}}" data-refreshed="{{.LogCopy.Refreshed}}"> |
| + <label>{{.LogCopy.Instance}} |
| + <select name="host"> |
| + <option value="">{{.LogCopy.AllInstances}}</option> |
| + {{range .LogHosts}}<option value="{{.}}"{{if eq . $.LogHost}} selected{{end}}>{{.}}</option>{{end}} |
| + </select> |
| + </label> |
| <label>Show |
| <select name="stream"> |
| <option value=""{{if eq .LogStream ""}} selected{{end}}>everything</option> |
| @@ -15,6 +21,7 @@ |
| </label> |
| <label>from the last |
| <select name="since"> |
| + {{if and (ne .LogSince "15m") (ne .LogSince "1h") (ne .LogSince "6h") (ne .LogSince "24h") (ne .LogSince "168h")}}<option value="{{.LogSince}}" selected>{{.LogSince}}</option>{{end}} |
| <option value="15m"{{if eq .LogSince "15m"}} selected{{end}}>15 minutes</option> |
| <option value="1h"{{if eq .LogSince "1h"}} selected{{end}}>hour</option> |
| <option value="6h"{{if eq .LogSince "6h"}} selected{{end}}>6 hours</option> |
| @@ -22,30 +29,165 @@ |
| <option value="168h"{{if eq .LogSince "168h"}} selected{{end}}>week</option> |
| </select> |
| </label> |
| + <label>{{.LogCopy.AllLevels}} |
| + <select name="level"> |
| + <option value=""{{if eq .LogLevel ""}} selected{{end}}>{{.LogCopy.AllLevels}}</option> |
| + <option value="debug"{{if eq .LogLevel "debug"}} selected{{end}}>debug</option> |
| + <option value="info"{{if eq .LogLevel "info"}} selected{{end}}>info</option> |
| + <option value="warn"{{if eq .LogLevel "warn"}} selected{{end}}>warn</option> |
| + <option value="error"{{if eq .LogLevel "error"}} selected{{end}}>error</option> |
| + </select> |
| + </label> |
| + <label>{{.LogCopy.Search}} |
| + <input type="search" name="text" value="{{.LogGrep}}"> |
| + </label> |
| + {{if .LogLegacyGrep}}<input type="hidden" name="grep" value="{{.LogLegacyGrep}}">{{end}} |
| <button type="submit">Update</button> |
| + <button type="button" id="log-follow">{{.LogCopy.Follow}}</button> |
| + <button type="button" id="log-pause" hidden>{{.LogCopy.Pause}}</button> |
| </form> |
| -{{if .LogRows}} |
| {{if .LogTruncated}}<p class="small">Showing the newest {{len .LogRows}} lines of this window — narrow the window, or use <code>carlos logs</code> for more.</p>{{end}} |
| -<div class="term" id="logterm" role="log" aria-label="Log output" tabindex="0"> |
| +<div class="term" id="logterm" role="log" aria-label="Log output" tabindex="0" aria-live="polite"> |
| {{- range logLines .LogRows -}} |
| <div class="tl{{if .Tone}} tl-{{.Tone}}{{end}}"><span class="tl-t">{{.Time}}</span> <span class="tl-s">{{.Stream}}</span>{{if .Host}} <span class="tl-h">{{.Host}}</span>{{end}} <span class="tl-m">{{.Msg}}</span>{{if .Fields}}<span class="tl-f">{{.Fields}}</span>{{end}}</div> |
| {{- end -}} |
| +{{if and (not .LogRows) (not .LogsErr)}}<div id="log-empty">No log lines in this window. <a href="{{.LogWidenURL}}">{{.LogCopy.Widen}}</a></div>{{end}} |
| </div> |
| +<p class="small" id="log-status" aria-live="polite"></p> |
| +{{if .LogRows}} |
| <p class="small">Times are UTC. Follow live with <code>carlos logs --app {{.AppName}} -f</code>.</p> |
| -<script> |
| -// Enhancement only: the pane is a scrolled region of plain lines, oldest |
| -// first. A terminal opens at its foot, so this puts the newest line in view |
| -// on load. With JS off the pane simply opens at the top. |
| -(function () { |
| - var t = document.getElementById("logterm"); |
| - if (t) t.scrollTop = t.scrollHeight; |
| -})(); |
| -</script> |
| {{else if not .LogsErr}} |
| -<p>No log lines in this window.</p> |
| <p class="small">An app's output appears here shortly after it runs; platform events |
| (wakes, restarts, failures) appear even when the app itself printed nothing. A |
| site served from files alone has no app output.</p> |
| {{end}} |
| +<script> |
| +(function () { |
| + var form = document.getElementById("logs-filter"); |
| + var term = document.getElementById("logterm"); |
| + var follow = document.getElementById("log-follow"); |
| + var pause = document.getElementById("log-pause"); |
| + var status = document.getElementById("log-status"); |
| + if (!form || !term || !follow || !pause || !status) return; |
| + var cursor = form.dataset.cursor || ""; |
| + var timer = 0; |
| + var request = null; |
| + var fetchTimeout = 0; |
| + var timedOut = false; |
| + var following = false; |
| + var generation = 0; |
| + var dirty = false; |
| + var activeQuery = new URLSearchParams(new FormData(form)); |
| + |
| + function setFollowing(on) { |
| + var moveFocus = document.activeElement === (on ? follow : pause); |
| + following = on; |
| + follow.hidden = on; |
| + pause.hidden = !on; |
| + var q = new URLSearchParams(activeQuery); |
| + if (on) q.set("follow", "1"); else q.delete("follow"); |
| + history.replaceState(null, "", location.pathname + "?" + q.toString()); |
| + if (moveFocus) (on ? pause : follow).focus(); |
| + } |
| + function addSpan(row, cls, value, prefix) { |
| + if (!value) return; |
| + if (prefix) row.appendChild(document.createTextNode(prefix)); |
| + var span = document.createElement("span"); |
| + span.className = cls; |
| + span.textContent = value; |
| + row.appendChild(span); |
| + } |
| + function append(line) { |
| + var empty = document.getElementById("log-empty"); |
| + if (empty) empty.remove(); |
| + var row = document.createElement("div"); |
| + row.className = "tl" + (line.tone ? " tl-" + line.tone : ""); |
| + addSpan(row, "tl-t", line.time, ""); |
| + addSpan(row, "tl-s", line.stream, " "); |
| + addSpan(row, "tl-h", line.host, " "); |
| + addSpan(row, "tl-m", line.msg, " "); |
| + addSpan(row, "tl-f", line.fields, ""); |
| + term.appendChild(row); |
| + while (term.querySelectorAll(".tl").length > 200) term.querySelector(".tl").remove(); |
| + term.scrollTop = term.scrollHeight; |
| + } |
| + function clearLines() { |
| + term.querySelectorAll(".tl").forEach(function (row) { row.remove(); }); |
| + } |
| + function stop() { |
| + generation++; |
| + following = false; |
| + if (timer) window.clearTimeout(timer); |
| + timer = 0; |
| + if (request) request.abort(); |
| + request = null; |
| + if (fetchTimeout) window.clearTimeout(fetchTimeout); |
| + fetchTimeout = 0; |
| + timedOut = false; |
| + term.setAttribute("aria-busy", "false"); |
| + setFollowing(false); |
| + } |
| + function poll() { |
| + if (!following) return; |
| + var thisGeneration = generation; |
| + var q = new URLSearchParams(activeQuery); |
| + var hadCursor = Boolean(cursor); |
| + q.set("limit", "200"); |
| + if (cursor) q.set("cursor", cursor); |
| + request = new AbortController(); |
| + timedOut = false; |
| + var thisRequest = request; |
| + fetchTimeout = window.setTimeout(function () { |
| + if (following && thisGeneration === generation && request === thisRequest) { |
| + timedOut = true; |
| + thisRequest.abort(); |
| + } |
| + }, 20000); |
| + term.setAttribute("aria-busy", "true"); |
| + fetch(form.dataset.url + "?" + q.toString(), {signal: request.signal, headers: {"Accept": "application/json"}}) |
| + .then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) |
| + .then(function (data) { |
| + if (!following || thisGeneration !== generation) return; |
| + window.clearTimeout(fetchTimeout); |
| + fetchTimeout = 0; |
| + if (data.gap) { |
| + cursor = ""; |
| + stop(); |
| + status.textContent = form.dataset.followError; |
| + return; |
| + } |
| + if (!hadCursor) clearLines(); |
| + data.lines.forEach(append); |
| + cursor = data.cursor || cursor; |
| + status.textContent = form.dataset.refreshed + " " + new Date().toLocaleTimeString(); |
| + term.setAttribute("aria-busy", "false"); |
| + timer = window.setTimeout(poll, 2000); |
| + }) |
| + .catch(function (err) { |
| + window.clearTimeout(fetchTimeout); |
| + fetchTimeout = 0; |
| + if (err.name === "AbortError" && !timedOut) return; |
| + if (!following || thisGeneration !== generation) return; |
| + stop(); |
| + status.textContent = form.dataset.followError; |
| + }); |
| + } |
| + form.addEventListener("input", function () { dirty = true; if (following) stop(); }); |
| + form.addEventListener("change", function () { dirty = true; if (following) stop(); }); |
| + follow.addEventListener("click", function () { |
| + if (dirty) { |
| + var q = new URLSearchParams(new FormData(form)); |
| + q.set("follow", "1"); |
| + location.assign(location.pathname + "?" + q.toString()); |
| + return; |
| + } |
| + setFollowing(true); |
| + poll(); |
| + }); |
| + pause.addEventListener("click", stop); |
| + term.scrollTop = term.scrollHeight; |
| + {{if .LogFollow}}setFollowing(true); poll();{{end}} |
| +})(); |
| +</script> |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html| index 07a415522..dd3a53097 100644 |
| --- a/internal/console/templates/app_overview.html |
| +++ b/internal/console/templates/app_overview.html |
| @@ -2,28 +2,7 @@ |
| {{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> |
| @@ -55,7 +34,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 & certs OK{{end}}</p> |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index 7e00f916e..1f73f349a 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -2,28 +2,7 @@ |
| {{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"> |
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 & 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’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 35eba307f..69a2b123c 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -21,6 +21,7 @@ |
| --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; --good: #5fae7f; |
| } |
| } |
| + [hidden] { display: none !important; } |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| background: var(--bg); color: var(--ink); |
| @@ -128,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); } |
| @@ -166,10 +182,14 @@ |
| the console's own output. The pane is a scrolled region in BOTH axes — |
| sideways because lines do not wrap, and down because 200 lines must not |
| turn the page itself into the scrollbar. */ |
| + #logs-filter { display: flex; flex-wrap: wrap; align-items: end; gap: 0.65rem; margin-bottom: 1rem; } |
| + #logs-filter label { display: flex; flex-direction: column; gap: 0.25rem; max-width: 100%; } |
| + #logs-filter select, #logs-filter input { max-width: 100%; margin: 0; } |
| + #logs-filter select[name="host"] { max-width: 28rem; } |
| .term { |
| background: var(--accent-soft); border-radius: 0.5rem; |
| padding: 0.8rem 0 0.8rem 1rem; margin: 0 0 1rem; |
| - max-height: 70vh; overflow: auto; |
| + min-height: 16rem; max-height: 70vh; overflow: auto; |
| font-family: var(--mono); font-size: 0.8rem; line-height: 1.55; |
| } |
| /* white-space:pre belongs to the LINE, not the pane: on the pane it would |
| @@ -331,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 e5bd22cb8..12200bf19 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 |
| } |