Merge branch 'console-ux-07-activity' into main
25 files changed,
+492
−59
apimode.go+7 −4canary.go+1 −1instances_cli.go+1 −1instances_cli_test.go+20 −0internal/console/activity.go+45 −0internal/console/activity_test.go+86 −0internal/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+9 −5internal/console/apiinstances.go+17 −2internal/console/apiinstances_test.go+20 −1internal/console/apirestartscope.go+16 −1internal/console/apirestartscope_test.go+31 −0internal/console/channelmove.go+3 −3internal/console/domainattach.go+17 −4internal/console/pendinghandlers.go+4 −4internal/console/server.go+35 −4internal/console/server_test.go+1 −2internal/console/store.go+32 −9internal/console/store_test.go+25 −0internal/console/templates/app_activity.html+6 −3mail_cli.go+1 −1
CI cancelled — superseded by a newer push — 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/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/apicli.go b/internal/console/apicli.go| index e6e2613b5..f57ae5bc2 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -695,6 +695,7 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| var req struct { |
| Version string `json:"version"` |
| Channel string `json:"channel"` |
| + Message string `json:"message"` |
| // Hotfix is spec §5's order bypass, and it is read nowhere above the |
| // rules decision below — see this handler's doc comment. |
| Hotfix bool `json:"hotfix"` |
| @@ -702,6 +703,10 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| if !readJSON(w, r, &req) { |
| return |
| } |
| + message, ok := checkedActivityMessage(w, req.Message) |
| + if !ok { |
| + return |
| + } |
| channel := strings.TrimSpace(req.Channel) |
| if !release.ValidChannel(channel) { |
| jsonError(w, http.StatusBadRequest, "invalid channel name") |
| @@ -853,7 +858,10 @@ func (s *Server) handleAPIPromote(w http.ResponseWriter, r *http.Request) { |
| }) |
| return |
| } |
| - s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel+" (cli)") |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "promote", TargetType: "channel", Target: channel, Message: message, |
| + Detail: "promoted " + version + " to " + channel + " (cli)", |
| + }) |
| s.bumpSerial(ctx) |
| writeJSON(w, http.StatusOK, map[string]any{ |
| "result": "promoted", "from": res.From, "to": res.To, "noop": false, |
| @@ -942,10 +950,15 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| } |
| var req struct { |
| Channel string `json:"channel"` |
| + Message string `json:"message"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| } |
| + message, ok := checkedActivityMessage(w, req.Message) |
| + if !ok { |
| + return |
| + } |
| channel := strings.TrimSpace(req.Channel) |
| if !release.ValidChannel(channel) { |
| jsonError(w, http.StatusBadRequest, "invalid channel name") |
| @@ -1023,7 +1036,10 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusConflict, err.Error()) |
| return |
| } |
| - s.audit(acct, app, addr, "rollback", "rolled back "+channel+" to "+target+" (cli)") |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "rollback", TargetType: "channel", Target: channel, Message: message, |
| + Detail: "rolled back " + channel + " to " + target + " (cli)", |
| + }) |
| s.bumpSerial(ctx) |
| writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"}) |
| } |
| @@ -1095,6 +1111,18 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) { |
| "no deployment bucket on this host — this console cannot request a restart") |
| return |
| } |
| + var req struct { |
| + Message string `json:"message"` |
| + } |
| + // The original restart door had no request body. Keep accepting those |
| + // clients; a body is present only when a newer caller supplies context. |
| + if r.ContentLength != 0 && !readJSON(w, r, &req) { |
| + return |
| + } |
| + message, ok := checkedActivityMessage(w, req.Message) |
| + if !ok { |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| if err := release.TouchRestartGen(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { |
| @@ -1107,7 +1135,10 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again") |
| return |
| } |
| - s.audit(acct, app, addr, "restart", "requested a restart (cli)") |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "restart", TargetType: "app", Target: app, Message: message, |
| + Detail: "requested a restart (cli)", |
| + }) |
| // The request is in the bucket; this is what makes boxes LOOK at it — they |
| // read restart generations only when the serial moves. The difference |
| // between "seconds" and "up to an hour" for the one verb people type when |
| @@ -1590,8 +1621,9 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| // both optional and both metadata rather than content — a re-ship of |
| // the same bytes tolerates a different value for either, the same |
| // way it already tolerates a different BuiltAt. |
| - Label string `json:"label,omitempty"` |
| - Notes string `json:"notes,omitempty"` |
| + Label string `json:"label,omitempty"` |
| + Notes string `json:"notes,omitempty"` |
| + Message string `json:"message,omitempty"` |
| // Capabilities is the app's declaration (internal/caps), ADDITIVE: |
| // absent from every request a CLI shipped before this field existed, |
| // and absent from every request that does not pass -capabilities. It |
| @@ -1612,6 +1644,10 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadRequest, "malformed meta part") |
| return |
| } |
| + message, ok := checkedActivityMessage(w, meta.Message) |
| + if !ok { |
| + return |
| + } |
| if meta.Kind != "binary" && meta.Kind != "static" { |
| jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`) |
| return |
| @@ -1922,7 +1958,10 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadGateway, "could not store the release — try again") |
| return |
| } |
| - s.audit(acct, app, addr, "ship", "shipped "+ver+" (cli)") |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "ship", TargetType: "release", Target: ver, Message: message, |
| + Detail: "shipped " + ver + " (cli)", |
| + }) |
| out := make(map[string]any, len(artifacts)) |
| for name, a := range artifacts { |
diff --git a/internal/console/apiconfig.go b/internal/console/apiconfig.go| index 24d7911d4..2de0abb18 100644 |
| --- a/internal/console/apiconfig.go |
| +++ b/internal/console/apiconfig.go |
| @@ -289,6 +289,7 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) { |
| // signed, so a console with no signing key writes them just as well. |
| var req struct { |
| Environment string `json:"environment"` |
| + Message string `json:"message"` |
| Set map[string]struct { |
| Kind string `json:"kind"` |
| Value string `json:"value"` |
| @@ -305,6 +306,10 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) { |
| if !readJSONLimit(w, r, &req, configPutMaxBytes) { |
| return |
| } |
| + message, ok := checkedActivityMessage(w, req.Message) |
| + if !ok { |
| + return |
| + } |
| if len(req.Set) == 0 && len(req.Unset) == 0 && req.ExecDelivery == nil { |
| jsonError(w, http.StatusBadRequest, "nothing to do — send set, unset or exec_delivery") |
| return |
| @@ -442,8 +447,10 @@ func (s *Server) handleAPIConfigPut(w http.ResponseWriter, r *http.Request) { |
| // review, MINOR 9): a row claiming three removals when nothing was |
| // there would mislead exactly the person reading rows to find out |
| // what happened. |
| - s.audit(acct, app, addr, "config", |
| - fmt.Sprintf("config: %d set, %d unset (%s) (cli)", len(sets), removed, bundle)) |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "config", TargetType: "environment", Target: bundle, Message: message, |
| + Detail: fmt.Sprintf("config: %d set, %d unset (%s) (cli)", len(sets), removed, bundle), |
| + }) |
| // Box-consumed (carlos-deploy §3): the fast loop's config-sync leg |
| // renders this bundle onto every route that references it. Announced |
| // per successful write rather than once at the end, for the same |
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go| index 35bcdd10d..36a809882 100644 |
| --- a/internal/console/apidomains.go |
| +++ b/internal/console/apidomains.go |
| @@ -248,6 +248,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| Route string `json:"route"` |
| Kind string `json:"kind"` |
| Channel string `json:"channel"` |
| + Message string `json:"message"` |
| // Wildcard and Catchall are the woodstar-carloku capability flips |
| // (Task 5), decoded as pointers so an ABSENT field means "leave |
| // unchanged" — the same tri-state rule apps place's -target and |
| @@ -261,6 +262,10 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| if !readJSON(w, r, &in) { |
| return |
| } |
| + message, ok := checkedActivityMessage(w, in.Message) |
| + if !ok { |
| + return |
| + } |
| // Path-traversal defence stated as such: this hostname becomes a bucket |
| // key segment (domains/<host>.json) and, on the box, a registry alias row |
| // and a certificate name. Nothing below runs until it is a well-formed, |
| @@ -300,7 +305,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // from the core's result: on the first-route branch the host IS the |
| // route the record just declared; on the alias branch it is the |
| // route the claim names. |
| - res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal) |
| + res, ref := s.attachDomain(ctx, acct, app, host, in.Kind, in.Channel, principal, message) |
| if ref != nil { |
| jsonError(w, ref.Status, ref.Msg) |
| return |
| @@ -375,7 +380,10 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) { |
| // reload pass, so a bump per call would keep every box converging onto |
| // nothing and an audit row per call would bury the tenant's log. |
| if changed { |
| - s.audit(acct, app, principal, "domain-attach", "attached hostname "+host+" -> "+route+" (cli)") |
| + s.auditActivity(acct, app, principal, Activity{ |
| + Action: "domain-attach", TargetType: "instance", Target: route, Message: message, |
| + Detail: "attached hostname " + host + " -> " + route + " (cli)", |
| + }) |
| // The claim is what a box's domains pass mints an alias row from, so |
| // this is a box-consumed write (carlos-deploy §3): announce it, and |
| // the hostname is serving seconds after the call instead of on the |
diff --git a/internal/console/apidomains_test.go b/internal/console/apidomains_test.go| index 96c6c0da6..909d1f34b 100644 |
| --- a/internal/console/apidomains_test.go |
| +++ b/internal/console/apidomains_test.go |
| @@ -133,6 +133,37 @@ func TestAPIDomainAttachCreates(t *testing.T) { |
| } |
| } |
| +func TestAPIDomainAttachRecordsDescriptionAndCanonicalRoute(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-message") |
| + body := `{"host":"tickets.example.com","route":"go.tito.io","message":"attach the ticket checkout"}` |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body) |
| + if code != http.StatusOK { |
| + t.Fatalf("attach = %d %s, want 200", code, out) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 1) |
| + if err != nil || len(events) != 1 { |
| + t.Fatalf("Events = %+v, %v", events, err) |
| + } |
| + got := events[0] |
| + if got.Action != "domain-attach" || got.TargetType != "instance" || got.Target != "go.tito.io" || |
| + got.Message != "attach the ticket checkout" || got.Detail != "attached hostname tickets.example.com -> go.tito.io (cli)" { |
| + t.Fatalf("activity = %+v", got) |
| + } |
| +} |
| + |
| +func TestAPIDomainAttachRejectsInvalidDescriptionBeforeWriting(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-dom-message-invalid") |
| + body := `{"host":"tickets.example.com","route":"go.tito.io","message":"first\nsecond"}` |
| + code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body) |
| + if code != http.StatusBadRequest { |
| + t.Fatalf("attach = %d %s, want 400", code, out) |
| + } |
| + claimAbsent(t, fx, "tickets.example.com") |
| + if events, err := fx.st.Events(OpsAccountID, "titogo", 10); err != nil || len(events) != 0 { |
| + t.Fatalf("activity after invalid message = %+v, %v", events, err) |
| + } |
| +} |
| + |
| // TestAPIDomainAttachAppToken: the app-held credential works on its own app |
| // — and a token bound to a NEIGHBOURING app in the same account meets the |
| // family's byte-identical 404 (apiInstancePrincipal's contract), never a |
| @@ -598,7 +629,7 @@ func TestAPIDomainAttachKindStaticFirstRoute(t *testing.T) { |
| const host = "fresh.example.com" |
| code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, |
| - attachBodyKind(host, "", "static", "")) |
| + `{"host":"fresh.example.com","kind":"static","message":"publish the new site"}`) |
| if code != http.StatusOK { |
| t.Fatalf("attach = %d %s, want 200", code, out) |
| } |
| @@ -620,6 +651,14 @@ func TestAPIDomainAttachKindStaticFirstRoute(t *testing.T) { |
| if rec.Kind != "static" || rec.Channel != "canary/live" || rec.Slug != "" { |
| t.Fatalf("record = %+v, want a static record on canary/live", *rec) |
| } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 1) |
| + if err != nil || len(events) != 1 { |
| + t.Fatalf("Events = %+v, %v", events, err) |
| + } |
| + if got := events[0]; got.Action != "domain-attach" || got.TargetType != "instance" || got.Target != host || |
| + got.Message != "publish the new site" || got.Detail != host+" (first route, canary/live)" { |
| + t.Fatalf("activity = %+v", got) |
| + } |
| } |
| // TestAPIDomainAttachKindStaticWithReadyRouteAliases: the same kind=static |
diff --git a/internal/console/apifeatures.go b/internal/console/apifeatures.go| index 6073a5dc7..99daa43af 100644 |
| --- a/internal/console/apifeatures.go |
| +++ b/internal/console/apifeatures.go |
| @@ -102,12 +102,17 @@ func (s *Server) handleAPIFeaturesSet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| var in struct { |
| - Set map[string]string `json:"set"` |
| - Unset []string `json:"unset"` |
| + Set map[string]string `json:"set"` |
| + Unset []string `json:"unset"` |
| + Message string `json:"message"` |
| } |
| if !readJSON(w, r, &in) { |
| return |
| } |
| + message, ok := checkedActivityMessage(w, in.Message) |
| + if !ok { |
| + return |
| + } |
| if len(in.Set) == 0 && len(in.Unset) == 0 { |
| jsonError(w, http.StatusBadRequest, "nothing to do — name at least one key to set or unset") |
| return |
| @@ -145,7 +150,10 @@ func (s *Server) handleAPIFeaturesSet(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| if changed { |
| - s.audit(acct, app, principal, "features", featuresAuditDetail(in.Set, in.Unset)) |
| + s.auditActivity(acct, app, principal, Activity{ |
| + Action: "features", TargetType: "app", Target: app, Message: message, |
| + Detail: featuresAuditDetail(in.Set, in.Unset), |
| + }) |
| } |
| resp := featuresResponse(acct.Sqid, app, rec) |
| resp.Changed = &changed |
diff --git a/internal/console/apifeatures_test.go b/internal/console/apifeatures_test.go| index bea0555bc..da10dcb2f 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 — |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 4d15885fc..017f47a61 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -547,6 +547,7 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| Host string `json:"host"` |
| Region string `json:"region"` |
| Channel string `json:"channel"` |
| + Message string `json:"message"` |
| // The route SHAPE fields (route-records design §3, HA design §II). |
| // All optional, all defaulting to the exact route this door minted |
| // before they existed, and all gated by provision.ValidateShape |
| @@ -586,6 +587,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| if !readJSON(w, r, &req) { |
| return |
| } |
| + message, ok := checkedActivityMessage(w, req.Message) |
| + if !ok { |
| + return |
| + } |
| host := strings.TrimSpace(req.Host) |
| // Path-traversal defence stated as such (spec §11): this host becomes a |
| // bucket key segment here and, on the box, the <host>.sock and |
| @@ -798,7 +803,10 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| // (TTL 60) written HERE, after the record write and before the |
| // response, behind an interface the deployment configures (nil = |
| // wildcard-covered). Recorded, not built. |
| - s.audit(acct, app, principal, "instance-create", "created instance "+host+" (cli)") |
| + s.auditActivity(acct, app, principal, Activity{ |
| + Action: "instance-create", TargetType: "instance", Target: host, Message: message, |
| + Detail: "created instance " + host + " (cli)", |
| + }) |
| // The record is what a box's reconcile leg mints a route from, so this is |
| // a box-consumed write (carlos-deploy §3): announce it, and the instance |
| // is serving seconds after signup instead of on the hour. |
| @@ -1216,6 +1224,10 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) |
| if !ok { |
| return |
| } |
| + message, ok := checkedActivityMessage(w, r.Header.Get(activityMessageHeader)) |
| + if !ok { |
| + return |
| + } |
| // The eighth destructive door, and the one with the shortest fuse: |
| // deleting a control-plane app's record is how the provisioning |
| // reconciler's removal phase is TOLD to retire the console's route, so a |
| @@ -1261,7 +1273,10 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request) |
| // idempotency makes "deleted" and "already gone" indistinguishable from |
| // the store, and the fact worth recording is that this principal asked |
| // for this host to stop existing. |
| - s.audit(acct, app, principal, "instance-delete", "deleted instance "+host+" (cli)") |
| + s.auditActivity(acct, app, principal, Activity{ |
| + Action: "instance-delete", TargetType: "instance", Target: host, Message: message, |
| + Detail: "deleted instance " + host + " (cli)", |
| + }) |
| // Retiring a route is reconcile's work too, and the direction people want |
| // FAST: the announcement is what takes the route down in seconds. |
| s.bumpSerial(ctx) |
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go| index 9b3981c63..6c767d9fb 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -1747,10 +1747,29 @@ func TestAPIInstanceDelete(t *testing.T) { |
| t.Fatalf("the manifest was destroyed by a traversal delete: %v", err) |
| } |
| - status, _, out := rawReq(t, http.MethodDelete, u+"/acme.go.tito.io", token, "") |
| + req, err := http.NewRequest(http.MethodDelete, u+"/acme.go.tito.io", nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + req.Header.Set(activityMessageHeader, "retire the old web copy") |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + outBytes, err := io.ReadAll(res.Body) |
| + res.Body.Close() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, out := res.StatusCode, string(outBytes) |
| if status != http.StatusOK || !strings.Contains(out, "deleted") { |
| t.Fatalf("delete = %d %s, want 200 deleted", status, out) |
| } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 1) |
| + if err != nil || len(events) != 1 || events[0].Message != "retire the old web copy" { |
| + t.Fatalf("delete activity = %+v, %v", events, err) |
| + } |
| if _, _, err := fx.rel.Get(context.Background(), |
| provision.RecordKey("bab", "titogo", "acme.go.tito.io")); err == nil { |
| t.Fatalf("the record survived its delete") |
diff --git a/internal/console/apirestartscope.go b/internal/console/apirestartscope.go| index 6ce6f3fb2..b0a67ee98 100644 |
| --- a/internal/console/apirestartscope.go |
| +++ b/internal/console/apirestartscope.go |
| @@ -103,6 +103,7 @@ func (s *Server) handleAPIRestartScope(w http.ResponseWriter, r *http.Request) { |
| Host string `json:"host"` |
| Environment string `json:"environment"` |
| DryRun bool `json:"dry_run"` |
| + Message string `json:"message"` |
| } |
| // STRICT, and that is a safety property rather than pedantry: a key this |
| // door does not declare — `"hosts"` for `"host"`, a field a future client |
| @@ -112,6 +113,10 @@ func (s *Server) handleAPIRestartScope(w http.ResponseWriter, r *http.Request) { |
| if !readJSONStrictLimit(w, r, &req, restartScopeMaxBytes) { |
| return |
| } |
| + message, ok := checkedActivityMessage(w, req.Message) |
| + if !ok { |
| + return |
| + } |
| host := strings.TrimSpace(req.Host) |
| envName := strings.TrimSpace(req.Environment) |
| if host != "" && !provision.ValidHostShape(host) { |
| @@ -217,7 +222,17 @@ func (s *Server) handleAPIRestartScope(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusBadGateway, "could not request the restart — nothing was cycled; try again") |
| return |
| } |
| - s.audit(acct, app, addr, "restart", restartAuditWords(scope)) |
| + targetType := "instance" |
| + if scope.Kind == release.RestartScopeEnvironment { |
| + targetType = "environment" |
| + } |
| + s.auditActivity(acct, app, addr, Activity{ |
| + Action: "restart", |
| + TargetType: targetType, |
| + Target: scope.Value, |
| + Message: message, |
| + Detail: restartAuditWords(scope), |
| + }) |
| // The request is in the bucket; this is what makes boxes LOOK at it — they |
| // read restart requests only when the serial moves. |
| s.bumpSerial(ctx) |
diff --git a/internal/console/apirestartscope_test.go b/internal/console/apirestartscope_test.go| index d6f31486e..30745ac74 100644 |
| --- a/internal/console/apirestartscope_test.go |
| +++ b/internal/console/apirestartscope_test.go |
| @@ -150,6 +150,37 @@ func TestAPIRestartScopeEnvironmentAddressesOnlyThatBundle(t *testing.T) { |
| } |
| } |
| +func TestAPIRestartScopeRecordsMessageAndTarget(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-message") |
| + status, _, out := postScope(t, fx, token, `{"environment":"relay","message":" cycle the relay workers "}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("restart scope = %d %s, want 200", status, out) |
| + } |
| + events, err := fx.st.Events(OpsAccountID, "titogo", 1) |
| + if err != nil || len(events) != 1 { |
| + t.Fatalf("Events = %+v, %v", events, err) |
| + } |
| + got := events[0] |
| + if got.Action != "restart" || got.TargetType != "environment" || got.Target != "relay" || |
| + got.Message != "cycle the relay workers" || got.Detail != "requested a restart of environment relay (cli)" { |
| + t.Fatalf("activity = %+v", got) |
| + } |
| +} |
| + |
| +func TestAPIRestartScopeRejectsInvalidMessageBeforeWriting(t *testing.T) { |
| + fx, token := scopeFixture(t, "code-scope-message-invalid") |
| + status, _, out := postScope(t, fx, token, `{"host":"bill.go.tito.io","message":"first\nsecond"}`) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("restart scope = %d %s, want 400", status, out) |
| + } |
| + if len(scopeLog(t, fx, "titogo").Requests) != 0 || restartGenEtag(t, fx, "titogo") != "" { |
| + t.Fatal("an invalid message still wrote a restart request") |
| + } |
| + if events, err := fx.st.Events(OpsAccountID, "titogo", 10); err != nil || len(events) != 0 { |
| + t.Fatalf("activity after invalid message = %+v, %v", events, err) |
| + } |
| +} |
| + |
| // TestAPIRestartScopeLeavesTheAppWideObjectAlone is the additive property at |
| // the door: a narrowed request must not move the object every OLD box watches, |
| // or the fleet's un-upgraded boxes would each cycle the whole app. |
diff --git a/internal/console/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/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/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/server.go b/internal/console/server.go| index bdfd75b56..cd68301a9 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1342,6 +1342,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 |
| @@ -3088,9 +3089,31 @@ func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + p.ActivityCopy = activityText |
| if events, err := s.store.Events(acct.ID, p.AppName, activityCap); err != nil { |
| log.Printf("console: events %s: %v", p.AppName, err) |
| } else { |
| + // Only link targets this account's assembled view proves still exist. |
| + // Deleted instances and same-named routes owned by another account stay |
| + // plain text; an activity link must never cross the membership boundary. |
| + instances := make(map[string]bool) |
| + for _, route := range p.Detail.Routes { |
| + if route.Account == acct.Sqid { |
| + instances[route.Host] = true |
| + } |
| + } |
| + for i := range events { |
| + if events[i].TargetType == "release" && events[i].Target != "" { |
| + events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/releases#version-" + url.QueryEscape(events[i].Target) |
| + } |
| + if events[i].TargetType == "channel" && events[i].Target != "" { |
| + events[i].TargetURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/deployments" |
| + } |
| + if events[i].TargetType == "instance" && instances[events[i].Target] { |
| + events[i].TargetURL = p.Base + "/apps/" + url.PathEscape(p.AppName) + |
| + "/instances/" + url.PathEscape(events[i].Target) |
| + } |
| + } |
| p.Events = events |
| } |
| s.render(w, http.StatusOK, "app_activity", p) |
| @@ -3132,6 +3155,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 |
| @@ -3599,7 +3630,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 |
| @@ -3825,7 +3856,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+".") |
| } |
| @@ -4107,7 +4138,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) |
| @@ -4245,7 +4276,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_test.go b/internal/console/server_test.go| index d67a229a1..b9912b540 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -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) |
| } |
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/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/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 |
| } |