console: audit table — the store remembers what owners did
Additive migration (audit table), RecordEvent/Events newest-first with a cap. Slice 3 groundwork: promote/rollback/canary handlers will record here and the app page grows a Recent-activity section.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+86
−0
internal/console/store.go+48 −0internal/console/store_test.go+38 −0
diff --git a/internal/console/store.go b/internal/console/store.go| index 6cbfcbe2f..47ab808a7 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -117,6 +117,14 @@ func (s *Store) migrate() error { |
| app TEXT PRIMARY KEY, |
| created_by TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| +);`, |
| + `CREATE TABLE IF NOT EXISTS audit ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + app TEXT NOT NULL, |
| + address TEXT NOT NULL, |
| + action TEXT NOT NULL, |
| + detail TEXT NOT NULL DEFAULT '', |
| + created_at INTEGER NOT NULL |
| );`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil { |
| @@ -356,6 +364,46 @@ func (s *Store) TakeConfirmation(rawToken string, now time.Time) (string, bool) |
| return addr, true |
| } |
| +// ---- 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. |
| +type Event struct { |
| + App, Address, Action, Detail string |
| + CreatedAt time.Time |
| +} |
| + |
| +// RecordEvent appends one audit row. Callers treat it as best-effort: |
| +// losing an audit line must never fail the action it records. |
| +func (s *Store) RecordEvent(app, address, action, detail string, now time.Time) error { |
| + _, err := s.db.Exec(`INSERT INTO audit (app, address, action, detail, created_at) |
| +VALUES (?, ?, ?, ?, ?)`, app, address, action, detail, now.Unix()) |
| + return err |
| +} |
| + |
| +// Events returns app's audit rows, newest first, capped at limit. |
| +func (s *Store) Events(app string, limit int) ([]Event, error) { |
| + rows, err := s.db.Query(`SELECT app, address, action, detail, created_at |
| +FROM audit WHERE app = ? ORDER BY id DESC LIMIT ?`, app, limit) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []Event |
| + for rows.Next() { |
| + var e Event |
| + var ts int64 |
| + if err := rows.Scan(&e.App, &e.Address, &e.Action, &e.Detail, &ts); err != nil { |
| + return nil, err |
| + } |
| + e.CreatedAt = time.Unix(ts, 0).UTC() |
| + out = append(out, e) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| // ---- keys ------------------------------------------------------------------- |
| // SigningKey returns the console's long-lived envelope-signing key (ECDSA |
diff --git a/internal/console/store_test.go b/internal/console/store_test.go| index 6ec1a8e0d..0e2b4b142 100644 |
| --- a/internal/console/store_test.go |
| +++ b/internal/console/store_test.go |
| @@ -227,6 +227,44 @@ func TestReopenMigrationIdempotent(t *testing.T) { |
| } |
| } |
| +func TestAuditEvents(t *testing.T) { |
| + s := openTestStore(t) |
| + base := time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) |
| + for i, ev := range []Event{ |
| + {App: "kass", Address: "paul@keymail.dev", Action: "promote", Detail: "promoted v1 to stable"}, |
| + {App: "kass", Address: "paul@keymail.dev", Action: "canary-create", Detail: "created canary rc at v2"}, |
| + {App: "aria", Address: "bob@keymail.dev", Action: "promote", Detail: "promoted z1 to stable"}, |
| + } { |
| + if err := s.RecordEvent(ev.App, ev.Address, ev.Action, ev.Detail, base.Add(time.Duration(i)*time.Minute)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + events, err := s.Events("kass", 10) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(events) != 2 { |
| + t.Fatalf("Events(kass) = %+v, want 2 rows", events) |
| + } |
| + // Newest first, and no cross-app leakage. |
| + if events[0].Action != "canary-create" || events[1].Action != "promote" { |
| + t.Fatalf("order = %s, %s — want canary-create first", events[0].Action, events[1].Action) |
| + } |
| + if events[0].Address != "paul@keymail.dev" || events[0].Detail != "created canary rc at v2" { |
| + t.Fatalf("event = %+v", events[0]) |
| + } |
| + if !events[0].CreatedAt.Equal(base.Add(time.Minute)) { |
| + t.Fatalf("CreatedAt = %v, want %v", events[0].CreatedAt, base.Add(time.Minute)) |
| + } |
| + // The cap trims oldest-first. |
| + if events, _ := s.Events("kass", 1); len(events) != 1 || events[0].Action != "canary-create" { |
| + t.Fatalf("Events(kass, 1) = %+v", events) |
| + } |
| + if events, _ := s.Events("ghost", 10); len(events) != 0 { |
| + t.Fatalf("Events(ghost) = %+v, want empty", events) |
| + } |
| +} |
| + |
| func TestCreateAppClaimOnce(t *testing.T) { |
| s := openTestStore(t) |
| now := time.Now() |