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 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 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.
+
+
+
+
+ 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(`
+
+
+
+ | Principal |
+ Last accessed |
+ Last path |
+ Requests |
+
+
+ `)
+ for _, entry := range data.Entries {
+ b.WriteString(`
+
+ ` + html.EscapeString(entry.KeyID) + ` |
+ ` + html.EscapeString(entry.LastAccessedAt.UTC().Format(time.RFC3339)) + ` |
+ ` + html.EscapeString(entry.LastPath) + ` |
+ ` + fmt.Sprintf("%d", entry.RequestCount) + ` |
+
`)
+ }
+ b.WriteString(`
+
+
`)
+ }
+ 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")
}))