From 50870dd369ac01d1842aa1555b72c334a1b2aa0e Mon Sep 17 00:00:00 2001 From: Jack O'Neill Date: Sat, 4 Apr 2026 14:16:02 +0200 Subject: [PATCH] feat(app): add lightweight status access tracking --- internal/app/app.go | 60 +--------- internal/app/status.go | 171 +++++++++++++++++++++++++++ internal/app/status_test.go | 84 +++++++++++++ internal/auth/access_tracker.go | 81 +++++++++++++ internal/auth/access_tracker_test.go | 45 +++++++ internal/auth/keyring_test.go | 10 +- internal/auth/middleware.go | 13 +- internal/auth/oauth_registry_test.go | 4 +- 8 files changed, 405 insertions(+), 63 deletions(-) create mode 100644 internal/app/status.go create mode 100644 internal/app/status_test.go create mode 100644 internal/auth/access_tracker.go create mode 100644 internal/auth/access_tracker_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 9509bc7..939cc0d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -158,7 +158,9 @@ func Run(ctx context.Context, configPath string) error { func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *store.DB, provider ai.Provider, keyring *auth.Keyring, oauthRegistry *auth.OAuthRegistry, tokenStore *auth.TokenStore, authCodes *auth.AuthCodeStore, dynClients *auth.DynamicClientStore, activeProjects *session.ActiveProjects) (http.Handler, error) { mux := http.NewServeMux() - authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, logger) + accessTracker := auth.NewAccessTracker() + oauthEnabled := oauthRegistry != nil && tokenStore != nil + authMiddleware := auth.Middleware(cfg.Auth, keyring, oauthRegistry, tokenStore, accessTracker, logger) filesTool := tools.NewFilesTool(db, activeProjects) metadataRetryer := tools.NewMetadataRetryer(context.Background(), db, provider, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger) @@ -198,7 +200,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st mux.Handle(cfg.MCP.Path, authMiddleware(mcpHandler)) mux.Handle("/files", authMiddleware(fileHandler(filesTool))) mux.Handle("/files/{id}", authMiddleware(fileHandler(filesTool))) - if oauthRegistry != nil && tokenStore != nil { + if oauthEnabled { mux.HandleFunc("/.well-known/oauth-authorization-server", oauthMetadataHandler()) mux.HandleFunc("/oauth-authorization-server", oauthMetadataHandler()) mux.HandleFunc("/oauth/register", oauthRegisterHandler(dynClients, logger)) @@ -227,59 +229,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st _, _ = w.Write([]byte("ready")) }) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - - if r.Method != http.MethodGet && r.Method != http.MethodHead { - w.Header().Set("Allow", "GET, HEAD") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - const homePage = ` - - - - - AMCS - - - -
- Avelon Memory Crystal project image -
-

Avelon Memory Crystal Server (AMCS)

-

AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.

- -
-
- -` - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.WriteHeader(http.StatusOK) - if r.Method == http.MethodHead { - return - } - - _, _ = w.Write([]byte(homePage)) - }) + mux.HandleFunc("/", homeHandler(info, accessTracker, oauthEnabled)) return observability.Chain( mux, diff --git a/internal/app/status.go b/internal/app/status.go new file mode 100644 index 0000000..457ddb0 --- /dev/null +++ b/internal/app/status.go @@ -0,0 +1,171 @@ +package app + +import ( + "fmt" + "html" + "net/http" + "strings" + "time" + + "git.warky.dev/wdevs/amcs/internal/auth" + "git.warky.dev/wdevs/amcs/internal/buildinfo" +) + +const connectedWindow = 10 * time.Minute + +type statusPageData struct { + Version string + BuildDate string + Commit string + ConnectedCount int + TotalKnown int + Entries []auth.AccessSnapshot + OAuthEnabled bool +} + +func renderHomePage(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) string { + entries := tracker.Snapshot() + data := statusPageData{ + Version: fallback(info.Version, "dev"), + BuildDate: fallback(info.BuildDate, "unknown"), + Commit: fallback(info.Commit, "unknown"), + ConnectedCount: tracker.ConnectedCount(now, connectedWindow), + TotalKnown: len(entries), + Entries: entries, + OAuthEnabled: oauthEnabled, + } + + var b strings.Builder + b.WriteString(` + + + + + AMCS + + + +
+ Avelon Memory Crystal project image +
+

