From 00a2579af07b26af43e1cd5a21b4ca5acec1e077 Mon Sep 17 00:00:00 2001 From: Randy Hammond Date: Mon, 27 Apr 2026 00:57:16 +0000 Subject: [PATCH] feat(api): emit Deprecation/Sunset headers on legacy endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds RFC 8594 deprecation headers, structured per-request warn logs, and an admin dashboard banner listing API keys that still hit the legacy surface. No legacy behaviour changed beyond the additive headers. Backend: - middleware.Deprecated(successor, sunset) attaches Deprecation: true, Sunset, Link rel=successor-version, and Cache-Control: no-store to every legacy /api/* and legacy WS route. - Per-request slog.Warn("legacy endpoint hit", method, path, apiKeyIdent) — apiKeyIdent is the truncated identifier (set by APIKeyAuth alongside apiKeyID), never the raw key. - New /api/v1/admin/legacy-usage endpoint returning a 24h aggregate ({method, path, apiKeyIdent, count, lastSeen}) backed by an in-memory ring buffer; no schema change. Frontend: - LegacyUsageBanner reads /api/v1/admin/legacy-usage on the admin dashboard, polls every 60s, dismissable per session via sessionStorage. Expandable details table with method, path, API key, count, last-seen relative time. Tests: middleware/deprecation_test.go covers header emission and the ring buffer; legacyusage/handler_test.go covers the aggregate endpoint; LegacyUsageBanner.test.tsx covers loading, empty, populated, and dismissed states. --- CHANGELOG.md | 3 + .../handler/admin/legacyusage/handler.go | 78 ++++++ .../handler/admin/legacyusage/handler_test.go | 184 +++++++++++++ backend/internal/handler/routes/routes.go | 80 +++--- backend/internal/middleware/auth.go | 3 + backend/internal/middleware/deprecation.go | 244 ++++++++++++++++++ .../internal/middleware/deprecation_test.go | 228 ++++++++++++++++ frontend/src/app/api.ts | 12 +- .../admin/LegacyUsageBanner.test.tsx | 161 ++++++++++++ .../components/admin/LegacyUsageBanner.tsx | 118 +++++++++ frontend/src/pages/Admin.test.tsx | 6 + frontend/src/pages/Admin.tsx | 2 + frontend/src/types/api.ts | 15 ++ 13 files changed, 1099 insertions(+), 35 deletions(-) create mode 100644 backend/internal/handler/admin/legacyusage/handler.go create mode 100644 backend/internal/handler/admin/legacyusage/handler_test.go create mode 100644 backend/internal/middleware/deprecation.go create mode 100644 backend/internal/middleware/deprecation_test.go create mode 100644 frontend/src/components/admin/LegacyUsageBanner.test.tsx create mode 100644 frontend/src/components/admin/LegacyUsageBanner.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 502fa73..0c337bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - v1 listener endpoints: `GET/PUT /api/v1/listener/tg-selection` (renamed from `/api/auth/tg-selection`), `GET /api/v1/calls`, `GET /api/v1/calls/:id/audio`, `GET /api/v1/calls/:id/transcript`, share/bookmark endpoints, and unauthenticated `/api/v1/health`, `/api/v1/setup/*`, `/api/v1/auth/{login,refresh,logout,password,me}`. - v1 admin endpoints under `/api/v1/admin/*` for talkgroup/unit/group/tag imports, RadioReference preview (path simplified — no `/csv` suffix), transcription status, and Swagger session bootstrap. - Native JSON-object framed WebSocket protocol on `GET /api/v1/ws/listener` and `GET /api/v1/ws/admin`. Frames carry a `type` discriminator (`connection.welcome`, `scanner.config`, `call.new`, `call.transcript`, `listener.count`, `listener.feedMap.snapshot`/`update`, `session.expired`, `connection.rejected`, `admin.event`, `admin.request`, `admin.response`) instead of the legacy 3-letter array opcodes. Admin error responses mirror the REST `{code,message,details?}` envelope. The frontend connects to the v1 paths; legacy `/ws`, `/api/ws`, and `/api/admin/ws` keep emitting the array-framed protocol unchanged for in-the-wild clients. +- RFC 8594 deprecation headers (`Deprecation: true`, `Sunset`, `Link: ; rel="successor-version"`, `Cache-Control: no-store`) on every legacy `/api/*` and legacy WebSocket route, pointing at the native `/api/v1/*` successor. Per-request structured warn log (`legacy endpoint hit`) records method, path, and a truncated API-key identifier — never the raw key. +- Admin endpoint `GET /api/v1/admin/legacy-usage` returning a 24-hour aggregate of legacy-endpoint hits (`{method, path, apiKeyIdent, count, lastSeen}`), backed by an in-memory ring buffer (no schema change). +- Admin dashboard banner that surfaces legacy-API usage from the new endpoint, with an expandable details table (method, path, API key, count, last seen) and per-session dismiss. ### Changed diff --git a/backend/internal/handler/admin/legacyusage/handler.go b/backend/internal/handler/admin/legacyusage/handler.go new file mode 100644 index 0000000..bf884cf --- /dev/null +++ b/backend/internal/handler/admin/legacyusage/handler.go @@ -0,0 +1,78 @@ +// Package legacyusage exposes the 24-hour legacy /api/* hit aggregate to +// admins. Backed by the in-memory ring buffer in +// middleware.DefaultLegacyUsageStore — see Phase N-3 in +// docs/plans/native-api-design-plan.md. +package legacyusage + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/middleware" +) + +// LegacyUsageEntry mirrors middleware.LegacyUsageEntry for swagger generation. +type LegacyUsageEntry struct { + Path string `json:"path"` + Method string `json:"method"` + APIKeyIdent string `json:"apiKeyIdent"` + Count int `json:"count"` + LastSeen time.Time `json:"lastSeen"` +} // @name LegacyUsageEntry + +// LegacyUsageResponse is the v1 admin envelope. +type LegacyUsageResponse struct { + WindowSeconds int `json:"windowSeconds"` + GeneratedAt time.Time `json:"generatedAt"` + Entries []LegacyUsageEntry `json:"entries"` +} // @name LegacyUsageResponse + +// Handler serves the legacy-usage report. +type Handler struct { + store *middleware.LegacyUsageStore + now func() time.Time +} + +// New constructs a Handler. store may be nil to use the package singleton; +// now may be nil to use time.Now. +func New(store *middleware.LegacyUsageStore, now func() time.Time) *Handler { + if store == nil { + store = middleware.DefaultLegacyUsageStore + } + if now == nil { + now = time.Now + } + return &Handler{store: store, now: now} +} + +// GetUsage handles GET /api/v1/admin/legacy-usage. +// +// @Summary Legacy /api/* usage report (24h) +// @Description Returns one entry per (path, method, apiKeyIdent) tuple seen on the legacy /api/* surface in the last 24 hours, sourced from an in-memory ring buffer. Used by the admin dashboard to surface clients that still need to migrate to /api/v1. +// @Tags v1-Admin +// @Produce json +// @Security BearerAuth +// @Success 200 {object} LegacyUsageResponse +// @Failure 401 {object} shared.APIErrorResponse +// @Failure 403 {object} shared.APIErrorResponse +// @Router /v1/admin/legacy-usage [get] +func (h *Handler) GetUsage(c *gin.Context) { + raw := h.store.Aggregate24h() + entries := make([]LegacyUsageEntry, len(raw)) + for i, e := range raw { + entries[i] = LegacyUsageEntry{ + Path: e.Path, + Method: e.Method, + APIKeyIdent: e.APIKeyIdent, + Count: e.Count, + LastSeen: e.LastSeen.UTC(), + } + } + c.JSON(http.StatusOK, LegacyUsageResponse{ + WindowSeconds: int((24 * time.Hour).Seconds()), + GeneratedAt: h.now().UTC(), + Entries: entries, + }) +} diff --git a/backend/internal/handler/admin/legacyusage/handler_test.go b/backend/internal/handler/admin/legacyusage/handler_test.go new file mode 100644 index 0000000..f4ea509 --- /dev/null +++ b/backend/internal/handler/admin/legacyusage/handler_test.go @@ -0,0 +1,184 @@ +package legacyusage_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" + "github.com/openscanner/openscanner/internal/middleware" + _ "modernc.org/sqlite" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +func newEngine(t *testing.T) (*gin.Engine, *db.Queries) { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{Queries: q, RateLimiter: rl, Version: "test"}) + return r, q +} + +func makeUser(t *testing.T, q *db.Queries, role string) (int64, string) { + t.Helper() + hash, _ := auth.HashPassword("pw") + now := time.Now().Unix() + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "u-" + role, PasswordHash: hash, Role: role, + CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "u-"+role, role, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + return uid, tok +} + +// TestGetLegacyUsage_EmptyReturnsEmptyArray asserts the entries field +// serialises as `[]`, not `null`, when the ring buffer has no records. +func TestGetLegacyUsage_EmptyReturnsEmptyArray(t *testing.T) { + // Reset the singleton so this test sees no leakage. + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r, q := newEngine(t) + _, tok := makeUser(t, q, auth.RoleAdmin) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/legacy-usage", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !contains(body, `"entries":[]`) { + t.Errorf("expected entries:[] in body, got: %s", body) + } + if !contains(body, `"windowSeconds":86400`) { + t.Errorf("expected windowSeconds:86400, got: %s", body) + } +} + +// TestGetLegacyUsage_PopulatedAfterLegacyHit ingests one hit on a legacy +// route and verifies it appears in the v1 aggregate report. +func TestGetLegacyUsage_PopulatedAfterLegacyHit(t *testing.T) { + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r, q := newEngine(t) + _, tok := makeUser(t, q, auth.RoleAdmin) + + // Hit a public legacy route — /api/health is unauthenticated. + for i := 0; i < 2; i++ { + hreq := httptest.NewRequest(http.MethodGet, "/api/health", nil) + hw := httptest.NewRecorder() + r.ServeHTTP(hw, hreq) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/legacy-usage", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body.String()) + } + var resp struct { + WindowSeconds int `json:"windowSeconds"` + Entries []struct { + Path string `json:"path"` + Method string `json:"method"` + APIKeyIdent string `json:"apiKeyIdent"` + Count int `json:"count"` + } `json:"entries"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v; body=%s", err, w.Body.String()) + } + if resp.WindowSeconds != 86400 { + t.Errorf("windowSeconds = %d, want 86400", resp.WindowSeconds) + } + + // Find the /api/health row. + var found bool + for _, e := range resp.Entries { + if e.Path == "/api/health" && e.Method == http.MethodGet { + found = true + if e.Count != 2 { + t.Errorf("count for /api/health = %d, want 2", e.Count) + } + if e.APIKeyIdent != "" { + t.Errorf("apiKeyIdent for unauth /api/health = %q, want \"\"", e.APIKeyIdent) + } + } + } + if !found { + t.Errorf("expected /api/health entry in aggregate, got: %+v", resp.Entries) + } +} + +// TestGetLegacyUsage_ListenerForbidden — JWT must have admin role. +func TestGetLegacyUsage_ListenerForbidden(t *testing.T) { + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r, q := newEngine(t) + _, tok := makeUser(t, q, auth.RoleListener) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/legacy-usage", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String()) + } +} + +// TestGetLegacyUsage_Unauthenticated — without a JWT, returns 401. +func TestGetLegacyUsage_Unauthenticated(t *testing.T) { + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r, _ := newEngine(t) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/legacy-usage", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String()) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && (indexOf(haystack, needle) >= 0) +} + +func indexOf(haystack, needle string) int { + for i := 0; i+len(needle) <= len(haystack); i++ { + if haystack[i:i+len(needle)] == needle { + return i + } + } + return -1 +} diff --git a/backend/internal/handler/routes/routes.go b/backend/internal/handler/routes/routes.go index 1c25b5b..c36b122 100644 --- a/backend/internal/handler/routes/routes.go +++ b/backend/internal/handler/routes/routes.go @@ -23,6 +23,7 @@ import ( "github.com/openscanner/openscanner/internal/db" "github.com/openscanner/openscanner/internal/downstream" "github.com/openscanner/openscanner/internal/handler/admin/imports" + "github.com/openscanner/openscanner/internal/handler/admin/legacyusage" "github.com/openscanner/openscanner/internal/handler/admin/radioreference" "github.com/openscanner/openscanner/internal/handler/admin/transcriptions" authhandler "github.com/openscanner/openscanner/internal/handler/auth" @@ -87,6 +88,7 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { importsHandler := imports.New(deps.Queries, deps.Hub) rrHandler := radioreference.New(deps.Queries) transcriptionsHandler := transcriptions.New(deps.Queries, deps.WhisperAvailable) + legacyUsageHandler := legacyusage.New(nil, nil) // Global middleware applied to every request. r.Use(middleware.RequestID()) @@ -95,50 +97,59 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { api := r.Group("/api") + // Phase N-3 — RFC 8594 deprecation signalling for legacy /api/* routes. + // Each registration below carries a `dep("/api/v1/...")` middleware that + // emits the Deprecation/Sunset/Link headers, records an entry in the + // 24-hour usage ring buffer, and emits a structured warn log. The map + // is hand-built so the successor URI on the Link header is exact. + dep := func(successor string) gin.HandlerFunc { + return middleware.Deprecated(successor, middleware.LegacyAPISunset) + } + // Health check — unauthenticated. - api.GET("/health", healthHandler.Get) + api.GET("/health", dep("/api/v1/health"), healthHandler.Get) // First-run setup — unauthenticated. - api.GET("/setup/status", setupHandler.GetSetupStatus) - api.POST("/setup", middleware.MaxBodySize(1<<20), setupHandler.PostSetup) + api.GET("/setup/status", dep("/api/v1/setup/status"), setupHandler.GetSetupStatus) + api.POST("/setup", dep("/api/v1/setup"), middleware.MaxBodySize(1<<20), setupHandler.PostSetup) // Auth — login and refresh are unauthenticated; the rest require a valid JWT. - api.POST("/auth/login", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostLogin) - api.POST("/auth/refresh", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostRefresh) + api.POST("/auth/login", dep("/api/v1/auth/login"), middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostLogin) + api.POST("/auth/refresh", dep("/api/v1/auth/refresh"), middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostRefresh) authRequired := api.Group("/auth") authRequired.Use(middleware.JWTAuth()) { - authRequired.POST("/logout", authH.PostLogout) - authRequired.PUT("/password", authH.PutPassword) - authRequired.GET("/me", authH.GetMe) - authRequired.GET("/tg-selection", authH.GetTGSelection) - authRequired.PUT("/tg-selection", authH.PutTGSelection) + authRequired.POST("/logout", dep("/api/v1/auth/logout"), authH.PostLogout) + authRequired.PUT("/password", dep("/api/v1/auth/password"), authH.PutPassword) + authRequired.GET("/me", dep("/api/v1/auth/me"), authH.GetMe) + authRequired.GET("/tg-selection", dep("/api/v1/listener/tg-selection"), authH.GetTGSelection) + authRequired.PUT("/tg-selection", dep("/api/v1/listener/tg-selection"), authH.PutTGSelection) } // Call search — public access with optional auth for bookmarks. - api.GET("/calls", middleware.OptionalJWTAuth(), callHandler.GetCalls) - api.GET("/calls/:id/audio", middleware.OptionalJWTOrSessionAuth(), callHandler.GetCallAudio) - api.GET("/calls/:id/transcript", middleware.OptionalJWTAuth(), callHandler.GetCallTranscript) + api.GET("/calls", dep("/api/v1/calls"), middleware.OptionalJWTAuth(), callHandler.GetCalls) + api.GET("/calls/:id/audio", dep("/api/v1/calls/:id/audio"), middleware.OptionalJWTOrSessionAuth(), callHandler.GetCallAudio) + api.GET("/calls/:id/transcript", dep("/api/v1/calls/:id/transcript"), middleware.OptionalJWTAuth(), callHandler.GetCallTranscript) // Shared calls — token-based public access (no auth required). // Rate-limited to 30 req/min per IP to prevent bandwidth exhaustion. sharedRateLimit := middleware.RateLimitByIP(30) - api.GET("/shared/:token", sharedRateLimit, shareHandler.GetSharedCallByToken) - api.GET("/shared/:token/audio", sharedRateLimit, shareHandler.GetSharedCallAudio) + api.GET("/shared/:token", dep("/api/v1/shared/:token"), sharedRateLimit, shareHandler.GetSharedCallByToken) + api.GET("/shared/:token/audio", dep("/api/v1/shared/:token/audio"), sharedRateLimit, shareHandler.GetSharedCallAudio) // Share management — JWT required. - api.POST("/calls/:id/share", middleware.JWTAuth(), shareHandler.PostShareCall) - api.DELETE("/calls/:id/share", middleware.JWTAuth(), shareHandler.DeleteShareCall) - api.GET("/calls/:id/share", middleware.JWTAuth(), shareHandler.GetCallShare) + api.POST("/calls/:id/share", dep("/api/v1/calls/:id/share"), middleware.JWTAuth(), shareHandler.PostShareCall) + api.DELETE("/calls/:id/share", dep("/api/v1/calls/:id/share"), middleware.JWTAuth(), shareHandler.DeleteShareCall) + api.GET("/calls/:id/share", dep("/api/v1/calls/:id/share"), middleware.JWTAuth(), shareHandler.GetCallShare) // Bookmarks — JWT required. bookmarksGroup := api.Group("/bookmarks") bookmarksGroup.Use(middleware.JWTAuth()) { - bookmarksGroup.GET("", bookmarkHandler.GetBookmarkIDs) - bookmarksGroup.GET("/calls", bookmarkHandler.GetBookmarkCalls) - bookmarksGroup.POST("", bookmarkHandler.PostToggleBookmark) + bookmarksGroup.GET("", dep("/api/v1/bookmarks"), bookmarkHandler.GetBookmarkIDs) + bookmarksGroup.GET("/calls", dep("/api/v1/bookmarks/calls"), bookmarkHandler.GetBookmarkCalls) + bookmarksGroup.POST("", dep("/api/v1/bookmarks"), bookmarkHandler.PostToggleBookmark) } // Call upload — API key auth. @@ -147,8 +158,8 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { upload := r.Group("/") upload.Use(middleware.MaxBodySize(50<<20), middleware.APIKeyAuth(deps.Queries)) { - upload.POST("/api/call-upload", callHandler.PostCallUpload) - upload.POST("/api/trunk-recorder-call-upload", callHandler.PostCallUpload) + upload.POST("/api/call-upload", dep("/api/v1/calls"), callHandler.PostCallUpload) + upload.POST("/api/trunk-recorder-call-upload", dep("/api/v1/calls"), callHandler.PostCallUpload) } // Admin routes — JWT + admin role required. @@ -156,20 +167,20 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { admin.Use(middleware.JWTAuth(), middleware.RequireAdmin(), middleware.MaxBodySize(2<<20)) // 2 MiB JSON body limit { // Import (file uploads — must stay REST) - admin.POST("/import/talkgroups", importsHandler.ImportTalkgroups) - admin.POST("/import/units", importsHandler.ImportUnits) - admin.POST("/import/groups", importsHandler.ImportGroups) - admin.POST("/import/tags", importsHandler.ImportTags) + admin.POST("/import/talkgroups", dep("/api/v1/admin/import/talkgroups"), importsHandler.ImportTalkgroups) + admin.POST("/import/units", dep("/api/v1/admin/import/units"), importsHandler.ImportUnits) + admin.POST("/import/groups", dep("/api/v1/admin/import/groups"), importsHandler.ImportGroups) + admin.POST("/import/tags", dep("/api/v1/admin/import/tags"), importsHandler.ImportTags) // RadioReference CSV preview (file upload — must stay REST) - admin.POST("/radioreference/preview/csv", rrHandler.PreviewCSV) + admin.POST("/radioreference/preview/csv", dep("/api/v1/admin/radioreference/preview"), rrHandler.PreviewCSV) // Transcription status - admin.GET("/transcriptions/status", transcriptionsHandler.GetStatus) + admin.GET("/transcriptions/status", dep("/api/v1/admin/transcriptions/status"), transcriptionsHandler.GetStatus) // Swagger: issue a short-lived HTTP-only cookie so Swagger UI // can be opened in a new browser tab without exposing the JWT. - admin.POST("/docs/session", authhandler.PostDocsSession) + admin.POST("/docs/session", dep("/api/v1/admin/docs/session"), authhandler.PostDocsSession) } // Swagger API documentation — protected by the HTTP-only cookie @@ -177,7 +188,7 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { swaggerDocs := api.Group("/admin/docs") swaggerDocs.Use(middleware.SwaggerCookieAuth()) { - swaggerDocs.GET("/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + swaggerDocs.GET("/*any", dep("/api/v1/admin/docs/*any"), ginSwagger.WrapHandler(swaggerFiles.Handler)) } // WebSocket endpoints. @@ -185,9 +196,9 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { // compatibility alias that delegates to the same handler so existing // rdio-scanner-shaped clients keep working during the legacy-API transition. listenerWS := gin.WrapF(ws.HandleListenerWS(deps.Hub, deps.Queries)) - r.GET("/api/ws", listenerWS) - r.GET("/ws", listenerWS) - r.GET("/api/admin/ws", gin.WrapF(ws.HandleAdminWS(deps.Hub, deps.Queries))) + r.GET("/api/ws", dep("/api/v1/ws/listener"), listenerWS) + r.GET("/ws", dep("/api/v1/ws/listener"), listenerWS) + r.GET("/api/admin/ws", dep("/api/v1/ws/admin"), gin.WrapF(ws.HandleAdminWS(deps.Hub, deps.Queries))) // Native (v1) WebSocket endpoints. Registered on the root router rather // than inside the /api/v1 group because the V1ErrorEnvelope middleware @@ -256,6 +267,7 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { // Plan §4.1 drops the trailing `/csv` segment on the v1 path. v1Admin.POST("/radioreference/preview", rrHandler.PreviewCSV) v1Admin.GET("/transcriptions/status", transcriptionsHandler.GetStatus) + v1Admin.GET("/legacy-usage", legacyUsageHandler.GetUsage) v1Admin.POST("/docs/session", authhandler.PostDocsSession) } v1SwaggerDocs := v1.Group("/admin/docs") diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index c1c51a9..5389db0 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -285,6 +285,9 @@ func APIKeyAuth(queries *db.Queries) gin.HandlerFunc { } c.Set("apiKeyID", apiKey.ID) + if apiKey.Ident.Valid { + c.Set("apiKeyIdent", apiKey.Ident.String) + } if apiKey.CallRateLimit.Valid { c.Set("apiKeyCallRate", apiKey.CallRateLimit.Int64) } diff --git a/backend/internal/middleware/deprecation.go b/backend/internal/middleware/deprecation.go new file mode 100644 index 0000000..fa22ee9 --- /dev/null +++ b/backend/internal/middleware/deprecation.go @@ -0,0 +1,244 @@ +// Phase N-3 — RFC 8594 deprecation signalling for legacy /api/* routes. +// +// Deprecated() emits the Deprecation, Sunset, Link, and Cache-Control headers +// on every response of the route it wraps, records a per-request entry into a +// 24-hour ring buffer for the admin "legacy-usage" report, and emits a +// structured slog warn line for operators. Functional behaviour is unchanged. +// +// See docs/plans/native-api-design-plan.md §10 "Phase N-3". +package middleware + +import ( + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// LegacyAPISunset is the date after which the legacy /api/* surface will be +// removed. Sent in the RFC 8594 Sunset header on every legacy response. +// +// One year out from Phase N-3 implementation. Bumped only when the project +// formally rescheduled Phase N-6 ("410 Gone"). +var LegacyAPISunset = time.Date(2027, time.April, 26, 0, 0, 0, 0, time.UTC) + +// LegacyUsageEntry is one row in the 24-hour aggregate report produced by +// LegacyUsageStore.Aggregate24h. JSON tags match the admin endpoint contract. +type LegacyUsageEntry struct { + Path string `json:"path"` + Method string `json:"method"` + APIKeyIdent string `json:"apiKeyIdent"` + Count int `json:"count"` + LastSeen time.Time `json:"lastSeen"` +} + +// legacyUsageRecord is one ring-buffer slot. Stored as a value type so the +// slice is contiguous and aggregation is cache-friendly. +type legacyUsageRecord struct { + at time.Time + path string + method string + status int + apiKeyIdent string + used bool +} + +// LegacyUsageStore is a fixed-capacity, concurrency-safe ring buffer of +// recent legacy-route hits. A single instance is shared by every Deprecated +// middleware via DefaultLegacyUsageStore. +type LegacyUsageStore struct { + mu sync.Mutex + buf []legacyUsageRecord + head int // next write slot + full bool // whether the buffer has wrapped at least once + clock func() time.Time + maxAge time.Duration +} + +const legacyUsageCapacity = 4096 + +// NewLegacyUsageStore returns a new ring buffer with capacity 4096 entries +// and a 24-hour retention window. clock may be nil to use time.Now. +func NewLegacyUsageStore(clock func() time.Time) *LegacyUsageStore { + if clock == nil { + clock = time.Now + } + return &LegacyUsageStore{ + buf: make([]legacyUsageRecord, legacyUsageCapacity), + clock: clock, + maxAge: 24 * time.Hour, + } +} + +// DefaultLegacyUsageStore is the package-level singleton used by Deprecated() +// and surfaced by the GET /api/v1/admin/legacy-usage handler. Tests that need +// isolation can construct their own instance via NewLegacyUsageStore. +var DefaultLegacyUsageStore = NewLegacyUsageStore(nil) + +// Record appends one legacy-hit observation. Never blocks; never errors. +func (s *LegacyUsageStore) Record(path, method, apiKeyIdent string, status int) { + s.mu.Lock() + defer s.mu.Unlock() + s.buf[s.head] = legacyUsageRecord{ + at: s.clock(), + path: path, + method: method, + status: status, + apiKeyIdent: apiKeyIdent, + used: true, + } + s.head = (s.head + 1) % len(s.buf) + if s.head == 0 { + s.full = true + } +} + +// Aggregate24h returns one entry per (path, method, apiKeyIdent) tuple seen +// in the last 24 hours, with the count and most recent timestamp. Older +// records are pruned at read time. Result is sorted by lastSeen descending. +func (s *LegacyUsageStore) Aggregate24h() []LegacyUsageEntry { + s.mu.Lock() + defer s.mu.Unlock() + + cutoff := s.clock().Add(-s.maxAge) + type aggKey struct{ path, method, ident string } + agg := make(map[aggKey]*LegacyUsageEntry) + + walk := func(rec legacyUsageRecord) { + if !rec.used || rec.at.Before(cutoff) { + return + } + k := aggKey{rec.path, rec.method, rec.apiKeyIdent} + e, ok := agg[k] + if !ok { + agg[k] = &LegacyUsageEntry{ + Path: rec.path, + Method: rec.method, + APIKeyIdent: rec.apiKeyIdent, + Count: 1, + LastSeen: rec.at, + } + return + } + e.Count++ + if rec.at.After(e.LastSeen) { + e.LastSeen = rec.at + } + } + for _, rec := range s.buf { + walk(rec) + } + + out := make([]LegacyUsageEntry, 0, len(agg)) + for _, e := range agg { + out = append(out, *e) + } + // Sort by lastSeen desc, then path asc for deterministic output. + for i := 1; i < len(out); i++ { + for j := i; j > 0; j-- { + a, b := out[j-1], out[j] + if a.LastSeen.Before(b.LastSeen) || + (a.LastSeen.Equal(b.LastSeen) && a.Path > b.Path) { + out[j-1], out[j] = out[j], out[j-1] + continue + } + break + } + } + return out +} + +// Deprecated returns a Gin middleware that: +// +// 1. Adds the RFC 8594 deprecation headers (Deprecation, Sunset, Link) and +// Cache-Control: no-store BEFORE the handler runs, so the headers are +// present even when the downstream handler aborts. +// +// 2. After the handler completes, records the request into the +// DefaultLegacyUsageStore ring buffer and emits a structured slog warn +// line so operators can drive the migration. +// +// successor — the v1 path that replaces this legacy route +// (e.g. "/api/v1/calls"). Sent verbatim in the Link header. +// sunset — the date after which the legacy route will be removed. +// Sent in RFC 1123 form (Sunset header). +func Deprecated(successor string, sunset time.Time) gin.HandlerFunc { + sunsetHeader := sunset.UTC().Format(http.TimeFormat) + linkHeader := fmt.Sprintf(`<%s>; rel="successor-version"`, successor) + return func(c *gin.Context) { + c.Header("Deprecation", "true") + c.Header("Sunset", sunsetHeader) + c.Header("Link", linkHeader) + c.Header("Cache-Control", "no-store") + + c.Next() + + ident := resolveLegacyUsageIdent(c) + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + status := c.Writer.Status() + DefaultLegacyUsageStore.Record(path, c.Request.Method, ident, status) + slog.WarnContext(c.Request.Context(), "legacy endpoint hit", + "path", path, + "method", c.Request.Method, + "apiKeyIdent", ident, + "status", status, + ) + } +} + +// resolveLegacyUsageIdent returns a short identifier for the caller without +// ever exposing a raw API key. Lookup priority: +// +// 1. apiKeyIdent set by APIKeyAuth (truncated to 6 chars). +// 2. apiKeyID set by APIKeyAuth (decimal string). +// 3. userID set by JWTAuth (formatted as "u:"). +// 4. empty string for unauthenticated public requests. +func resolveLegacyUsageIdent(c *gin.Context) string { + if v, ok := c.Get("apiKeyIdent"); ok { + if s, ok := v.(string); ok && s != "" { + return truncateIdent(s, 6) + } + } + if v, ok := c.Get("apiKeyID"); ok { + switch id := v.(type) { + case int64: + if id != 0 { + return fmt.Sprintf("%d", id) + } + case int: + if id != 0 { + return fmt.Sprintf("%d", id) + } + } + } + if v, ok := c.Get("userID"); ok { + switch id := v.(type) { + case int64: + if id != 0 { + return fmt.Sprintf("u:%d", id) + } + case int: + if id != 0 { + return fmt.Sprintf("u:%d", id) + } + } + } + return "" +} + +// truncateIdent returns the first n runes of s. Used to keep API-key idents +// from leaking sensitive length information into log lines and the admin +// usage report. +func truncateIdent(s string, n int) string { + if n <= 0 || len(s) <= n { + return s + } + // Byte-truncate is fine here — idents are ASCII in practice. + return s[:n] +} diff --git a/backend/internal/middleware/deprecation_test.go b/backend/internal/middleware/deprecation_test.go new file mode 100644 index 0000000..6191e3b --- /dev/null +++ b/backend/internal/middleware/deprecation_test.go @@ -0,0 +1,228 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/middleware" +) + +// TestDeprecatedHeaders asserts the four RFC 8594 headers are set on every +// response, including aborted handler chains, and use the configured sunset. +func TestDeprecatedHeaders(t *testing.T) { + sunset := time.Date(2027, time.April, 26, 0, 0, 0, 0, time.UTC) + wantSunset := sunset.UTC().Format(http.TimeFormat) + + tests := []struct { + name string + path string + handle gin.HandlerFunc + }{ + { + name: "200 ok handler", + path: "/api/calls", + handle: func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }, + }, + { + name: "handler aborts with 401", + path: "/api/calls", + handle: func(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "nope"}) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET(tt.path, middleware.Deprecated("/api/v1/calls", sunset), tt.handle) + + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if got := w.Header().Get("Deprecation"); got != "true" { + t.Errorf("Deprecation header = %q, want %q", got, "true") + } + if got := w.Header().Get("Sunset"); got != wantSunset { + t.Errorf("Sunset header = %q, want %q", got, wantSunset) + } + wantLink := `; rel="successor-version"` + if got := w.Header().Get("Link"); got != wantLink { + t.Errorf("Link header = %q, want %q", got, wantLink) + } + if got := w.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control header = %q, want %q", got, "no-store") + } + }) + } +} + +// TestDeprecatedRecordsToDefaultStore confirms the singleton ring buffer +// observes hits so the admin /legacy-usage endpoint sees real traffic. +func TestDeprecatedRecordsToDefaultStore(t *testing.T) { + gin.SetMode(gin.TestMode) + // Reset the singleton so a parallel test run is deterministic. + middleware.DefaultLegacyUsageStore = middleware.NewLegacyUsageStore(nil) + + r := gin.New() + r.GET("/api/calls", middleware.Deprecated("/api/v1/calls", time.Now().Add(24*time.Hour)), + func(c *gin.Context) { c.Status(http.StatusOK) }) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest(http.MethodGet, "/api/calls", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + } + + got := middleware.DefaultLegacyUsageStore.Aggregate24h() + if len(got) != 1 { + t.Fatalf("aggregate len = %d, want 1; got: %+v", len(got), got) + } + e := got[0] + if e.Path != "/api/calls" || e.Method != http.MethodGet || e.Count != 3 { + t.Errorf("unexpected entry: %+v", e) + } +} + +// TestLegacyUsageStoreRecordAndAggregate exercises the ring buffer directly: +// distinct tuples remain distinct, repeated tuples are counted, and stale +// records (>24h) are pruned at read time. +func TestLegacyUsageStoreRecordAndAggregate(t *testing.T) { + now := time.Date(2026, time.April, 26, 12, 0, 0, 0, time.UTC) + clock := now + store := middleware.NewLegacyUsageStore(func() time.Time { return clock }) + + // Five fresh hits, three of which share a tuple. + store.Record("/api/calls", "GET", "k1", 200) + clock = clock.Add(time.Second) + store.Record("/api/calls", "GET", "k1", 200) + clock = clock.Add(time.Second) + store.Record("/api/calls", "GET", "k1", 200) + clock = clock.Add(time.Second) + store.Record("/api/calls", "POST", "k1", 200) + clock = clock.Add(time.Second) + store.Record("/api/health", "GET", "", 200) + + // One stale hit older than the 24h window — must be pruned. + clock = now.Add(-25 * time.Hour) + store.Record("/api/old", "GET", "k1", 200) + clock = now.Add(time.Minute) + + entries := store.Aggregate24h() + if len(entries) != 3 { + t.Fatalf("aggregate len = %d, want 3; got: %+v", len(entries), entries) + } + + byKey := map[string]middleware.LegacyUsageEntry{} + for _, e := range entries { + byKey[e.Method+" "+e.Path+" "+e.APIKeyIdent] = e + } + if got := byKey["GET /api/calls k1"].Count; got != 3 { + t.Errorf("count for GET /api/calls k1 = %d, want 3", got) + } + if got := byKey["POST /api/calls k1"].Count; got != 1 { + t.Errorf("count for POST /api/calls k1 = %d, want 1", got) + } + if got := byKey["GET /api/health "].Count; got != 1 { + t.Errorf("count for GET /api/health (anon) = %d, want 1", got) + } + if _, present := byKey["GET /api/old k1"]; present { + t.Errorf("expected stale /api/old entry to be pruned, but was present") + } +} + +// TestLegacyUsageStoreRingWrap asserts that once the buffer wraps, the +// oldest records are overwritten and not returned. +func TestLegacyUsageStoreRingWrap(t *testing.T) { + store := middleware.NewLegacyUsageStore(nil) + // 4096 + 10 hits with rotating idents — only the last 4096 may aggregate. + const overflow = 10 + const cap = 4096 + for i := 0; i < cap+overflow; i++ { + store.Record("/api/calls", "GET", "k0", 200) + } + got := store.Aggregate24h() + if len(got) != 1 { + t.Fatalf("aggregate len = %d, want 1", len(got)) + } + if got[0].Count != cap { + t.Errorf("aggregate count = %d, want %d (ring capacity)", got[0].Count, cap) + } +} + +// TestDeprecatedIdentResolution covers the priority chain: +// apiKeyIdent → apiKeyID → userID → "". +func TestDeprecatedIdentResolution(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + setup func(c *gin.Context) + wantIdent string + wantPrefix string + }{ + { + name: "no auth → empty", + setup: func(*gin.Context) {}, + wantIdent: "", + }, + { + name: "apiKeyIdent truncates to 6", + setup: func(c *gin.Context) { + c.Set("apiKeyIdent", "abcdef-very-long-name") + c.Set("apiKeyID", int64(7)) + }, + wantIdent: "abcdef", + }, + { + name: "apiKeyID fallback when no ident", + setup: func(c *gin.Context) { + c.Set("apiKeyID", int64(42)) + }, + wantIdent: "42", + }, + { + name: "userID fallback formatted u:N", + setup: func(c *gin.Context) { + c.Set("userID", int64(99)) + }, + wantPrefix: "u:99", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := middleware.NewLegacyUsageStore(nil) + middleware.DefaultLegacyUsageStore = store + + r := gin.New() + r.GET("/api/calls", func(c *gin.Context) { tt.setup(c) }, + middleware.Deprecated("/api/v1/calls", time.Now().Add(time.Hour)), + func(c *gin.Context) { c.Status(http.StatusOK) }, + ) + req := httptest.NewRequest(http.MethodGet, "/api/calls", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + got := store.Aggregate24h() + if len(got) != 1 { + t.Fatalf("aggregate len = %d, want 1", len(got)) + } + if tt.wantPrefix != "" { + if !strings.HasPrefix(got[0].APIKeyIdent, tt.wantPrefix) { + t.Errorf("ident = %q, want prefix %q", got[0].APIKeyIdent, tt.wantPrefix) + } + } else if got[0].APIKeyIdent != tt.wantIdent { + t.Errorf("ident = %q, want %q", got[0].APIKeyIdent, tt.wantIdent) + } + }) + } +} diff --git a/frontend/src/app/api.ts b/frontend/src/app/api.ts index 9680916..bf08318 100644 --- a/frontend/src/app/api.ts +++ b/frontend/src/app/api.ts @@ -5,7 +5,11 @@ import { type FetchArgs, type FetchBaseQueryError, } from "@reduxjs/toolkit/query/react"; -import type { SetupStatus, RefreshResponse } from "@/types"; +import type { + SetupStatus, + RefreshResponse, + LegacyUsageResponse, +} from "@/types"; const rawBaseQuery = fetchBaseQuery({ baseUrl: "/api", @@ -86,6 +90,7 @@ export const api = createApi({ "Bookmarks", "Setup", "SharedLinks", + "LegacyUsage", ], baseQuery: baseQueryWithRefresh, endpoints: (builder) => ({ @@ -143,6 +148,10 @@ export const api = createApi({ query: () => "/bookmarks/calls", providesTags: ["Bookmarks"], }), + getLegacyUsage: builder.query({ + query: () => "/v1/admin/legacy-usage", + providesTags: ["LegacyUsage"], + }), }), }); @@ -152,4 +161,5 @@ export const { useGetBookmarkIDsQuery, useToggleBookmarkMutation, useGetBookmarkCallsQuery, + useGetLegacyUsageQuery, } = api; diff --git a/frontend/src/components/admin/LegacyUsageBanner.test.tsx b/frontend/src/components/admin/LegacyUsageBanner.test.tsx new file mode 100644 index 0000000..b2e03ef --- /dev/null +++ b/frontend/src/components/admin/LegacyUsageBanner.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { configureStore } from "@reduxjs/toolkit"; +import { Provider } from "react-redux"; +import LegacyUsageBanner from "@/components/admin/LegacyUsageBanner"; +import { api } from "@/app/api"; +import { scannerSlice } from "@/app/slices/scanner/scannerSlice"; +import { authSlice } from "@/app/slices/shared/authSlice"; +import { callsSlice } from "@/app/slices/scanner/callsSlice"; +import type { LegacyUsageResponse } from "@/types"; + +// ── Mocks ──────────────────────────────────────────────────────────────── + +type QueryResult = { + data?: LegacyUsageResponse; + isLoading: boolean; + isError: boolean; +}; + +let mockResult: QueryResult = { + data: undefined, + isLoading: false, + isError: false, +}; + +vi.mock("@/app/api", async () => { + const actual = await vi.importActual("@/app/api"); + return { + ...actual, + useGetLegacyUsageQuery: () => mockResult, + }; +}); + +// ── Harness ────────────────────────────────────────────────────────────── + +function makeStore() { + return configureStore({ + reducer: { + scanner: scannerSlice.reducer, + auth: authSlice.reducer, + calls: callsSlice.reducer, + [api.reducerPath]: api.reducer, + }, + middleware: (gDM) => gDM().concat(api.middleware), + }); +} + +function renderBanner() { + return render( + + + , + ); +} + +const sampleResponse: LegacyUsageResponse = { + windowSeconds: 86400, + generatedAt: "2026-04-26T23:59:00Z", + entries: [ + { + path: "/api/call-upload", + method: "POST", + apiKeyIdent: "abc123", + count: 47, + lastSeen: new Date(Date.now() - 3 * 60_000).toISOString(), + }, + { + path: "/api/call", + method: "GET", + apiKeyIdent: "", + count: 5, + lastSeen: new Date(Date.now() - 30_000).toISOString(), + }, + ], +}; + +describe("LegacyUsageBanner", () => { + beforeEach(() => { + sessionStorage.clear(); + mockResult = { data: undefined, isLoading: false, isError: false }; + }); + + it("renders nothing when entries are empty", () => { + mockResult = { + data: { + windowSeconds: 86400, + generatedAt: "2026-04-26T23:59:00Z", + entries: [], + }, + isLoading: false, + isError: false, + }; + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing while the query is loading", () => { + mockResult = { data: undefined, isLoading: true, isError: false }; + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when the query errors (e.g. 401/403)", () => { + mockResult = { data: undefined, isLoading: false, isError: true }; + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders summary and entries when data is present", async () => { + mockResult = { + data: sampleResponse, + isLoading: false, + isError: false, + }; + const user = userEvent.setup(); + renderBanner(); + + expect(screen.getByText(/Legacy API in use/i)).toBeInTheDocument(); + // 52 total requests across 2 distinct keys ("abc123" + unauthenticated). + expect( + screen.getByText(/52 requests across 2 API keys/i), + ).toBeInTheDocument(); + + // Expand details. + await user.click(screen.getByText(/Show details/i)); + + expect(screen.getByText("/api/call-upload")).toBeInTheDocument(); + expect(screen.getByText("abc123")).toBeInTheDocument(); + expect(screen.getByText("(unauthenticated)")).toBeInTheDocument(); + expect(screen.getByText("47")).toBeInTheDocument(); + }); + + it("hides the banner and persists dismissal in sessionStorage on dismiss", async () => { + mockResult = { + data: sampleResponse, + isLoading: false, + isError: false, + }; + const user = userEvent.setup(); + renderBanner(); + + await user.click( + screen.getByRole("button", { name: /dismiss legacy api warning/i }), + ); + + expect(screen.queryByText(/Legacy API in use/i)).not.toBeInTheDocument(); + expect(sessionStorage.getItem("os.legacyUsageBanner.dismissed")).toBe("1"); + }); + + it("stays hidden if sessionStorage already records dismissal", () => { + sessionStorage.setItem("os.legacyUsageBanner.dismissed", "1"); + mockResult = { + data: sampleResponse, + isLoading: false, + isError: false, + }; + const { container } = renderBanner(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/frontend/src/components/admin/LegacyUsageBanner.tsx b/frontend/src/components/admin/LegacyUsageBanner.tsx new file mode 100644 index 0000000..1b5fd01 --- /dev/null +++ b/frontend/src/components/admin/LegacyUsageBanner.tsx @@ -0,0 +1,118 @@ +import { useState } from "react"; +import { AlertTriangle, X } from "lucide-react"; +import { useGetLegacyUsageQuery } from "@/app/api"; +import type { LegacyUsageEntry } from "@/types"; + +const DISMISS_KEY = "os.legacyUsageBanner.dismissed"; +const POLL_INTERVAL_MS = 60_000; + +function formatRelative(iso: string, now: number = Date.now()): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + const deltaSec = Math.max(0, Math.round((now - t) / 1000)); + if (deltaSec < 60) return `${deltaSec}s ago`; + const deltaMin = Math.round(deltaSec / 60); + if (deltaMin < 60) + return `${deltaMin} minute${deltaMin === 1 ? "" : "s"} ago`; + const deltaHr = Math.round(deltaMin / 60); + if (deltaHr < 24) return `${deltaHr} hour${deltaHr === 1 ? "" : "s"} ago`; + const deltaDay = Math.round(deltaHr / 24); + return `${deltaDay} day${deltaDay === 1 ? "" : "s"} ago`; +} + +function readDismissed(): boolean { + try { + return sessionStorage.getItem(DISMISS_KEY) === "1"; + } catch { + return false; + } +} + +function writeDismissed(): void { + try { + sessionStorage.setItem(DISMISS_KEY, "1"); + } catch { + // sessionStorage may be disabled — best-effort. + } +} + +export default function LegacyUsageBanner() { + const [dismissed, setDismissed] = useState(() => readDismissed()); + + const { data, isLoading, isError } = useGetLegacyUsageQuery(undefined, { + pollingInterval: POLL_INTERVAL_MS, + refetchOnFocus: true, + refetchOnMountOrArgChange: true, + }); + + if (dismissed) return null; + if (isLoading || isError) return null; + + const entries: LegacyUsageEntry[] = data?.entries ?? []; + if (entries.length === 0) return null; + + const totalRequests = entries.reduce((sum, e) => sum + e.count, 0); + const distinctKeys = new Set( + entries.map((e) => e.apiKeyIdent || "(unauthenticated)"), + ).size; + + const handleDismiss = () => { + writeDismissed(); + setDismissed(true); + }; + + return ( +
+ +
+

Legacy API in use

+

+ {totalRequests} request{totalRequests === 1 ? "" : "s"} across{" "} + {distinctKeys} API key{distinctKeys === 1 ? "" : "s"} in the last 24h + to deprecated /api/* endpoints. + Migrate to /api/v1/*. +

+
+ + Show details ({entries.length} endpoint + {entries.length === 1 ? "" : "s"}) + +
+ + + + + + + + + + + + {entries.map((e, i) => ( + + + + + + + + ))} + +
MethodPathAPI keyCountLast seen
{e.method}{e.path} + {e.apiKeyIdent || "(unauthenticated)"} + {e.count}{formatRelative(e.lastSeen)}
+
+
+
+ +
+ ); +} diff --git a/frontend/src/pages/Admin.test.tsx b/frontend/src/pages/Admin.test.tsx index 77c5c86..3ea08e6 100644 --- a/frontend/src/pages/Admin.test.tsx +++ b/frontend/src/pages/Admin.test.tsx @@ -27,6 +27,12 @@ vi.mock("react-router-dom", async () => { }; }); +// LegacyUsageBanner mounts on Admin; stub the data hook so it stays empty +// and we don't trigger a real fetch in jsdom. +vi.mock("@/components/admin/LegacyUsageBanner", () => ({ + default: () => null, +})); + // --- Helpers --- function makeStore(preloadedState?: Partial) { diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index a4b9f95..48083e8 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -50,6 +50,7 @@ import WebhooksPanel from "@/components/admin/WebhooksPanel"; import ActivityPanel from "@/components/admin/ActivityPanel"; import SharedLinksPanel from "@/components/admin/SharedLinksPanel"; import TranscriptionPanel from "@/components/admin/TranscriptionPanel"; +import LegacyUsageBanner from "@/components/admin/LegacyUsageBanner"; const navItems = [ { to: "/admin/activity", label: "Activity", icon: Activity }, @@ -200,6 +201,7 @@ export default function Admin() {
+ } /> } /> diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index fe375c6..59d9b24 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -5,3 +5,18 @@ export interface SetupStatus { needsSetup: boolean; publicAccess: boolean; } + +// Legacy /api/* usage report from GET /api/v1/admin/legacy-usage +export interface LegacyUsageEntry { + path: string; + method: string; + apiKeyIdent: string; + count: number; + lastSeen: string; +} + +export interface LegacyUsageResponse { + windowSeconds: number; + generatedAt: string; + entries: LegacyUsageEntry[]; +}