feat: move dynamic config behind backend control plane

This commit is contained in:
Saravana Achu Mac 2026-04-04 14:26:12 -07:00
parent a4fce709f0
commit b551ab2a4f
7 changed files with 314 additions and 129 deletions

View File

@ -1,5 +1,6 @@
import * as dotenv from 'dotenv';
import logger from '../utils/logger.js';
import { listDynamicConfigEntries } from '../services/dynamicConfigRepository.js';
dotenv.config({ override: true });
@ -400,39 +401,42 @@ const aiConfigParsers: Record<string, (value: unknown) => unknown> = {
FAIL_OPEN: (value) => toBoolean(value, config.AI.FAIL_OPEN),
};
/**
* Loads global configuration from Supabase to override .env defaults.
* This allows for remote management of bot behavior.
*/
export const loadDynamicConfig = async (supabase: any) => {
try {
logger.info('--- Loading Dynamic Global Config from Supabase ---');
const { data, error } = await supabase.client
.from('bot_config') // Using 'bot_config' table
.select('*');
export function applyDynamicConfigEntries(data: Array<{ key: string; value: unknown }>) {
if (!Array.isArray(data) || data.length === 0) {
return [] as string[];
}
if (error) {
logger.warn(`[Config] Failed to load dynamic config: ${error.message}. Using .env defaults.`);
return;
const loadedKeys: string[] = [];
data.forEach((item: any) => {
const { key, value } = item;
if (key in config) {
const parser = dynamicConfigParsers[key];
(config as any)[key] = parser ? parser(value) : value;
loadedKeys.push(key);
} else if (key in config.AI) {
const parser = aiConfigParsers[key];
(config.AI as any)[key] = parser ? parser(value) : value;
loadedKeys.push(`AI.${key}`);
}
});
return loadedKeys;
}
/**
* Loads global configuration from Cosmos-first dynamic config storage to override .env defaults.
* Falls back to the legacy Supabase table during migration.
*/
export const loadDynamicConfig = async (supabase?: any) => {
try {
logger.info('--- Loading Dynamic Global Config from control-plane storage ---');
const data = await listDynamicConfigEntries(supabase);
if (data && data.length > 0) {
const loadedKeys: string[] = [];
data.forEach((item: any) => {
const { key, value } = item;
if (key in config) {
const parser = dynamicConfigParsers[key];
(config as any)[key] = parser ? parser(value) : value;
loadedKeys.push(key);
} else if (key in config.AI) {
const parser = aiConfigParsers[key];
(config.AI as any)[key] = parser ? parser(value) : value;
loadedKeys.push(`AI.${key}`);
}
});
const loadedKeys = applyDynamicConfigEntries(data);
logger.info(`✅ Dynamic Config Loaded: ${loadedKeys.join(', ')}`);
} else {
logger.info(' No dynamic config overrides found in DB. Using .env defaults.');
logger.info(' No dynamic config overrides found in control-plane storage. Using .env defaults.');
}
} catch (err: any) {
logger.error(`[Config] Unexpected error loading dynamic config: ${err.message}`);

View File

@ -6,13 +6,14 @@ import logger from '../utils/logger.js';
import fs from 'fs';
import path from 'path';
import { ManualTrader } from './ManualTrader.js';
import { config } from '../config/index.js';
import { applyDynamicConfigEntries, config, loadDynamicConfig } from '../config/index.js';
import { AIClient } from './aiClient.js';
import { supabaseService } from './SupabaseService.js';
import { healthTracker, HealthSnapshot, TradingControlSnapshot } from './healthTracker.js';
import { observabilityService } from './observabilityService.js';
import { isTradingAdmin, verifyTradingAccessToken } from './platformAuthService.js';
import { loadGlobalTradingControl, saveGlobalTradingControl } from './tradingControlRepository.js';
import { listDynamicConfigEntries, upsertDynamicConfigEntries } from './dynamicConfigRepository.js';
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
import { OperationalEvent } from '../domain/operationalEvents.js';
import { runBacktest } from '../backtest/index.js';
@ -1601,6 +1602,27 @@ export class ApiServer {
});
});
this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => {
try {
const items = await listDynamicConfigEntries(supabaseService);
res.json({ items });
} catch (error: any) {
res.status(500).json({ error: `Failed to load dynamic config: ${error.message}` });
}
});
this.app.put('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (req, res) => {
try {
const items = Array.isArray(req.body?.items) ? req.body.items : [];
await upsertDynamicConfigEntries(items, supabaseService);
applyDynamicConfigEntries(items);
await loadDynamicConfig(supabaseService);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: `Failed to update dynamic config: ${error.message}` });
}
});
this.app.post('/api/backtest/run', this.requireAuth, async (req, res) => {
const authUserId = (req as AuthenticatedRequest).authUserId;
if (!authUserId) {

View File

@ -0,0 +1,145 @@
import { getContainer } from '@bytelyst/cosmos';
import { config } from '../config/index.js';
import logger from '../utils/logger.js';
import type { supabaseService } from './SupabaseService.js';
export interface DynamicConfigEntry {
key: string;
value: string;
description?: string;
updated_at?: string;
}
interface DynamicConfigDocument extends DynamicConfigEntry {
id: string;
productId: string;
updatedAt: string;
}
type LegacySupabaseService = typeof supabaseService;
const CONTAINER_NAME = 'dynamic_config';
function isCosmosConfigured(): boolean {
return Boolean(config.COSMOS_ENDPOINT && config.COSMOS_KEY);
}
function normalizeEntry(entry: Partial<DynamicConfigEntry> | null | undefined): DynamicConfigEntry | null {
const key = String(entry?.key || '').trim();
if (!key) return null;
return {
key,
value: String(entry?.value ?? ''),
description: entry?.description ? String(entry.description) : '',
updated_at: entry?.updated_at ? String(entry.updated_at) : undefined,
};
}
async function listFromCosmos(): Promise<DynamicConfigEntry[]> {
if (!isCosmosConfigured()) return [];
const container = getContainer(CONTAINER_NAME);
const { resources } = await container.items
.query<DynamicConfigDocument>({
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.key',
parameters: [{ name: '@productId', value: config.PRODUCT_ID }],
})
.fetchAll();
return resources
.map((resource) => normalizeEntry({
key: resource.key,
value: resource.value,
description: resource.description,
updated_at: resource.updatedAt,
}))
.filter((entry): entry is DynamicConfigEntry => Boolean(entry));
}
async function listFromSupabase(legacyService?: LegacySupabaseService): Promise<DynamicConfigEntry[]> {
const client = legacyService?.getClient?.();
if (!client) return [];
try {
const { data, error } = await client
.from('bot_config')
.select('*')
.order('key');
if (error || !Array.isArray(data)) {
return [];
}
return data
.map((row) => normalizeEntry(row as DynamicConfigEntry))
.filter((entry): entry is DynamicConfigEntry => Boolean(entry));
} catch (error) {
logger.warn(`[DynamicConfig] Legacy Supabase read failed: ${error instanceof Error ? error.message : 'unknown error'}`);
return [];
}
}
async function mirrorToSupabase(entries: DynamicConfigEntry[], legacyService?: LegacySupabaseService): Promise<void> {
const client = legacyService?.getClient?.();
if (!client || entries.length === 0) return;
try {
const payload = entries.map((entry) => ({
key: entry.key,
value: entry.value,
description: entry.description || '',
updated_at: entry.updated_at || new Date().toISOString(),
}));
const { error } = await client
.from('bot_config')
.upsert(payload, { onConflict: 'key' });
if (error) {
logger.warn(`[DynamicConfig] Legacy Supabase mirror failed: ${error.message}`);
}
} catch (error) {
logger.warn(`[DynamicConfig] Legacy Supabase mirror failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
export async function listDynamicConfigEntries(legacyService?: LegacySupabaseService): Promise<DynamicConfigEntry[]> {
try {
const cosmosEntries = await listFromCosmos();
if (cosmosEntries.length > 0) {
return cosmosEntries;
}
} catch (error) {
logger.warn(`[DynamicConfig] Cosmos read failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`);
}
return listFromSupabase(legacyService);
}
export async function upsertDynamicConfigEntries(
entries: DynamicConfigEntry[],
legacyService?: LegacySupabaseService
): Promise<void> {
const normalized = entries
.map((entry) => normalizeEntry(entry))
.filter((entry): entry is DynamicConfigEntry => Boolean(entry));
if (normalized.length === 0) {
return;
}
const now = new Date().toISOString();
if (isCosmosConfigured()) {
try {
const container = getContainer(CONTAINER_NAME);
await Promise.all(normalized.map((entry) => container.items.upsert<DynamicConfigDocument>({
id: entry.key,
productId: config.PRODUCT_ID,
key: entry.key,
value: entry.value,
description: entry.description || '',
updated_at: entry.updated_at || now,
updatedAt: entry.updated_at || now,
})));
} catch (error) {
logger.warn(`[DynamicConfig] Cosmos upsert failed: ${error instanceof Error ? error.message : 'unknown error'}`);
}
}
await mirrorToSupabase(normalized, legacyService);
}

View File

@ -1,44 +1,27 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { GlobalConfigManager } from './GlobalConfigManager';
// import { tableNameBotConfig } from '../lib/const';
// Simplified mocks
const configOrderMock = vi.fn();
const configUpsertMock = vi.fn();
vi.mock('../lib/supabaseClient', () => ({
supabase: {
from: (table: string) => {
if (table === 'bot_config') {
return {
select: () => ({
order: configOrderMock
}),
upsert: configUpsertMock
};
}
return {
select: () => ({ order: vi.fn().mockResolvedValue({ data: [], error: null }) }),
upsert: vi.fn().mockResolvedValue({ error: null })
};
}
}
}));
describe('GlobalConfigManager DOM behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
configOrderMock.mockResolvedValue({
data: [
{ key: 'BOT_MODE', value: 'paper', description: 'Trading mode' },
{ key: 'MAX_POSITIONS', value: '5', description: 'Max positions' }
],
error: null
});
configUpsertMock.mockResolvedValue({ error: null });
});
import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { GlobalConfigManager } from './GlobalConfigManager';
const { fetchDynamicConfigItemsMock, upsertDynamicConfigItemsMock } = vi.hoisted(() => ({
fetchDynamicConfigItemsMock: vi.fn(),
upsertDynamicConfigItemsMock: vi.fn(),
}));
vi.mock('../lib/dynamicConfigApi', () => ({
fetchDynamicConfigItems: fetchDynamicConfigItemsMock,
upsertDynamicConfigItems: upsertDynamicConfigItemsMock,
}));
describe('GlobalConfigManager DOM behavior', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchDynamicConfigItemsMock.mockResolvedValue([
{ key: 'BOT_MODE', value: 'paper', description: 'Trading mode' },
{ key: 'MAX_POSITIONS', value: '5', description: 'Max positions' }
]);
upsertDynamicConfigItemsMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
@ -61,20 +44,22 @@ describe('GlobalConfigManager DOM behavior', () => {
render(<GlobalConfigManager />);
await waitFor(() => expect(screen.getByText(/Global Bot Configuration/i)).toBeInTheDocument());
const saveButtons = screen.getAllByRole('button', { name: /Save/i });
fireEvent.click(saveButtons[0]);
await waitFor(() => {
expect(configUpsertMock).toHaveBeenCalled();
});
expect(screen.getByText(/updated successfully/i)).toBeInTheDocument();
});
it('handles save failure', async () => {
configUpsertMock.mockResolvedValue({ error: { message: 'Write Denied' } });
render(<GlobalConfigManager />);
await waitFor(() => expect(screen.getByText(/Global Bot Configuration/i)).toBeInTheDocument());
const saveButtons = screen.getAllByRole('button', { name: /Save/i });
fireEvent.click(saveButtons[0]);
await waitFor(() => {
expect(upsertDynamicConfigItemsMock).toHaveBeenCalledWith([
{ key: 'BOT_MODE', value: 'paper', description: 'Trading mode' }
]);
});
expect(screen.getByText(/updated successfully/i)).toBeInTheDocument();
});
it('handles save failure', async () => {
upsertDynamicConfigItemsMock.mockRejectedValue(new Error('Write Denied'));
render(<GlobalConfigManager />);
await waitFor(() => expect(screen.getByText(/Global Bot Configuration/i)).toBeInTheDocument());
const saveButtons = screen.getAllByRole('button', { name: /Save/i });
fireEvent.click(saveButtons[0]);
@ -82,18 +67,15 @@ describe('GlobalConfigManager DOM behavior', () => {
await waitFor(() => {
expect(screen.getByText(/Failed to save BOT_MODE: Write Denied/i)).toBeInTheDocument();
});
});
it('handles table missing error', async () => {
configOrderMock.mockResolvedValue({
data: null,
error: { code: '42P01', message: 'miss' }
});
render(<GlobalConfigManager />);
await waitFor(() => {
expect(screen.getByText(/Database table "bot_config" not found/i)).toBeInTheDocument();
});
});
});
});
it('handles service unavailable error', async () => {
fetchDynamicConfigItemsMock.mockRejectedValue(new Error('Dynamic config service unavailable.'));
render(<GlobalConfigManager />);
await waitFor(() => {
expect(screen.getByText(/Dynamic config service unavailable./i)).toBeInTheDocument();
});
});
});

View File

@ -1,7 +1,6 @@
import React from 'react';
import { supabase } from '../lib/supabaseClient';
import { tableNameBotConfig } from '../lib/const';
import { Save, AlertCircle, CheckCircle } from 'lucide-react';
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
interface ConfigItem {
key: string;
@ -17,19 +16,12 @@ export const GlobalConfigManager = () => {
const fetchConfigs = async () => {
setLoading(true);
const { data, error } = await supabase
.from(tableNameBotConfig)
.select('*')
.order('key');
if (error) {
console.error('Error fetching global config:', error);
// Table might not exist yet
if (error.code === '42P01') {
setMessage({ type: 'error', text: 'Database table "bot_config" not found. Please run the SQL setup.' });
}
} else {
try {
const data = await fetchDynamicConfigItems();
setConfigs(data || []);
} catch (error: any) {
console.error('Error fetching global config:', error);
setMessage({ type: 'error', text: error?.message || 'Dynamic config service unavailable.' });
}
setLoading(false);
};
@ -44,15 +36,12 @@ export const GlobalConfigManager = () => {
const handleSave = async (item: ConfigItem) => {
setSaving(true);
const { error } = await supabase
.from(tableNameBotConfig)
.upsert({ key: item.key, value: item.value, updated_at: new Date().toISOString() });
if (error) {
setMessage({ type: 'error', text: `Failed to save ${item.key}: ${error.message}` });
} else {
try {
await upsertDynamicConfigItems([item]);
setMessage({ type: 'success', text: `${item.key} updated successfully.` });
setTimeout(() => setMessage(null), 3000);
} catch (error: any) {
setMessage({ type: 'error', text: `Failed to save ${item.key}: ${error.message}` });
}
setSaving(false);
};

View File

@ -0,0 +1,50 @@
import { supabase } from './supabaseClient';
import { tradingRuntime } from './runtime';
export interface DynamicConfigItem {
key: string;
value: string;
description: string;
}
async function getAccessToken(): Promise<string> {
const { data: sessionData } = await supabase.auth.getSession();
const accessToken = sessionData.session?.access_token;
if (!accessToken) {
throw new Error('Not authenticated');
}
return accessToken;
}
export async function fetchDynamicConfigItems(): Promise<DynamicConfigItem[]> {
const accessToken = await getAccessToken();
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/admin/config/dynamic`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const body = await response.json().catch(() => ({} as any));
if (!response.ok) {
throw new Error(body?.error || `Failed to load dynamic config (${response.status})`);
}
return Array.isArray(body?.items) ? (body.items as DynamicConfigItem[]) : [];
}
export async function upsertDynamicConfigItems(items: DynamicConfigItem[]): Promise<void> {
const accessToken = await getAccessToken();
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/admin/config/dynamic`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ items }),
});
const body = await response.json().catch(() => ({} as any));
if (!response.ok || body?.success === false) {
throw new Error(body?.error || `Failed to update dynamic config (${response.status})`);
}
}

View File

@ -15,6 +15,7 @@ import type { BotState } from '../hooks/useWebSocket';
import { useWebSocket } from '../hooks/useWebSocket';
import { useAuth } from '../components/AuthContext';
import { tradingRuntime } from '../lib/runtime';
import { fetchDynamicConfigItems, upsertDynamicConfigItems } from '../lib/dynamicConfigApi';
interface AdminTabProps {
botState: BotState;
@ -256,12 +257,8 @@ export const AdminTab = ({ botState }: AdminTabProps) => {
const fetchDbSyncSettings = async () => {
try {
const { data, error } = await supabase
.from('bot_config')
.select('*')
.in('key', ['ENABLE_DB_SNAPSHOTS', 'DB_SNAPSHOT_INTERVAL_MS']);
if (!error && data) {
const data = await fetchDynamicConfigItems();
if (data) {
data.forEach((item: { key: string; value: string; }) => {
if (item.key === 'ENABLE_DB_SNAPSHOTS') {
setDbSyncEnabled(item.value === 'true');
@ -287,11 +284,7 @@ export const AdminTab = ({ botState }: AdminTabProps) => {
{ key: 'DB_SNAPSHOT_INTERVAL_MS', value: String(dbSyncInterval), description: 'Minimum interval between database snapshots in ms' }
];
const { error } = await supabase
.from('bot_config')
.upsert(updates, { onConflict: 'key' });
if (error) throw error;
await upsertDynamicConfigItems(updates);
} catch (err: any) {
setControlError(`DB Sync Update Failed: ${err.message}`);
} finally {