Avelon Memory Crystal Server (AMCS)

+

AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.

+
+ LLM Instructions + Health Check + Readiness Check`) + if data.OAuthEnabled { + b.WriteString(` + OAuth Authorization Server`) + } + b.WriteString(` +
+ +
+
+ Connected users + ` + fmt.Sprintf("%d", data.ConnectedCount) + ` +
+
+ Known principals + ` + fmt.Sprintf("%d", data.TotalKnown) + ` +
+
+ Version + ` + html.EscapeString(data.Version) + ` +
+
+ +
+ Build date: ` + html.EscapeString(data.BuildDate) + `  •  + Commit: ` + html.EscapeString(data.Commit) + `  •  + Connected window: last 10 minutes +
+ +

Recent access

`) + if len(data.Entries) == 0 { + b.WriteString(` +

No authenticated access recorded yet.

`) + } else { + b.WriteString(` + + + + + + + + + + `) + for _, entry := range data.Entries { + b.WriteString(` + + + + + + `) + } + b.WriteString(` + +
PrincipalLast accessedLast pathRequests
` + html.EscapeString(entry.KeyID) + `` + html.EscapeString(entry.LastAccessedAt.UTC().Format(time.RFC3339)) + `` + html.EscapeString(entry.LastPath) + `` + fmt.Sprintf("%d", entry.RequestCount) + `
`) + } + b.WriteString(` +
+
+ +`) + + return b.String() +} + +func fallback(value, defaultValue string) string { + if strings.TrimSpace(value) == "" { + return defaultValue + } + return value +} + +func homeHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + + _, _ = w.Write([]byte(renderHomePage(info, tracker, oauthEnabled, time.Now()))) + } +} diff --git a/internal/app/status_test.go b/internal/app/status_test.go new file mode 100644 index 0000000..9c1a67d --- /dev/null +++ b/internal/app/status_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.warky.dev/wdevs/amcs/internal/auth" + "git.warky.dev/wdevs/amcs/internal/buildinfo" + "git.warky.dev/wdevs/amcs/internal/config" +) + +func TestRenderHomePageHidesOAuthLinkWhenDisabled(t *testing.T) { + tracker := auth.NewAccessTracker() + page := renderHomePage(buildinfo.Info{Version: "v1.2.3", BuildDate: "2026-04-04", Commit: "abc123"}, tracker, false, time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)) + + if strings.Contains(page, "/oauth-authorization-server") { + t.Fatal("page unexpectedly contains OAuth link") + } + if !strings.Contains(page, "Connected users") { + t.Fatal("page missing Connected users stat") + } +} + +func TestRenderHomePageShowsTrackedAccess(t *testing.T) { + tracker := auth.NewAccessTracker() + now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC) + tracker.Record("client-a", "/files", "127.0.0.1:1234", "tester", now) + + page := renderHomePage(buildinfo.Info{Version: "v1.2.3"}, tracker, true, now) + + for _, needle := range []string{"client-a", "/files", "1", "/oauth-authorization-server"} { + if !strings.Contains(page, needle) { + t.Fatalf("page missing %q", needle) + } + } +} + +func TestHomeHandlerAllowsHead(t *testing.T) { + handler := homeHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), false) + req := httptest.NewRequest(http.MethodHead, "/", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := rec.Body.String(); body != "" { + t.Fatalf("body = %q, want empty for HEAD", body) + } +} + +func TestMiddlewareRecordsAuthenticatedAccess(t *testing.T) { + keyring, err := auth.NewKeyring([]config.APIKey{{ID: "client-a", Value: "secret"}}) + if err != nil { + t.Fatalf("NewKeyring() error = %v", err) + } + tracker := auth.NewAccessTracker() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := auth.Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, tracker, logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodGet, "/files", nil) + req.Header.Set("x-brain-key", "secret") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + snap := tracker.Snapshot() + if len(snap) != 1 { + t.Fatalf("len(snapshot) = %d, want 1", len(snap)) + } + if snap[0].KeyID != "client-a" || snap[0].LastPath != "/files" { + t.Fatalf("snapshot[0] = %+v, want keyID client-a and path /files", snap[0]) + } +} diff --git a/internal/auth/access_tracker.go b/internal/auth/access_tracker.go new file mode 100644 index 0000000..5799923 --- /dev/null +++ b/internal/auth/access_tracker.go @@ -0,0 +1,81 @@ +package auth + +import ( + "sort" + "sync" + "time" +) + +type AccessSnapshot struct { + KeyID string + LastPath string + RemoteAddr string + UserAgent string + RequestCount int + LastAccessedAt time.Time +} + +type AccessTracker struct { + mu sync.RWMutex + entries map[string]AccessSnapshot +} + +func NewAccessTracker() *AccessTracker { + return &AccessTracker{entries: make(map[string]AccessSnapshot)} +} + +func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent string, now time.Time) { + if t == nil || keyID == "" { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + entry := t.entries[keyID] + entry.KeyID = keyID + entry.LastPath = path + entry.RemoteAddr = remoteAddr + entry.UserAgent = userAgent + entry.LastAccessedAt = now.UTC() + entry.RequestCount++ + t.entries[keyID] = entry +} + +func (t *AccessTracker) Snapshot() []AccessSnapshot { + if t == nil { + return nil + } + + t.mu.RLock() + defer t.mu.RUnlock() + + items := make([]AccessSnapshot, 0, len(t.entries)) + for _, entry := range t.entries { + items = append(items, entry) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].LastAccessedAt.After(items[j].LastAccessedAt) + }) + + return items +} + +func (t *AccessTracker) ConnectedCount(now time.Time, window time.Duration) int { + if t == nil { + return 0 + } + + cutoff := now.UTC().Add(-window) + t.mu.RLock() + defer t.mu.RUnlock() + + count := 0 + for _, entry := range t.entries { + if !entry.LastAccessedAt.Before(cutoff) { + count++ + } + } + return count +} diff --git a/internal/auth/access_tracker_test.go b/internal/auth/access_tracker_test.go new file mode 100644 index 0000000..f383356 --- /dev/null +++ b/internal/auth/access_tracker_test.go @@ -0,0 +1,45 @@ +package auth + +import ( + "testing" + "time" +) + +func TestAccessTrackerRecordAndSnapshot(t *testing.T) { + tracker := NewAccessTracker() + older := time.Date(2026, 4, 4, 10, 0, 0, 0, time.UTC) + newer := older.Add(2 * time.Minute) + + tracker.Record("client-a", "/files", "10.0.0.1:1234", "agent-a", older) + tracker.Record("client-b", "/mcp", "10.0.0.2:1234", "agent-b", newer) + tracker.Record("client-a", "/files/1", "10.0.0.1:1234", "agent-a2", newer.Add(30*time.Second)) + + snap := tracker.Snapshot() + if len(snap) != 2 { + t.Fatalf("len(snapshot) = %d, want 2", len(snap)) + } + if snap[0].KeyID != "client-a" { + t.Fatalf("snapshot[0].KeyID = %q, want client-a", snap[0].KeyID) + } + if snap[0].RequestCount != 2 { + t.Fatalf("snapshot[0].RequestCount = %d, want 2", snap[0].RequestCount) + } + if snap[0].LastPath != "/files/1" { + t.Fatalf("snapshot[0].LastPath = %q, want /files/1", snap[0].LastPath) + } + if snap[0].UserAgent != "agent-a2" { + t.Fatalf("snapshot[0].UserAgent = %q, want agent-a2", snap[0].UserAgent) + } +} + +func TestAccessTrackerConnectedCount(t *testing.T) { + tracker := NewAccessTracker() + now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC) + + tracker.Record("recent", "/mcp", "", "", now.Add(-2*time.Minute)) + tracker.Record("stale", "/mcp", "", "", now.Add(-11*time.Minute)) + + if got := tracker.ConnectedCount(now, 10*time.Minute); got != 1 { + t.Fatalf("ConnectedCount() = %d, want 1", got) + } +} diff --git a/internal/auth/keyring_test.go b/internal/auth/keyring_test.go index 708623e..c72df71 100644 --- a/internal/auth/keyring_test.go +++ b/internal/auth/keyring_test.go @@ -39,7 +39,7 @@ func TestMiddlewareAllowsHeaderAuthAndSetsContext(t *testing.T) { t.Fatalf("NewKeyring() error = %v", err) } - handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { keyID, ok := KeyIDFromContext(r.Context()) if !ok || keyID != "client-a" { t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok) @@ -63,7 +63,7 @@ func TestMiddlewareAllowsBearerAuthAndSetsContext(t *testing.T) { t.Fatalf("NewKeyring() error = %v", err) } - handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { keyID, ok := KeyIDFromContext(r.Context()) if !ok || keyID != "client-a" { t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok) @@ -90,7 +90,7 @@ func TestMiddlewarePrefersExplicitHeaderOverBearerAuth(t *testing.T) { t.Fatalf("NewKeyring() error = %v", err) } - handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { keyID, ok := KeyIDFromContext(r.Context()) if !ok || keyID != "client-a" { t.Fatalf("KeyIDFromContext() = (%q, %v), want (client-a, true)", keyID, ok) @@ -119,7 +119,7 @@ func TestMiddlewareAllowsQueryParamWhenEnabled(t *testing.T) { HeaderName: "x-brain-key", QueryParam: "key", AllowQueryParam: true, - }, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })) @@ -138,7 +138,7 @@ func TestMiddlewareRejectsMissingOrInvalidKey(t *testing.T) { t.Fatalf("NewKeyring() error = %v", err) } - handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Middleware(config.AuthConfig{HeaderName: "x-brain-key"}, keyring, nil, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") })) diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 72c6bcc..1d075bd 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "strings" + "time" "git.warky.dev/wdevs/amcs/internal/config" ) @@ -14,11 +15,16 @@ type contextKey string const keyIDContextKey contextKey = "auth.key_id" -func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthRegistry, tokenStore *TokenStore, log *slog.Logger) func(http.Handler) http.Handler { +func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthRegistry, tokenStore *TokenStore, tracker *AccessTracker, log *slog.Logger) func(http.Handler) http.Handler { headerName := cfg.HeaderName if headerName == "" { headerName = "x-brain-key" } + recordAccess := func(r *http.Request, keyID string) { + if tracker != nil { + tracker.Record(keyID, r.URL.Path, r.RemoteAddr, r.UserAgent(), time.Now()) + } + } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 1. Custom header → keyring only. @@ -30,6 +36,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg http.Error(w, "invalid API key", http.StatusUnauthorized) return } + recordAccess(r, keyID) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) return } @@ -39,12 +46,14 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg if bearer := extractBearer(r); bearer != "" { if tokenStore != nil { if keyID, ok := tokenStore.Lookup(bearer); ok { + recordAccess(r, keyID) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) return } } if keyring != nil { if keyID, ok := keyring.Lookup(bearer); ok { + recordAccess(r, keyID) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) return } @@ -66,6 +75,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg http.Error(w, "invalid OAuth client credentials", http.StatusUnauthorized) return } + recordAccess(r, keyID) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) return } @@ -79,6 +89,7 @@ func Middleware(cfg config.AuthConfig, keyring *Keyring, oauthRegistry *OAuthReg http.Error(w, "invalid API key", http.StatusUnauthorized) return } + recordAccess(r, keyID) next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), keyIDContextKey, keyID))) return } diff --git a/internal/auth/oauth_registry_test.go b/internal/auth/oauth_registry_test.go index f4a9fb9..7f8ba37 100644 --- a/internal/auth/oauth_registry_test.go +++ b/internal/auth/oauth_registry_test.go @@ -42,7 +42,7 @@ func TestMiddlewareAllowsOAuthBasicAuthAndSetsContext(t *testing.T) { t.Fatalf("NewOAuthRegistry() error = %v", err) } - handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { keyID, ok := KeyIDFromContext(r.Context()) if !ok || keyID != "oauth-client" { t.Fatalf("KeyIDFromContext() = (%q, %v), want (oauth-client, true)", keyID, ok) @@ -70,7 +70,7 @@ func TestMiddlewareRejectsOAuthMissingOrInvalidCredentials(t *testing.T) { t.Fatalf("NewOAuthRegistry() error = %v", err) } - handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler := Middleware(config.AuthConfig{}, nil, oauthRegistry, nil, nil, testLogger())(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Fatal("next handler should not be called") }))