feat(backtest): runtime + per-user feature flags (Option C)
Replaces the build-time VITE_BACKTEST_ENABLED gate with a fully runtime
flow: a global Cosmos-backed default (already shipped in the existing
dynamicConfig system) plus a new per-user override layer. An admin can
now enable backtest for specific users without flipping the global
switch — useful for staged rollout and beta testers.
Resolution order: per-user override > global config > env fallback.
Both /api/feature-flags (FE display) and /api/backtest/run (server
guard) consult the same merge logic.
Backend (backend/src/...):
~ services/profileRepository.ts
+ TradingUserFeatureFlags interface
+ featureFlags?: TradingUserFeatureFlags on TradingUserProfile
+ setUserFeatureFlags(userId, { backtestEnabled, ... })
~ saveCurrentUserProfile() — strip role + featureFlags from input
so non-admins can't elevate via PATCH /api/me/profile
~ mergeTradingUserProfiles() — preserves explicit flag values only
~ services/apiServer.ts
~ /api/feature-flags merges per-user override into the response
+ /api/admin/users/:userId/feature-flags (GET — overrides + effective)
+ /api/admin/users/:userId/feature-flags (PATCH — admin-only writer)
~ /api/backtest/run resolves effective flags before guarding
~ backtest/index.ts
+ RunBacktestOptions.skipGlobalFeatureFlagCheck
~ runBacktest() honors the override (route already gated stricter)
Frontend (web/src/...):
~ backtest/flags.ts — isBacktestBuildEnabled() now returns true.
Kept as a no-op function so existing callers don't break.
+ lib/userFeatureFlagsApi.ts — typed admin client
+ components/admin/UserFeatureFlagsPanel.tsx
Tri-state picker per flag (Default / On / Off), Look up by user id,
Save/Reset, shows the merged "effective" value.
~ tabs/ConfigTab.tsx — mounts <UserFeatureFlagsPanel /> below the
existing global Backtest Access Control section.
~ layout-fixes.css §27 — styles for the per-user panel.
Tests:
+ testBacktestEngine: skipGlobalFeatureFlagCheck enables per-user
override semantics. 12/12 regression checks pass.
Security note: featureFlags + role are explicitly stripped from
saveCurrentUserProfile input. Only the admin-only PATCH endpoint can
set per-user overrides.
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
4456873ab4
commit
4fc53703c6
@ -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<BacktestResult> => {
|
||||
if (!options.skipGlobalFeatureFlagCheck) {
|
||||
assertBacktestFeatureEnabled();
|
||||
}
|
||||
assertBacktestMode(request.mode);
|
||||
assertBacktestStrategyConfigSafe(request.strategyConfig);
|
||||
const historical = await loadHistoricalData(request);
|
||||
|
||||
@ -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<string, unknown> };
|
||||
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({
|
||||
|
||||
@ -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<TradingUserProfile> = {}
|
||||
): Promise<TradingUserProfile> {
|
||||
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<TradingUserProfile> {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
//
|
||||
|
||||
@ -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;
|
||||
|
||||
216
web/src/components/admin/UserFeatureFlagsPanel.tsx
Normal file
216
web/src/components/admin/UserFeatureFlagsPanel.tsx
Normal file
@ -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<FlagRowProps> = ({ label, description, effective, value, onChange }) => (
|
||||
<div className="user-flag-row">
|
||||
<div className="user-flag-row-meta">
|
||||
<div className="user-flag-row-label">{label}</div>
|
||||
<div className="user-flag-row-desc">{description}</div>
|
||||
{typeof effective === 'boolean' ? (
|
||||
<div className="user-flag-row-effective">
|
||||
Effective: <span className={`font-semibold ${effective ? 'text-emerald-500' : 'text-zinc-500'}`}>
|
||||
{effective ? 'enabled' : 'disabled'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="user-flag-row-controls">
|
||||
{(['inherit', 'on', 'off'] as TriState[]).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => onChange(option)}
|
||||
className={`user-flag-tri ${value === option ? 'is-active' : ''}`}
|
||||
title={option === 'inherit' ? 'Use global default for this user' : option === 'on' ? 'Force enabled' : 'Force disabled'}
|
||||
>
|
||||
{option === 'inherit' ? 'Default' : option === 'on' ? 'On' : 'Off'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const UserFeatureFlagsPanel: React.FC = () => {
|
||||
const [userId, setUserId] = React.useState('');
|
||||
const [data, setData] = React.useState<UserFeatureFlagsResponse | null>(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 (
|
||||
<div className="settings-control-panel">
|
||||
<div className="settings-control-header">
|
||||
<div>
|
||||
<h3>Per-User Feature Flag Overrides</h3>
|
||||
<p>
|
||||
Pin a flag <code>on</code> or <code>off</code> for a specific user, overriding the global
|
||||
default above. Use <code>Default</code> to revert. Per-user overrides take precedence on
|
||||
both <code>/api/feature-flags</code> reads and <code>/api/backtest/run</code> guards.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="user-flag-lookup">
|
||||
<label className="space-y-1 flex-1 min-w-[240px]">
|
||||
<span className="text-[10px] uppercase tracking-wider text-zinc-400">User ID</span>
|
||||
<Input
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="e.g. 0a4f9b3e-…"
|
||||
controlSize="sm"
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') void handleLoad(); }}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleLoad()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Loading…' : 'Look up'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className={`user-flag-message ${message.kind === 'error' ? 'is-error' : 'is-success'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data && pending ? (
|
||||
<>
|
||||
<div className="user-flag-section-meta">
|
||||
User <span className="font-mono">{data.userId}</span>
|
||||
</div>
|
||||
|
||||
<FlagRow
|
||||
label="Backtest engine"
|
||||
description="Whether the user can call /api/backtest/run at all."
|
||||
effective={data.effective?.backtestEnabled}
|
||||
value={pending.backtestEnabled}
|
||||
onChange={(next) => setPending((p) => p ? { ...p, backtestEnabled: next } : p)}
|
||||
/>
|
||||
<FlagRow
|
||||
label="Customer-grade backtest"
|
||||
description="Allows non-admins. Admins always pass when 'Backtest engine' is enabled."
|
||||
effective={data.effective?.backtestCustomerEnabled}
|
||||
value={pending.backtestCustomerEnabled}
|
||||
onChange={(next) => setPending((p) => p ? { ...p, backtestCustomerEnabled: next } : p)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPending({
|
||||
backtestEnabled: triFromBool(data.overrides.backtestEnabled),
|
||||
backtestCustomerEnabled: triFromBool(data.overrides.backtestCustomerEnabled),
|
||||
})}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!dirty || saving}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save overrides'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
|
||||
63
web/src/lib/userFeatureFlagsApi.ts
Normal file
63
web/src/lib/userFeatureFlagsApi.ts
Normal file
@ -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<UserFeatureFlagsResponse> {
|
||||
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<UserFeatureFlagsResponse> {
|
||||
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;
|
||||
}
|
||||
@ -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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserFeatureFlagsPanel />
|
||||
|
||||
<div className="table-container overflow-x-auto">
|
||||
<table className="pro-table w-full">
|
||||
<thead>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user