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:
Devin 2026-05-10 19:04:12 +00:00
parent 4456873ab4
commit 4fc53703c6
9 changed files with 640 additions and 23 deletions

View File

@ -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);

View File

@ -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({

View File

@ -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;
}

View File

@ -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)
//

View File

@ -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;

View 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>
);
};

View File

@ -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);
}

View 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;
}

View File

@ -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>