carlos / platform Public

Merge branch 'console-ux-07-activity' into main

paul@keymail.dev cff5c82ba80d5c4eb707b0ed606e60ed493330f7 parent f4207d3parent 4efced9
25 files changed, +492 −59
  • apimode.go +7 −4
  • canary.go +1 −1
  • instances_cli.go +1 −1
  • instances_cli_test.go +20 −0
  • internal/console/activity.go +45 −0
  • internal/console/activity_test.go +86 −0
  • internal/console/apicli.go +45 −6
  • internal/console/apiconfig.go +9 −2
  • internal/console/apidomains.go +10 −2
  • internal/console/apidomains_test.go +40 −1
  • internal/console/apifeatures.go +11 −3
  • internal/console/apifeatures_test.go +9 −5
  • internal/console/apiinstances.go +17 −2
  • internal/console/apiinstances_test.go +20 −1
  • internal/console/apirestartscope.go +16 −1
  • internal/console/apirestartscope_test.go +31 −0
  • internal/console/channelmove.go +3 −3
  • internal/console/domainattach.go +17 −4
  • internal/console/pendinghandlers.go +4 −4
  • internal/console/server.go +35 −4
  • internal/console/server_test.go +1 −2
  • internal/console/store.go +32 −9
  • internal/console/store_test.go +25 −0
  • internal/console/templates/app_activity.html +6 −3
  • mail_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 &lt;script&gt;alert(1)&lt;/script&gt;", "promoted v3 to edge (cli)", "Update delivery configuration", "config: 2 set, 0 unset (relay) (cli)", `/apps/kass/releases#version-v3`, "shipped v3 (cli)"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("missing %q", want)
+ }
+ }
+ if strings.Contains(body, "<script>alert(1)</script>") || strings.Contains(body, "CLI promotions don") {
+ t.Fatal("activity contains unescaped message or obsolete CLI exclusion")
+ }
+}
+
+func TestActivityAppRestartBodyCompatibility(t *testing.T) {
+ for _, body := range []string{"", "{}", `{"message":"Reload delivery configuration"}`} {
+ t.Run(body, func(t *testing.T) {
+ fx, token := restartFixture(t, "activity-restart-body")
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restart", token, body)
+ if status != 200 {
+ t.Fatalf("restart = %d %s", status, out)
+ }
+ events, err := fx.st.Events(OpsAccountID, "titogo", 10)
+ if err != nil || len(events) != 1 {
+ t.Fatalf("events = %+v, %v", events, err)
+ }
+ want := ""
+ if strings.Contains(body, "message") {
+ want = "Reload delivery configuration"
+ }
+ e := events[0]
+ if e.Action != "restart" || e.TargetType != "app" || e.Target != "titogo" || e.Message != want || e.Detail == "" {
+ t.Fatalf("event = %+v", e)
+ }
+ })
+ }
+}
diff --git a/internal/console/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
}