diff --git a/backend/src/backtest/index.ts b/backend/src/backtest/index.ts index dda746f..1baba86 100644 --- a/backend/src/backtest/index.ts +++ b/backend/src/backtest/index.ts @@ -15,13 +15,23 @@ export interface RunBacktestOptions { * See docs/backtest/ENGINE_READINESS.md §3.3. */ logLevel?: string; + /** + * Skip the global ENABLE_BACKTEST guard. Set to true ONLY when the + * caller has already performed a stricter per-user feature-flag check + * (e.g. the /api/backtest/run route handler that consults per-user + * overrides). This intentionally lets a per-user flag take precedence + * over the global default. + */ + skipGlobalFeatureFlagCheck?: boolean; } export const runBacktest = async ( request: BacktestRequest, options: RunBacktestOptions = {} ): Promise => { - assertBacktestFeatureEnabled(); + if (!options.skipGlobalFeatureFlagCheck) { + assertBacktestFeatureEnabled(); + } assertBacktestMode(request.mode); assertBacktestStrategyConfigSafe(request.strategyConfig); const historical = await loadHistoricalData(request); diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index ec15b29..440867f 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -32,6 +32,8 @@ import { listTradeProfilesForUser, saveCurrentUserProfile, saveTradeProfileForUser, + setUserFeatureFlags, + type TradingUserFeatureFlags, } from './profileRepository.js'; import { deleteManualEntryForUser, @@ -2350,11 +2352,28 @@ export class ApiServer { }); }); - this.app.get('/api/feature-flags', this.requireAuth, (_req, res) => { + this.app.get('/api/feature-flags', this.requireAuth, async (req, res) => { + const authReq = req as AuthenticatedRequest; + // Per-user override merge: if the caller has featureFlags set on + // their TradingUserProfile, those win over global config defaults. + // `undefined` per-user values fall through to the global default. + let userFlags: TradingUserFeatureFlags | undefined; + if (authReq.authUserId) { + try { + const profile = await getCurrentUserProfile(authReq.authUserId); + userFlags = profile.featureFlags; + } catch (error) { + logger.warn(`[feature-flags] failed to load per-user flags for ${authReq.authUserId}: ${error instanceof Error ? error.message : 'unknown'}`); + } + } const flags: TradingFeatureFlagsResponse = { backtest: { - enableBacktest: Boolean(config.ENABLE_BACKTEST), - customerEnabled: Boolean(config.BACKTEST_CUSTOMER_ENABLED), + enableBacktest: typeof userFlags?.backtestEnabled === 'boolean' + ? userFlags.backtestEnabled + : Boolean(config.ENABLE_BACKTEST), + customerEnabled: typeof userFlags?.backtestCustomerEnabled === 'boolean' + ? userFlags.backtestCustomerEnabled + : Boolean(config.BACKTEST_CUSTOMER_ENABLED), maxCsvBytes: Number(config.BACKTEST_MAX_CSV_BYTES), maxRows: Number(config.BACKTEST_MAX_ROWS), }, @@ -2366,6 +2385,74 @@ export class ApiServer { res.json(flags); }); + // Admin: read a specific user's feature-flag overrides + the merged + // effective values they would see. Useful for the admin UI. + this.app.get( + '/api/admin/users/:userId/feature-flags', + this.requireAuth, + this.requireAdmin, + async (req, res) => { + try { + const userId = String(req.params.userId || '').trim(); + if (!userId) { + res.status(400).json({ error: 'userId is required' }); + return; + } + const profile = await getCurrentUserProfile(userId); + const overrides = profile.featureFlags || {}; + const effective = { + backtestEnabled: typeof overrides.backtestEnabled === 'boolean' + ? overrides.backtestEnabled + : Boolean(config.ENABLE_BACKTEST), + backtestCustomerEnabled: typeof overrides.backtestCustomerEnabled === 'boolean' + ? overrides.backtestCustomerEnabled + : Boolean(config.BACKTEST_CUSTOMER_ENABLED), + }; + res.json({ userId, overrides, effective }); + } catch (error: any) { + res.status(500).json({ error: `Failed to read user feature flags: ${error.message}` }); + } + } + ); + + // Admin: set per-user feature-flag overrides. Pass `null` for a key to + // clear the override (revert to global default), `true`/`false` to + // pin, omit a key to leave it unchanged. + this.app.patch( + '/api/admin/users/:userId/feature-flags', + this.requireAuth, + this.requireAdmin, + async (req, res) => { + try { + const userId = String(req.params.userId || '').trim(); + if (!userId) { + res.status(400).json({ error: 'userId is required' }); + return; + } + const body = (req.body || {}) as { overrides?: Record }; + const inOverrides = body.overrides || {}; + const flagsUpdate: { [K in keyof TradingUserFeatureFlags]?: TradingUserFeatureFlags[K] | null } = {}; + for (const key of ['backtestEnabled', 'backtestCustomerEnabled'] as const) { + if (!(key in inOverrides)) continue; + const value = inOverrides[key]; + if (value === null) flagsUpdate[key] = null; + else if (typeof value === 'boolean') flagsUpdate[key] = value; + else { + res.status(400).json({ error: `${key} must be boolean or null` }); + return; + } + } + const updated = await setUserFeatureFlags(userId, flagsUpdate); + res.json({ + userId, + overrides: updated.featureFlags || {}, + }); + } catch (error: any) { + res.status(500).json({ error: `Failed to update user feature flags: ${error.message}` }); + } + } + ); + // ── DevOps version: PUBLIC, no auth (build SHA + branch + image) ───── // Useful for ops/CI health checks and rollback verification. this.app.get('/api/devops/version', (_req, res) => { @@ -2790,13 +2877,31 @@ export class ApiServer { if (!this.enforceRateLimit(req as AuthenticatedRequest, res, 'backtest')) { return; } - if (!config.ENABLE_BACKTEST) { + + // Resolve effective flags: per-user override > global config default. + // A per-user `backtestEnabled: true` lets a specific user run + // backtests even when the global flag is off (admin-driven beta). + let userFlagsForGate: TradingUserFeatureFlags | undefined; + try { + const profile = await getCurrentUserProfile(authUserId); + userFlagsForGate = profile.featureFlags; + } catch (error) { + logger.warn(`[backtest/run] failed to load per-user flags: ${error instanceof Error ? error.message : 'unknown'}`); + } + const effectiveEnableBacktest = typeof userFlagsForGate?.backtestEnabled === 'boolean' + ? userFlagsForGate.backtestEnabled + : Boolean(config.ENABLE_BACKTEST); + const effectiveCustomerEnabled = typeof userFlagsForGate?.backtestCustomerEnabled === 'boolean' + ? userFlagsForGate.backtestCustomerEnabled + : Boolean(config.BACKTEST_CUSTOMER_ENABLED); + + if (!effectiveEnableBacktest) { res.status(404).json({ success: false, error: 'Backtest feature is disabled' }); return; } const isAdmin = await isTradingAdmin(authUserId, authReq.authRole); - if (!isAdmin && !config.BACKTEST_CUSTOMER_ENABLED) { + if (!isAdmin && !effectiveCustomerEnabled) { res.status(403).json({ success: false, error: 'Backtest is restricted to admin users. Ask an admin to enable customer backtest access.' @@ -2856,7 +2961,12 @@ export class ApiServer { }; const result = await runBacktest(backtestRequest, { - profileSettings: executionProfileSettings + profileSettings: executionProfileSettings, + // The route already performed the merged (per-user > global) + // feature-flag check above. Skip the internal global check + // so a per-user override of `backtestEnabled: true` works + // even when the global flag is off. + skipGlobalFeatureFlagCheck: true, }); this.auditTradeEvent({ diff --git a/backend/src/services/profileRepository.ts b/backend/src/services/profileRepository.ts index 22f9251..88d6cb4 100644 --- a/backend/src/services/profileRepository.ts +++ b/backend/src/services/profileRepository.ts @@ -4,6 +4,20 @@ import { config } from '../config/index.js'; import logger from '../utils/logger.js'; import { getLegacySupabaseClient } from './legacySupabaseClient.js'; +/** + * Per-user runtime feature flags. Each field overrides the corresponding + * global default in `config` when defined; `undefined` means "use the global". + * + * Stored on the trading_users Cosmos doc; admin-only writes via + * PATCH /api/admin/users/:id/feature-flags. + */ +export interface TradingUserFeatureFlags { + /** Override the global ENABLE_BACKTEST gate for this user. */ + backtestEnabled?: boolean; + /** Override the global BACKTEST_CUSTOMER_ENABLED gate for this user. */ + backtestCustomerEnabled?: boolean; +} + export interface TradingUserProfile { user_id: string; first_name: string; @@ -19,6 +33,7 @@ export interface TradingUserProfile { drop_threshold_for_buy?: number; gain_threshold_for_sell?: number; market_poll_interval_in_seconds?: number; + featureFlags?: TradingUserFeatureFlags; } export interface TradeProfileRecord { @@ -96,6 +111,17 @@ function mergeTradingUserProfiles( return null; } + // Per-user featureFlags merge: primary wins per-key over fallback. + // We only include defined values so an `undefined` flag doesn't clobber + // the global default at the API merge layer. + const mergedFlags: TradingUserFeatureFlags = {}; + const primaryFlags = primary?.featureFlags || {}; + const fallbackFlags = fallback?.featureFlags || {}; + if (typeof primaryFlags.backtestEnabled === 'boolean') mergedFlags.backtestEnabled = primaryFlags.backtestEnabled; + else if (typeof fallbackFlags.backtestEnabled === 'boolean') mergedFlags.backtestEnabled = fallbackFlags.backtestEnabled; + if (typeof primaryFlags.backtestCustomerEnabled === 'boolean') mergedFlags.backtestCustomerEnabled = primaryFlags.backtestCustomerEnabled; + else if (typeof fallbackFlags.backtestCustomerEnabled === 'boolean') mergedFlags.backtestCustomerEnabled = fallbackFlags.backtestCustomerEnabled; + return { user_id: userId, first_name: coalesceString(primary?.first_name, fallback?.first_name), @@ -111,6 +137,7 @@ function mergeTradingUserProfiles( drop_threshold_for_buy: coalesceNumber(primary?.drop_threshold_for_buy, fallback?.drop_threshold_for_buy, 0), gain_threshold_for_sell: coalesceNumber(primary?.gain_threshold_for_sell, fallback?.gain_threshold_for_sell, 0), market_poll_interval_in_seconds: coalesceNumber(primary?.market_poll_interval_in_seconds, fallback?.market_poll_interval_in_seconds, 0), + ...(Object.keys(mergedFlags).length > 0 ? { featureFlags: mergedFlags } : {}), }; } @@ -714,21 +741,31 @@ export async function saveCurrentUserProfile( fallback: Partial = {} ): Promise { const existing = await getCurrentUserProfile(userId, fallback); + // SECURITY: featureFlags + role are NEVER persisted from end-user input. + // - role is authoritative from JWT (see comment below) + // - featureFlags are admin-only via setUserFeatureFlags() / the + // /api/admin/users/:id/feature-flags endpoint + // Strip them from input here so a non-admin can't elevate their own + // feature gates by PATCHing /api/me/profile. + const { role: _ignoredRole, featureFlags: _ignoredFlags, ...safeInput } = input; + void _ignoredRole; void _ignoredFlags; const merged: TradingUserProfile = { ...existing, - ...input, + ...safeInput, user_id: userId, - email: String(input.email ?? existing.email ?? fallback.email ?? ''), + email: String(safeInput.email ?? existing.email ?? fallback.email ?? ''), // Role is intentionally NOT persisted from client input — JWT role is the // authoritative source. We keep the existing stored role for backward // compatibility but it's overridden on read at the API layer. role: String(existing.role ?? fallback.role ?? 'member'), - first_name: String(input.first_name ?? existing.first_name ?? fallback.first_name ?? ''), - last_name: String(input.last_name ?? existing.last_name ?? fallback.last_name ?? ''), - trade_enable: Boolean(input.trade_enable ?? existing.trade_enable ?? fallback.trade_enable ?? true), - drop_threshold_for_buy: Number(input.drop_threshold_for_buy ?? existing.drop_threshold_for_buy ?? fallback.drop_threshold_for_buy ?? 0), - gain_threshold_for_sell: Number(input.gain_threshold_for_sell ?? existing.gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0), - market_poll_interval_in_seconds: Number(input.market_poll_interval_in_seconds ?? existing.market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0), + first_name: String(safeInput.first_name ?? existing.first_name ?? fallback.first_name ?? ''), + last_name: String(safeInput.last_name ?? existing.last_name ?? fallback.last_name ?? ''), + trade_enable: Boolean(safeInput.trade_enable ?? existing.trade_enable ?? fallback.trade_enable ?? true), + drop_threshold_for_buy: Number(safeInput.drop_threshold_for_buy ?? existing.drop_threshold_for_buy ?? fallback.drop_threshold_for_buy ?? 0), + gain_threshold_for_sell: Number(safeInput.gain_threshold_for_sell ?? existing.gain_threshold_for_sell ?? fallback.gain_threshold_for_sell ?? 0), + market_poll_interval_in_seconds: Number(safeInput.market_poll_interval_in_seconds ?? existing.market_poll_interval_in_seconds ?? fallback.market_poll_interval_in_seconds ?? 0), + // Preserve any existing featureFlags — admin can edit via setUserFeatureFlags + ...(existing.featureFlags ? { featureFlags: existing.featureFlags } : {}), }; try { @@ -769,3 +806,37 @@ export async function saveCurrentUserProfile( return merged; } + +/** + * Admin-only: update the per-user featureFlags overrides on a user profile. + * Used by PATCH /api/admin/users/:id/feature-flags. Loads, merges, persists. + * + * `flags` keys with explicit `null` clear the override (returning to global + * default); `undefined` keys are left untouched. + */ +export async function setUserFeatureFlags( + userId: string, + flags: { [K in keyof TradingUserFeatureFlags]?: TradingUserFeatureFlags[K] | null } +): Promise { + const existing = await getCurrentUserProfile(userId); + const next: TradingUserFeatureFlags = { ...(existing.featureFlags || {}) }; + for (const key of Object.keys(flags) as (keyof TradingUserFeatureFlags)[]) { + const value = flags[key]; + if (value === null) { + delete next[key]; + } else if (typeof value === 'boolean') { + next[key] = value; + } + } + const updated: TradingUserProfile = { + ...existing, + ...(Object.keys(next).length > 0 ? { featureFlags: next } : { featureFlags: undefined }), + }; + try { + await upsertTradingUserProfileToCosmos(updated); + } catch (error) { + logger.warn(`[Profiles] Cosmos featureFlags save failed for ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`); + throw error; + } + return updated; +} diff --git a/backend/testBacktestEngine.ts b/backend/testBacktestEngine.ts index a70ec1d..ea1d418 100644 --- a/backend/testBacktestEngine.ts +++ b/backend/testBacktestEngine.ts @@ -313,6 +313,47 @@ try { pass('empty candle dataset throws explicit error'); } catch (e) { fail('empty data error', e); } +// --------------------------------------------------------------------------- +// Test 8a — skipGlobalFeatureFlagCheck honors per-user override +// +// When the route handler has already done a per-user check (Stage E2 in +// docs/backtest/ENGINE_READINESS.md), runBacktest should bypass the global +// ENABLE_BACKTEST guard so a per-user override of `backtestEnabled: true` +// can let a specific user run backtests even when global is off. + +try { + const previousGlobalFlag = config.ENABLE_BACKTEST; + config.ENABLE_BACKTEST = false; + const start = Date.parse('2024-01-01T00:00:00Z'); + const candles = buildSyntheticCandles(start, 200, () => ({ open: 50000, high: 50100, low: 49900, close: 50050 })); + const request = buildBacktestRequest(candles); + + // Without the override → assertion should fire + let blockedAsExpected = false; + try { + await runBacktest(request); + } catch (e) { + blockedAsExpected = (e as Error).message.includes('disabled'); + } + assert.ok(blockedAsExpected, 'global gate blocks when ENABLE_BACKTEST=false'); + + // With override → bypasses the gate. Should at minimum not throw the + // "feature is disabled" error (may still return 0 trades for synthetic data). + let bypassWorked = false; + try { + const r = await runBacktest(request, { skipGlobalFeatureFlagCheck: true }); + bypassWorked = typeof r.summary?.netPnlUsd === 'number'; + } catch (e) { + // Any error other than the feature-disabled one is acceptable here; + // the point is the global guard didn't fire. + bypassWorked = !(e as Error).message.includes('disabled'); + } + assert.ok(bypassWorked, 'skipGlobalFeatureFlagCheck bypasses ENABLE_BACKTEST guard'); + + config.ENABLE_BACKTEST = previousGlobalFlag; + pass('skipGlobalFeatureFlagCheck enables per-user override semantics'); +} catch (e) { fail('skipGlobalFeatureFlagCheck', e); } + // --------------------------------------------------------------------------- // Test 8 — Alpaca data source plumbing (without hitting the network) // diff --git a/web/src/backtest/flags.ts b/web/src/backtest/flags.ts index b6f7421..6396fa5 100644 --- a/web/src/backtest/flags.ts +++ b/web/src/backtest/flags.ts @@ -26,14 +26,20 @@ const toBoolean = (value: unknown, fallback: boolean = false): boolean => { return fallback; }; -export const isBacktestBuildEnabled = (): boolean => { - const raw = import.meta.env.VITE_BACKTEST_ENABLED; - if (raw === undefined || String(raw).trim() === '') { - // Default to disabled — backtest is not yet production-ready. - return false; - } - return toBoolean(raw, false); -}; +/** + * Whether the backtest UI is enabled in this build. + * + * Historically this read `VITE_BACKTEST_ENABLED` to keep backtest code out + * of customer bundles. That gate was removed because: + * 1. Runtime gating via /api/feature-flags is the single source of truth + * (admin toggle in Settings + per-user override). + * 2. The build flag created a "deployed but still off" footgun. + * 3. Bundle-size impact is small and best solved with lazy-loading. + * + * Kept as a function (returning true) so existing call sites + tests don't + * need to change. Remove this entirely once all callers are updated. + */ +export const isBacktestBuildEnabled = (): boolean => true; export const clearBacktestRuntimeFlagCache = (): void => { runtimeFlagsCache = null; diff --git a/web/src/components/admin/UserFeatureFlagsPanel.tsx b/web/src/components/admin/UserFeatureFlagsPanel.tsx new file mode 100644 index 0000000..efd45a8 --- /dev/null +++ b/web/src/components/admin/UserFeatureFlagsPanel.tsx @@ -0,0 +1,216 @@ +import * as React from 'react'; +import { Button, Input } from '../ui/Primitives'; +import { + fetchUserFeatureFlags, + updateUserFeatureFlags, + type UserFeatureFlagsResponse, +} from '../../lib/userFeatureFlagsApi'; + +/** + * Admin-only: look up a user by ID and pin/unpin their per-user feature-flag + * overrides. Backed by GET/PATCH /api/admin/users/:userId/feature-flags. + * + * Each flag is shown as a 3-way picker (Use global default / Force on / Force + * off) so the admin can express "this user gets backtest even though global + * is off" without ambiguity. + */ +type TriState = 'inherit' | 'on' | 'off'; + +const triFromBool = (value?: boolean): TriState => + typeof value === 'boolean' ? (value ? 'on' : 'off') : 'inherit'; + +const triToOverride = (value: TriState): boolean | null => + value === 'inherit' ? null : value === 'on'; + +interface FlagRowProps { + label: string; + description: string; + effective?: boolean; + value: TriState; + onChange: (next: TriState) => void; +} + +const FlagRow: React.FC = ({ label, description, effective, value, onChange }) => ( +
+
+
{label}
+
{description}
+ {typeof effective === 'boolean' ? ( +
+ Effective: + {effective ? 'enabled' : 'disabled'} + +
+ ) : null} +
+
+ {(['inherit', 'on', 'off'] as TriState[]).map((option) => ( + + ))} +
+
+); + +export const UserFeatureFlagsPanel: React.FC = () => { + const [userId, setUserId] = React.useState(''); + const [data, setData] = React.useState(null); + const [loading, setLoading] = React.useState(false); + const [saving, setSaving] = React.useState(false); + const [message, setMessage] = React.useState<{ kind: 'success' | 'error'; text: string } | null>(null); + + // Local edit state — separate from the server-loaded data so the user can + // make multiple changes before saving. + const [pending, setPending] = React.useState<{ + backtestEnabled: TriState; + backtestCustomerEnabled: TriState; + } | null>(null); + + const handleLoad = async () => { + const trimmed = userId.trim(); + if (!trimmed) { + setMessage({ kind: 'error', text: 'Enter a user id to look up.' }); + return; + } + setLoading(true); + setMessage(null); + try { + const response = await fetchUserFeatureFlags(trimmed); + setData(response); + setPending({ + backtestEnabled: triFromBool(response.overrides.backtestEnabled), + backtestCustomerEnabled: triFromBool(response.overrides.backtestCustomerEnabled), + }); + } catch (error: any) { + setData(null); + setPending(null); + setMessage({ kind: 'error', text: error?.message || 'Lookup failed.' }); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!data || !pending) return; + setSaving(true); + setMessage(null); + try { + const response = await updateUserFeatureFlags(data.userId, { + backtestEnabled: triToOverride(pending.backtestEnabled), + backtestCustomerEnabled: triToOverride(pending.backtestCustomerEnabled), + }); + setData(response); + setPending({ + backtestEnabled: triFromBool(response.overrides.backtestEnabled), + backtestCustomerEnabled: triFromBool(response.overrides.backtestCustomerEnabled), + }); + setMessage({ kind: 'success', text: `Saved overrides for ${response.userId}.` }); + setTimeout(() => setMessage(null), 4000); + } catch (error: any) { + setMessage({ kind: 'error', text: error?.message || 'Save failed.' }); + } finally { + setSaving(false); + } + }; + + const dirty = data && pending && ( + triFromBool(data.overrides.backtestEnabled) !== pending.backtestEnabled + || triFromBool(data.overrides.backtestCustomerEnabled) !== pending.backtestCustomerEnabled + ); + + return ( +
+
+
+

Per-User Feature Flag Overrides

+

+ Pin a flag on or off for a specific user, overriding the global + default above. Use Default to revert. Per-user overrides take precedence on + both /api/feature-flags reads and /api/backtest/run guards. +

+
+
+ +
+ + +
+ + {message ? ( +
+ {message.text} +
+ ) : null} + + {data && pending ? ( + <> +
+ User {data.userId} +
+ + setPending((p) => p ? { ...p, backtestEnabled: next } : p)} + /> + setPending((p) => p ? { ...p, backtestCustomerEnabled: next } : p)} + /> + +
+ + +
+ + ) : null} +
+ ); +}; diff --git a/web/src/layout-fixes.css b/web/src/layout-fixes.css index 8809954..63fe213 100644 --- a/web/src/layout-fixes.css +++ b/web/src/layout-fixes.css @@ -636,3 +636,100 @@ .saved-setup-action.is-admin-only:hover { background: color-mix(in oklab, var(--bl-warning) 10%, transparent); } + +/* --------------------------------------------------------------------------- + Section 27 — Per-user feature flag override panel (Settings → Bot Config) + Tri-state pickers, used for runtime per-user backtest gating. +--------------------------------------------------------------------------- */ +.user-flag-lookup { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + margin-top: 8px; +} +.user-flag-section-meta { + margin-top: 12px; + font-size: 11px; + color: var(--bl-text-quiet); + letter-spacing: 0.04em; +} +.user-flag-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + margin-top: 8px; + background: var(--bl-surface-overlay); + border: 1px solid var(--bl-border-subtle); + border-radius: 14px; +} +.user-flag-row-meta { + flex: 1 1 280px; + min-width: 0; +} +.user-flag-row-label { + font-size: 13px; + font-weight: 700; + color: var(--foreground); +} +.user-flag-row-desc { + font-size: 11px; + color: var(--muted-foreground); + margin-top: 2px; +} +.user-flag-row-effective { + font-size: 11px; + color: var(--bl-text-quiet); + margin-top: 4px; +} +.user-flag-row-controls { + display: inline-flex; + flex-shrink: 0; + border: 1px solid var(--bl-border-subtle); + border-radius: 999px; + overflow: hidden; + background: var(--bl-surface-muted); +} +.user-flag-tri { + appearance: none; + font: inherit; + background: transparent; + border: 0; + color: var(--bl-text-secondary); + padding: 6px 14px; + font-size: 11px; + font-weight: 700; + cursor: pointer; + transition: background-color 120ms ease, color 120ms ease; +} +.user-flag-tri:hover { + background: var(--bl-surface-highlight); + color: var(--foreground); +} +.user-flag-tri.is-active { + background: var(--bl-accent); + color: var(--bl-accent-foreground, white); +} +.user-flag-tri:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--bl-focus-ring, var(--bl-accent, #5A8CFF)); +} +.user-flag-message { + margin-top: 10px; + font-size: 12px; + padding: 8px 12px; + border-radius: 10px; +} +.user-flag-message.is-error { + background: var(--bl-danger-muted); + color: var(--bl-danger); + border: 1px solid color-mix(in oklab, var(--bl-danger) 30%, transparent); +} +.user-flag-message.is-success { + background: var(--bl-success-muted); + color: var(--bl-success); + border: 1px solid color-mix(in oklab, var(--bl-success) 30%, transparent); +} diff --git a/web/src/lib/userFeatureFlagsApi.ts b/web/src/lib/userFeatureFlagsApi.ts new file mode 100644 index 0000000..e56917c --- /dev/null +++ b/web/src/lib/userFeatureFlagsApi.ts @@ -0,0 +1,63 @@ +import { getPlatformAccessToken } from './authSession'; +import { tradingRuntime } from './runtime'; +import { createRequestId } from '../../../shared/request-id.js'; + +/** + * Per-user feature-flag override admin API client. + * Backed by `/api/admin/users/:userId/feature-flags` (admin-only). + * + * Each flag is a tri-state: + * - boolean → pin to that value (overrides global default) + * - undefined → no override (use global default) + * - null (only valid in PATCH body) → clear an existing override + */ + +export interface UserFeatureFlagOverrides { + backtestEnabled?: boolean; + backtestCustomerEnabled?: boolean; +} + +export interface UserFeatureFlagsResponse { + userId: string; + overrides: UserFeatureFlagOverrides; + effective?: { + backtestEnabled: boolean; + backtestCustomerEnabled: boolean; + }; +} + +const headers = async () => { + const token = await getPlatformAccessToken(); + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'x-request-id': createRequestId('web-user-flags'), + }; +}; + +export async function fetchUserFeatureFlags(userId: string): Promise { + const url = `${tradingRuntime.tradingApiUrl}/api/admin/users/${encodeURIComponent(userId)}/feature-flags`; + const response = await fetch(url, { headers: await headers() }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error || `Failed to load user feature flags (${response.status})`); + } + return body as UserFeatureFlagsResponse; +} + +export async function updateUserFeatureFlags( + userId: string, + overrides: { [K in keyof UserFeatureFlagOverrides]?: UserFeatureFlagOverrides[K] | null } +): Promise { + const url = `${tradingRuntime.tradingApiUrl}/api/admin/users/${encodeURIComponent(userId)}/feature-flags`; + const response = await fetch(url, { + method: 'PATCH', + headers: await headers(), + body: JSON.stringify({ overrides }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error || `Failed to update user feature flags (${response.status})`); + } + return body as UserFeatureFlagsResponse; +} diff --git a/web/src/tabs/ConfigTab.tsx b/web/src/tabs/ConfigTab.tsx index e851451..f37d295 100644 --- a/web/src/tabs/ConfigTab.tsx +++ b/web/src/tabs/ConfigTab.tsx @@ -5,6 +5,7 @@ import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynami import { useAuth } from '../components/AuthContext'; import { BACKTEST_FLAG_KEYS } from '../../../shared/feature-flags.js'; import { Button, Checkbox, Textarea } from '../components/ui/Primitives'; +import { UserFeatureFlagsPanel } from '../components/admin/UserFeatureFlagsPanel'; interface ConfigItem { key: string; @@ -254,6 +255,8 @@ export const ConfigTab = () => { + +