Record structured activity descriptions
Store an optional human description separately from each machine-derived action, target, and legacy detail. Carry descriptions through console mutation doors, including scoped restarts and domain attaches, while keeping old events and callers readable. Render Activity with its own description column and typed target links. Keep action details authoritative, validate descriptions before writes, and retain account-scoped authorization and no-op audit behavior. Validation: - go build ./...: pass - make test: pass (go vet, gofmt, and go test ./...) - focused domain attach and scoped restart tests: pass
Co-authored-by: Codex <noreply@openai.com>
26 files changed,
+494
−59
.gitignore+2 −0apimode.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 passed — run details
diff --git a/.gitignore b/.gitignore| index 42d71f76e..183f87420 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -9,3 +9,5 @@ platform |
| # directory in the checkout is that step's cache — see the step for why. |
| .amadan-cache/ |
| infra/modules/**/.terraform.lock.hcl |
| + |
| +writing/ |
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 de186a440..02f2f5c56 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -544,6 +544,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 |
| @@ -583,6 +584,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 |
| @@ -774,7 +779,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. |
| @@ -1192,6 +1200,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 |
| @@ -1237,7 +1249,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 0f9a38a78..6b951a68f 100644 |
| --- a/internal/console/apiinstances_test.go |
| +++ b/internal/console/apiinstances_test.go |
| @@ -1739,10 +1739,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 449b0ab52..7f6b9dcfc 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1322,6 +1322,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 |
| @@ -2866,9 +2867,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) |
| @@ -2910,6 +2933,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 |
| @@ -3377,7 +3408,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 |
| @@ -3603,7 +3634,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+".") |
| } |
| @@ -3885,7 +3916,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) |
| @@ -4023,7 +4054,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 df744afe2..dde583cc7 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 |
| } |