From 324a4916717e956abd5c4b5a91c5be412cc4449b Mon Sep 17 00:00:00 2001 From: bestony Date: Wed, 15 Jul 2026 00:23:12 +0800 Subject: [PATCH] feat: extend Server-Timing to authenticated user web APIs Mirror the Admin UI Server-Timing opt-in for user-facing pages so authenticated callers can inspect total/app/db/redis/deps metrics on session, profile, keys, usage, payment, and related user APIs. - Collect when X-User-UI-Request=1 or path is on the user allowlist - Emit for non-admin only on allowlisted paths (header is not auth) - Exclude payment public/webhook surfaces - Mark matching SPA requests and allow the new CORS request header --- .../internal/pkg/servertiming/collector.go | 1 + backend/internal/server/middleware/cors.go | 2 +- .../internal/server/middleware/cors_test.go | 1 + .../server/middleware/server_timing.go | 81 ++++++++++++- .../server/middleware/server_timing_test.go | 110 ++++++++++++++---- .../src/api/__tests__/adminUIRequest.spec.ts | 62 ++++++++++ frontend/src/api/__tests__/client.spec.ts | 52 +++++++++ frontend/src/api/adminUIRequest.ts | 51 ++++++++ frontend/src/api/client.ts | 17 ++- 9 files changed, 349 insertions(+), 28 deletions(-) diff --git a/backend/internal/pkg/servertiming/collector.go b/backend/internal/pkg/servertiming/collector.go index 553edede31..060d4458cb 100644 --- a/backend/internal/pkg/servertiming/collector.go +++ b/backend/internal/pkg/servertiming/collector.go @@ -13,6 +13,7 @@ import ( const ( HeaderName = "Server-Timing" AdminUIHeader = "X-Admin-UI-Request" + UserUIHeader = "X-User-UI-Request" MetricDatabase = "db" MetricRedis = "redis" dependencyPrefix = "dep_" diff --git a/backend/internal/server/middleware/cors.go b/backend/internal/server/middleware/cors.go index 0283d53115..fcf6d332bc 100644 --- a/backend/internal/server/middleware/cors.go +++ b/backend/internal/server/middleware/cors.go @@ -52,7 +52,7 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc { } allowHeaders := []string{ "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", - "accept", "origin", "Cache-Control", "X-Requested-With", "X-API-Key", "X-Admin-UI-Request", + "accept", "origin", "Cache-Control", "X-Requested-With", "X-API-Key", "X-Admin-UI-Request", "X-User-UI-Request", } // OpenAI Node SDK 会发送 x-stainless-* 请求头,需在 CORS 中显式放行。 openAIProperties := []string{ diff --git a/backend/internal/server/middleware/cors_test.go b/backend/internal/server/middleware/cors_test.go index 6a61f696df..8828389860 100644 --- a/backend/internal/server/middleware/cors_test.go +++ b/backend/internal/server/middleware/cors_test.go @@ -104,6 +104,7 @@ func TestCORS_AllowedOrigin_HasAllowHeaders(t *testing.T) { assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Headers"), "允许的 origin 应收到 Allow-Headers") assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-Admin-UI-Request") + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-User-UI-Request") assert.NotEmpty(t, w.Header().Get("Access-Control-Allow-Methods"), "允许的 origin 应收到 Allow-Methods") assert.Contains(t, w.Header().Get("Access-Control-Expose-Headers"), "Server-Timing") diff --git a/backend/internal/server/middleware/server_timing.go b/backend/internal/server/middleware/server_timing.go index 2bb21071e0..2301e23893 100644 --- a/backend/internal/server/middleware/server_timing.go +++ b/backend/internal/server/middleware/server_timing.go @@ -25,7 +25,7 @@ func (w *serverTimingResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } -// ServerTiming collects timing only for requests made by the Admin web UI. +// ServerTiming collects timing for Admin and User web UI requests when enabled. func ServerTiming(enabled bool) gin.HandlerFunc { if !enabled { return func(c *gin.Context) { @@ -33,7 +33,7 @@ func ServerTiming(enabled bool) gin.HandlerFunc { } } return func(c *gin.Context) { - if !isAdminUIRequest(c) || c.Request == nil { + if !shouldCollectServerTiming(c) || c.Request == nil { c.Next() return } @@ -85,13 +85,19 @@ func (w *serverTimingResponseWriter) finalize() { }) } -// ServerTimingHeaderValue returns a timing value only for an authenticated admin. +// ServerTimingHeaderValue returns a timing value only for authorized UI scopes. +// Admins may receive timing for any collected Admin/User UI request. Non-admin +// authenticated users may receive timing only on allowlisted user-facing paths. +// X-User-UI-Request is a scope signal and is never used as authorization. func ServerTimingHeaderValue(c *gin.Context) string { if c == nil || c.Request == nil { return "" } role, ok := GetUserRoleFromContext(c) - if !ok || role != "admin" { + if !ok || role == "" { + return "" + } + if role != "admin" && !isUserTimingPath(c.Request.URL.Path) { return "" } return servertiming.HeaderValue(c.Request.Context(), time.Now(), responseCacheStatus(c.Writer.Header())) @@ -106,6 +112,10 @@ func ServerTimingResponseHeader(c *gin.Context) http.Header { return http.Header{servertiming.HeaderName: []string{value}} } +func shouldCollectServerTiming(c *gin.Context) bool { + return isAdminUIRequest(c) || isUserUIRequest(c) +} + func isAdminUIRequest(c *gin.Context) bool { if c == nil || c.Request == nil || c.Request.URL == nil { return false @@ -117,6 +127,69 @@ func isAdminUIRequest(c *gin.Context) bool { return path == "/api/v1/admin" || strings.HasPrefix(path, "/api/v1/admin/") } +func isUserUIRequest(c *gin.Context) bool { + if c == nil || c.Request == nil || c.Request.URL == nil { + return false + } + if strings.TrimSpace(c.GetHeader(servertiming.UserUIHeader)) == "1" { + return true + } + return isUserTimingPath(c.Request.URL.Path) +} + +// isUserTimingPath reports whether the path is a user-facing web API that may +// emit Server-Timing for authenticated callers (excluding public payment routes). +func isUserTimingPath(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + const prefix = "/api/v1" + if !strings.HasPrefix(path, prefix) { + return false + } + rest := strings.TrimPrefix(path, prefix) + if rest == "" { + return false + } + if !strings.HasPrefix(rest, "/") { + rest = "/" + rest + } + + switch { + case rest == "/auth/me", + rest == "/auth/revoke-all-sessions", + rest == "/auth/oauth/bind-token": + return true + case rest == "/user", strings.HasPrefix(rest, "/user/"): + return true + case rest == "/keys", strings.HasPrefix(rest, "/keys/"): + return true + case rest == "/groups/available", rest == "/groups/rates": + return true + case rest == "/channels/available": + return true + case rest == "/usage", strings.HasPrefix(rest, "/usage/"): + return true + case rest == "/announcements", strings.HasPrefix(rest, "/announcements/"): + return true + case rest == "/redeem", strings.HasPrefix(rest, "/redeem/"): + return true + case rest == "/subscriptions", strings.HasPrefix(rest, "/subscriptions/"): + return true + case rest == "/channel-monitors", strings.HasPrefix(rest, "/channel-monitors/"): + return true + case strings.HasPrefix(rest, "/payment/"): + // Exclude public and webhook payment surfaces. + if strings.HasPrefix(rest, "/payment/public") || strings.HasPrefix(rest, "/payment/webhook") { + return false + } + return true + default: + return false + } +} + func responseCacheStatus(header http.Header) string { for _, name := range []string{snapshotCacheHeader, usageCacheHeader} { switch strings.ToLower(strings.TrimSpace(header.Get(name))) { diff --git a/backend/internal/server/middleware/server_timing_test.go b/backend/internal/server/middleware/server_timing_test.go index c064840ece..435890ccc6 100644 --- a/backend/internal/server/middleware/server_timing_test.go +++ b/backend/internal/server/middleware/server_timing_test.go @@ -15,7 +15,8 @@ func runServerTimingRequest( t *testing.T, enabled bool, path string, - marker string, + adminMarker string, + userMarker string, role string, handler gin.HandlerFunc, ) *httptest.ResponseRecorder { @@ -32,8 +33,11 @@ func runServerTimingRequest( recorder := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) - if marker != "" { - request.Header.Set(servertiming.AdminUIHeader, marker) + if adminMarker != "" { + request.Header.Set(servertiming.AdminUIHeader, adminMarker) + } + if userMarker != "" { + request.Header.Set(servertiming.UserUIHeader, userMarker) } engine.ServeHTTP(recorder, request) return recorder @@ -41,26 +45,36 @@ func runServerTimingRequest( func TestServerTimingScopesAndRoleGate(t *testing.T) { tests := []struct { - name string - enabled bool - path string - marker string - role string - wantHeader bool + name string + enabled bool + path string + adminMarker string + userMarker string + role string + wantHeader bool }{ {name: "disabled", enabled: false, path: "/api/v1/admin/users", role: "admin"}, {name: "admin API path", enabled: true, path: "/api/v1/admin/users", role: "admin", wantHeader: true}, - {name: "shared API marked by admin UI", enabled: true, path: "/api/v1/groups/available", marker: "1", role: "admin", wantHeader: true}, - {name: "non admin role", enabled: true, path: "/api/v1/groups/available", marker: "1", role: "user"}, - {name: "unauthenticated public request", enabled: true, path: "/api/v1/settings/public", marker: "1"}, - {name: "unmarked shared API", enabled: true, path: "/api/v1/groups/available", role: "admin"}, - {name: "invalid marker", enabled: true, path: "/api/v1/groups/available", marker: "true", role: "admin"}, + {name: "shared API marked by admin UI", enabled: true, path: "/api/v1/groups/available", adminMarker: "1", role: "admin", wantHeader: true}, + {name: "user role on allowlisted path", enabled: true, path: "/api/v1/groups/available", role: "user", wantHeader: true}, + {name: "user role with user UI marker on allowlisted path", enabled: true, path: "/api/v1/keys", userMarker: "1", role: "user", wantHeader: true}, + {name: "user role cannot use admin marker on non-user path", enabled: true, path: "/api/v1/settings/public", adminMarker: "1", role: "user"}, + {name: "user marker alone does not authorize non-user path", enabled: true, path: "/api/v1/settings/public", userMarker: "1", role: "user"}, + {name: "unauthenticated public request", enabled: true, path: "/api/v1/settings/public", adminMarker: "1"}, + {name: "unauthenticated user path", enabled: true, path: "/api/v1/keys"}, + {name: "unmarked shared API still scopes by path for admin", enabled: true, path: "/api/v1/groups/available", role: "admin", wantHeader: true}, + {name: "invalid admin marker on non-scoped path", enabled: true, path: "/api/v1/settings/public", adminMarker: "true", role: "admin"}, {name: "admin prefix boundary", enabled: true, path: "/api/v1/administrator", role: "admin"}, + {name: "auth me path", enabled: true, path: "/api/v1/auth/me", role: "user", wantHeader: true}, + {name: "payment user path", enabled: true, path: "/api/v1/payment/plans", role: "user", wantHeader: true}, + {name: "payment public excluded", enabled: true, path: "/api/v1/payment/public/orders/verify", userMarker: "1", role: "user"}, + {name: "payment webhook excluded", enabled: true, path: "/api/v1/payment/webhook/stripe", userMarker: "1", role: "user"}, + {name: "channel monitors path", enabled: true, path: "/api/v1/channel-monitors/1/status", role: "user", wantHeader: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - recorder := runServerTimingRequest(t, tt.enabled, tt.path, tt.marker, tt.role, func(c *gin.Context) { + recorder := runServerTimingRequest(t, tt.enabled, tt.path, tt.adminMarker, tt.userMarker, tt.role, func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) header := recorder.Header().Get(servertiming.HeaderName) @@ -77,9 +91,49 @@ func TestServerTimingScopesAndRoleGate(t *testing.T) { } } +func TestIsUserTimingPath(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"/api/v1/auth/me", true}, + {"/api/v1/auth/revoke-all-sessions", true}, + {"/api/v1/auth/oauth/bind-token", true}, + {"/api/v1/auth/login", false}, + {"/api/v1/user", true}, + {"/api/v1/user/profile", true}, + {"/api/v1/user/totp/status", true}, + {"/api/v1/keys", true}, + {"/api/v1/keys/12", true}, + {"/api/v1/groups/available", true}, + {"/api/v1/groups/rates", true}, + {"/api/v1/groups", false}, + {"/api/v1/channels/available", true}, + {"/api/v1/channels", false}, + {"/api/v1/usage/stats", true}, + {"/api/v1/announcements", true}, + {"/api/v1/redeem/history", true}, + {"/api/v1/subscriptions/active", true}, + {"/api/v1/channel-monitors", true}, + {"/api/v1/payment/config", true}, + {"/api/v1/payment/orders/my", true}, + {"/api/v1/payment/public/orders/verify", false}, + {"/api/v1/payment/webhook/easypay", false}, + {"/api/v1/admin/users", false}, + {"/api/v1/settings/public", false}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + if got := isUserTimingPath(tt.path); got != tt.want { + t.Fatalf("isUserTimingPath(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + func TestServerTimingCollectorIsRequestScoped(t *testing.T) { active := false - recorder := runServerTimingRequest(t, true, "/api/v1/keys", "1", "admin", func(c *gin.Context) { + recorder := runServerTimingRequest(t, true, "/api/v1/keys", "1", "", "admin", func(c *gin.Context) { active = servertiming.Active(c.Request.Context()) c.Status(http.StatusNoContent) }) @@ -91,8 +145,24 @@ func TestServerTimingCollectorIsRequestScoped(t *testing.T) { } } +func TestServerTimingCollectorForUserUIMarker(t *testing.T) { + active := false + // Use a non-allowlisted path so collection depends on the user UI marker. + recorder := runServerTimingRequest(t, true, "/api/v1/settings/public", "", "1", "admin", func(c *gin.Context) { + active = servertiming.Active(c.Request.Context()) + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + if !active { + t.Fatal("collector was not attached for user UI marker") + } + // Admin role may emit even when the path is not user-allowlisted. + if recorder.Header().Get(servertiming.HeaderName) == "" { + t.Fatal("admin timing header missing for user-UI-marked request") + } +} + func TestServerTimingFinalizesBeforeEarlyCommit(t *testing.T) { - recorder := runServerTimingRequest(t, true, "/api/v1/admin/stream", "", "admin", func(c *gin.Context) { + recorder := runServerTimingRequest(t, true, "/api/v1/admin/stream", "", "", "admin", func(c *gin.Context) { c.Status(http.StatusAccepted) c.Writer.WriteHeaderNow() }) @@ -102,7 +172,7 @@ func TestServerTimingFinalizesBeforeEarlyCommit(t *testing.T) { } func TestServerTimingFinalizesOnFlush(t *testing.T) { - recorder := runServerTimingRequest(t, true, "/api/v1/admin/export", "", "admin", func(c *gin.Context) { + recorder := runServerTimingRequest(t, true, "/api/v1/admin/export", "", "", "admin", func(c *gin.Context) { c.Writer.Flush() }) if got := recorder.Header().Get(servertiming.HeaderName); got == "" { @@ -120,7 +190,7 @@ func TestServerTimingStatusResponses(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - recorder := runServerTimingRequest(t, true, "/api/v1/admin/test", "", "admin", func(c *gin.Context) { + recorder := runServerTimingRequest(t, true, "/api/v1/admin/test", "", "", "admin", func(c *gin.Context) { c.Status(tt.status) }) if recorder.Code != tt.status { @@ -156,7 +226,7 @@ func TestServerTimingCacheOutcome(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - recorder := runServerTimingRequest(t, true, "/api/v1/admin/dashboard", "", "admin", func(c *gin.Context) { + recorder := runServerTimingRequest(t, true, "/api/v1/admin/dashboard", "", "", "admin", func(c *gin.Context) { c.Header(tt.headerName, tt.value) c.JSON(http.StatusOK, gin.H{"ok": true}) }) diff --git a/frontend/src/api/__tests__/adminUIRequest.spec.ts b/frontend/src/api/__tests__/adminUIRequest.spec.ts index 9064a52f1a..fba7b3b180 100644 --- a/frontend/src/api/__tests__/adminUIRequest.spec.ts +++ b/frontend/src/api/__tests__/adminUIRequest.spec.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest' import { ADMIN_UI_REQUEST_HEADER, + USER_UI_REQUEST_HEADER, + isUserTimingAPIPath, shouldMarkAdminUIRequest, + shouldMarkUserUIRequest, } from '@/api/adminUIRequest' describe('Admin UI request marker', () => { @@ -36,3 +39,62 @@ describe('Admin UI request marker', () => { expect(shouldMarkAdminUIRequest(requestURL, pagePath)).toBe(false) }) }) + +describe('User UI request marker', () => { + it('uses the stable request header name', () => { + expect(USER_UI_REQUEST_HEADER).toBe('X-User-UI-Request') + }) + + it.each([ + '/auth/me', + '/auth/revoke-all-sessions', + '/auth/oauth/bind-token', + '/user', + '/user/profile', + '/user/password', + '/user/notify-email/send-code', + '/user/totp/status', + '/user/aff', + '/user/platform-quotas', + '/keys', + '/keys/12', + '/groups/available', + '/groups/rates', + '/channels/available', + '/usage', + '/usage/stats', + '/usage/dashboard/snapshot-v2', + '/announcements', + '/announcements/3/read', + '/redeem', + '/redeem/history', + '/subscriptions', + '/subscriptions/active', + '/channel-monitors', + '/channel-monitors/9/status', + '/payment/config', + '/payment/plans', + '/payment/orders', + '/payment/orders/my', + '/api/v1/auth/me', + '/api/v1/keys?page=1', + 'https://api.example.test/api/v1/payment/orders/1', + ])('marks user timing API %s', (requestURL) => { + expect(shouldMarkUserUIRequest(requestURL)).toBe(true) + expect(isUserTimingAPIPath(requestURL)).toBe(true) + }) + + it.each([ + '/auth/login', + '/settings/public', + '/admin/users', + '/groups', + '/channels', + '/payment/public/orders/verify', + '/payment/webhook/stripe', + '/api/v1/payment/public/orders/resolve', + '', + ])('does not mark non-user timing API %s', (requestURL) => { + expect(shouldMarkUserUIRequest(requestURL)).toBe(false) + }) +}) diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index b275cca34b..d1b619027e 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -170,6 +170,58 @@ describe('API Client', () => { const config = adapter.mock.calls[0][0] expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy() }) + + it('用户侧 timing API 自动带 User UI 标记', async () => { + const adapter = vi.fn().mockResolvedValue({ + status: 200, + data: { code: 0, data: {} }, + headers: {}, + config: {}, + statusText: 'OK', + }) + apiClient.defaults.adapter = adapter + + await apiClient.get('/auth/me') + + const config = adapter.mock.calls[0][0] + expect(config.headers.get('X-User-UI-Request')).toBe('1') + expect(config.headers.get('X-Admin-UI-Request')).toBeFalsy() + }) + + it('支付用户 API 带 User UI 标记,公开支付 API 不带', async () => { + const adapter = vi.fn().mockResolvedValue({ + status: 200, + data: { code: 0, data: {} }, + headers: {}, + config: {}, + statusText: 'OK', + }) + apiClient.defaults.adapter = adapter + + await apiClient.get('/payment/plans') + expect(adapter.mock.calls[0][0].headers.get('X-User-UI-Request')).toBe('1') + + await apiClient.post('/payment/public/orders/verify', {}) + expect(adapter.mock.calls[1][0].headers.get('X-User-UI-Request')).toBeFalsy() + }) + + it('管理页调用共享 API 时同时带 Admin 与 User UI 标记', async () => { + window.history.replaceState({}, '', '/admin/dashboard') + const adapter = vi.fn().mockResolvedValue({ + status: 200, + data: { code: 0, data: {} }, + headers: {}, + config: {}, + statusText: 'OK', + }) + apiClient.defaults.adapter = adapter + + await apiClient.get('/keys') + + const config = adapter.mock.calls[0][0] + expect(config.headers.get('X-Admin-UI-Request')).toBe('1') + expect(config.headers.get('X-User-UI-Request')).toBe('1') + }) }) // --- 响应拦截器 --- diff --git a/frontend/src/api/adminUIRequest.ts b/frontend/src/api/adminUIRequest.ts index 2d60e2987d..0964c75747 100644 --- a/frontend/src/api/adminUIRequest.ts +++ b/frontend/src/api/adminUIRequest.ts @@ -1,4 +1,5 @@ export const ADMIN_UI_REQUEST_HEADER = 'X-Admin-UI-Request' +export const USER_UI_REQUEST_HEADER = 'X-User-UI-Request' function isAdminPath(path: string): boolean { return ( @@ -20,8 +21,58 @@ function requestPath(rawURL: string): string { } } +/** Normalize Axios relative paths and absolute API paths to a comparable form. */ +function normalizeAPIPath(path: string): string { + const raw = requestPath(path) + if (!raw) return '' + if (raw === '/api/v1' || raw.startsWith('/api/v1/')) { + return raw.slice('/api/v1'.length) || '/' + } + if (raw.startsWith('/')) { + return raw + } + return `/${raw}` +} + +/** + * User-facing web APIs that may emit Server-Timing when ENABLE_SERVER_TIMING is on. + * Mirrors backend isUserTimingPath allowlist (excluding public payment surfaces). + */ +export function isUserTimingAPIPath(requestURL: string): boolean { + const path = normalizeAPIPath(requestURL) + if (!path) return false + + if ( + path === '/auth/me' || + path === '/auth/revoke-all-sessions' || + path === '/auth/oauth/bind-token' + ) { + return true + } + if (path === '/user' || path.startsWith('/user/')) return true + if (path === '/keys' || path.startsWith('/keys/')) return true + if (path === '/groups/available' || path === '/groups/rates') return true + if (path === '/channels/available') return true + if (path === '/usage' || path.startsWith('/usage/')) return true + if (path === '/announcements' || path.startsWith('/announcements/')) return true + if (path === '/redeem' || path.startsWith('/redeem/')) return true + if (path === '/subscriptions' || path.startsWith('/subscriptions/')) return true + if (path === '/channel-monitors' || path.startsWith('/channel-monitors/')) return true + if (path.startsWith('/payment/')) { + if (path.startsWith('/payment/public') || path.startsWith('/payment/webhook')) { + return false + } + return true + } + return false +} + export function shouldMarkAdminUIRequest(requestURL: string, pagePath?: string): boolean { const currentPath = pagePath ?? (typeof window !== 'undefined' ? window.location.pathname : '') return isAdminPath(requestPath(requestURL)) || isAdminPath(currentPath) } + +export function shouldMarkUserUIRequest(requestURL: string): boolean { + return isUserTimingAPIPath(requestURL) +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a2b4d2f650..8375cf8763 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6,7 +6,12 @@ import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig, AxiosResponse } from 'axios' import type { ApiResponse } from '@/types' import { getLocale } from '@/i18n' -import { ADMIN_UI_REQUEST_HEADER, shouldMarkAdminUIRequest } from './adminUIRequest' +import { + ADMIN_UI_REQUEST_HEADER, + USER_UI_REQUEST_HEADER, + shouldMarkAdminUIRequest, + shouldMarkUserUIRequest, +} from './adminUIRequest' import { getAPIBaseURL } from './url' export { buildApiUrl, buildGatewayUrl } from './url' @@ -75,8 +80,14 @@ apiClient.interceptors.request.use( config.params.timezone = getUserTimezone() } - if (config.headers && shouldMarkAdminUIRequest(String(config.url || ''))) { - config.headers[ADMIN_UI_REQUEST_HEADER] = '1' + if (config.headers) { + const requestURL = String(config.url || '') + if (shouldMarkAdminUIRequest(requestURL)) { + config.headers[ADMIN_UI_REQUEST_HEADER] = '1' + } + if (shouldMarkUserUIRequest(requestURL)) { + config.headers[USER_UI_REQUEST_HEADER] = '1' + } } return config