using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using PolyTrader.App.Avalonia.Shell;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.DependencyInjection;
using PolyTrader.Core.Modularity;
using PolyTrader.Core.Notifications;
using PolyTrader.Core.Security;
using PolyTrader.Modules.Accounting;
using PolyTrader.Modules.CopyTrading;
using PolyTrader.Modules.ResolutionFarming;
using PolyTrader.Modules.Supervisor;
using PolyTraderSharp;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using PolyTrader.Web.Configuration;
using PolyTrader.Web.Hosting;
namespace PolyTrader.App.Avalonia
{
///
/// Einstiegspunkt der plattformneutralen PolyTrader-Anwendung.
///
/// Der Aufbau ist bewusst zweigeteilt: stellt den Generic Host mit
/// Persistenz, Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst
/// hängt Avalonia daran. Damit lässt sich derselbe Host später ohne Umbau
/// für den kopflosen Linux-Betrieb starten (Schalter --headless, Stufe L2).
///
internal static class Program
{
private const string ServerSettingsPath = "server_settings.xml";
///
/// Rueckgabewert bei Konfigurationsfehlern. 78 ist EX_CONFIG aus den BSD-sysexits und
/// damit ein etablierter Wert fuer "Konfiguration unbrauchbar". Wichtig fuer den
/// Dienstbetrieb: deploy/polytrader.service traegt ihn in
/// RestartPreventExitStatus ein, damit systemd bei einem Konfigurationsfehler
/// NICHT im Zehn-Sekunden-Takt neu startet - ein fehlender Connection-String repariert
/// sich durch Neustarts nicht.
///
private const int ExitCodeConfigurationError = 78;
[STAThread]
public static int Main(string[] args)
{
bool headless = args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase));
bool smoke = args.Any(a => string.Equals(a, "--smoke-ui", StringComparison.OrdinalIgnoreCase));
// D-2: Lizenz-CLI-Schalter (--license-status/--license-set-key/--license-deactivate)
// laufen VOR dem Host-Aufbau und beenden sich selbst - kein Sinn, dafür Trading-Dienste
// hochzufahren.
if (Licensing.LicenseCli.TryHandle(args, out int cliExitCode))
return cliExitCode;
var dcOptions = LoadDeploymentcenterOptions();
// Ohne Datenbankverbindung kann die Anwendung nichts Sinnvolles tun - jedes Repository
// haengt daran. Bis zum 26.08.2026 lief sie trotzdem los und starb erst tief im
// DI-Aufbau mit einer ArgumentException samt 50 Zeilen Stacktrace ("The string argument
// 'connectionString' cannot be empty"). Auf einem Server ist das doppelt schlecht:
// Der Betreiber sieht nicht, was fehlt, und systemd startet den Dienst wegen
// Restart=on-failure endlos neu. Deshalb hier frueh und verstaendlich abbrechen.
var connectionString = LoadDatabaseConnectionString();
if (string.IsNullOrWhiteSpace(connectionString))
{
var err = Console.Error;
err.WriteLine("FEHLER: Keine Datenbankverbindung konfiguriert.");
err.WriteLine();
err.WriteLine("Erwartet wird 'Database:MySqlConnectionString' in appsettings.Local.json");
err.WriteLine($"neben der Programmdatei ({AppContext.BaseDirectory}).");
err.WriteLine();
err.WriteLine("Beispiel:");
err.WriteLine("{");
err.WriteLine(" \"Database\": {");
err.WriteLine(" \"MySqlConnectionString\": \"Server=HOST;Port=3306;Database=polytrader;User ID=BENUTZER;Password=PASSWORT;\"");
err.WriteLine(" }");
err.WriteLine("}");
err.WriteLine();
err.WriteLine("Die Datei ist bewusst nicht versioniert (Zugangsdaten). Das Schema legt");
err.WriteLine("'dotnet ef database update' an - die Anwendung migriert nicht selbst.");
return ExitCodeConfigurationError;
}
// Konstruktionsprüfung (--smoke-ui) testet Fensteraufbau, keine Lizenzlogik - dort
// bewusst ALLE Module registrieren, sonst würde die Prüfung auf einem Build-Server
// (nie lizenziert) still einen Großteil ihrer Abdeckung verlieren.
var host = BuildHost(dcOptions, enforceLicense: !smoke, out var bootLog);
// Konstruktionspruefung der Fenster: Host NICHT starten. Sonst laufen Trading-Engine,
// WSS-Clients und Jobs gegen die echten Boersen-Endpunkte an - fuer einen reinen
// UI-Test unerwuenscht (und auf einem Build-Server schlicht falsch).
if (smoke)
{
try { return RunSmokeUi(host, bootLog); }
finally { host.Dispose(); }
}
host.Start();
try
{
if (headless)
{
bootLog.Info("Kopfloser Betrieb: keine Oberfläche, Trading-Dienste laufen. Beenden mit Strg+C.");
host.WaitForShutdown();
return 0;
}
App.Services = host.Services;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return 0;
}
finally
{
// Geordnetes Herunterfahren der Module – erst nach dem Ende der Oberfläche.
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
host.Dispose();
}
}
/// Von Avalonia (auch vom XAML-Previewer) erwartete Fabrikmethode.
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
///
/// Lädt NUR den Deploymentcenter-Konfigurationsblock, bevor der Generic Host existiert –
/// die Lizenzprüfung (D-2) muss VOR der Modul-Liste laufen, also bevor
/// seine eigene baut.
/// Liest dieselben Dateien wie der Host später (appsettings.json + appsettings.Local.json),
/// nur eigenständig und minimal. Von mitbenutzt.
///
internal static DeploymentcenterOptions LoadDeploymentcenterOptions()
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Local.json", optional: true)
.Build();
var options = new DeploymentcenterOptions();
config.GetSection(DeploymentcenterOptions.SectionName).Bind(options);
return options;
}
///
/// Liest den Connection-String, bevor der Generic Host existiert - fuer die fruehe
/// Konfigurationspruefung in . Liest dieselben Dateien und denselben
/// Schluessel wie der Host spaeter, damit beide nicht auseinanderlaufen koennen.
///
internal static string LoadDatabaseConnectionString()
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Local.json", optional: true)
.Build();
return config["Database:MySqlConnectionString"] ?? string.Empty;
}
///
/// Lädt die WebUI-Konfiguration (Port, LocalOnly, AuthToken) aus den Konfigurationsdateien.
///
internal static WebUiOptions LoadWebUiOptions()
{
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Local.json", optional: true)
.Build();
var options = new WebUiOptions();
config.GetSection(WebUiOptions.SectionName).Bind(options);
return options;
}
///
/// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die
/// aktiven Module. Kennt die Oberfläche nicht.
///
///
/// D-2: Wenn false (nur --smoke-ui), werden immer alle Module registriert –
/// die Konstruktionsprüfung testet Fensteraufbau, keine Lizenzlogik.
///
private static IHost BuildHost(DeploymentcenterOptions dcOptions, bool enforceLicense, out TerminalLogger bootLog)
{
var serverSettings = ServerSettings.Load(ServerSettingsPath);
var logger = new TerminalLogger();
bootLog = logger;
// Anzeige-Kultur festnageln: Auf Linux richtet sich die Kultur sonst nach LANG/LC_ALL,
// das unter systemd oft gar nicht gesetzt ist - dann faellt .NET auf Invariant zurueck
// und aus "1.234,56 USDC" wird "1,234.56 USDC". Maschinen-I/O (API, Logs, CSV) laeuft
// unabhaengig davon ohnehin invariant.
var uiCulture = new System.Globalization.CultureInfo("de-DE");
System.Globalization.CultureInfo.DefaultThreadCurrentCulture = uiCulture;
System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = uiCulture;
ConfigureSecretProtection(logger);
// Selbstheilende Migration (B-12): ein noch im Klartext abgelegter Lizenzschluessel
// wird beim ersten Start mit Master-Key verschluesselt nachgezogen. server_settings.xml
// traegt neben ihm auch den Watchdog-Token - die Konvention "Secrets nur verschluesselt
// at-rest" soll dort genauso gelten wie in der Datenbank.
if (SecretProtection.IsConfigured
&& !string.IsNullOrWhiteSpace(serverSettings.LicenseKey)
&& !SecretProtection.IsEncrypted(serverSettings.LicenseKey))
{
try
{
serverSettings.LicenseKey = SecretProtection.Protect(serverSettings.LicenseKey);
serverSettings.Save(ServerSettingsPath);
logger.Info("🔐 Lizenzschlüssel lag im Klartext vor und wurde verschlüsselt nachgezogen.");
}
catch (Exception ex)
{
logger.Warning($"⚠️ Lizenzschlüssel konnte nicht verschlüsselt nachgezogen werden: {ex.Message}");
}
}
// Anzeige-Zeitzone einmalig setzen (Logs, Auswertungen). Ein unbekannter Wert ist nicht
// fatal - es wird auf die Systemzeitzone ausgewichen und gewarnt.
AppTimeZone.Configure(serverSettings.ApplicationTimeZoneId, m => logger.Warning(m));
logger.Info($"Anzeige-Zeitzone: {AppTimeZone.CurrentId}");
var allModules = new List
{
new CopyTradingModule(),
new ResolutionFarmingModule(),
new SupervisorModule(),
new AccountingModule()
};
var disabled = new HashSet(serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase);
// D-2: ohne gültige Lizenz startet nur die Core-Shell (Terminal, Einstellungen) - alle
// Trading-/Analyse-Module bleiben unregistriert. Kein Environment.Exit (Entscheidung
// 31.07.2026): der Schlüssel lässt sich im Einstellungsfenster nachtragen.
bool licensed = !enforceLicense || Licensing.LicenseGate.CheckUsable(dcOptions, serverSettings, logger);
if (!licensed)
{
foreach (var m in allModules) disabled.Add(m.Name);
}
var modules = allModules.Where(m => !disabled.Contains(m.Name)).ToList();
var webOptions = LoadWebUiOptions();
if (!enforceLicense)
{
// Bei Konstruktionsprüfung (--smoke-ui) kein Kestrel starten
webOptions.Enabled = false;
}
if (webOptions.Enabled)
{
string bind = webOptions.LocalOnly ? "127.0.0.1 (nur lokal)" : "0.0.0.0 (Netzwerk/VPN)";
logger.Info($"🌐 WebGUI aktiv auf Port {webOptions.Port} [{bind}].");
if (string.IsNullOrWhiteSpace(webOptions.AuthToken))
logger.Warning("⚠️ WebGUI: Kein Zugangs-Token gesetzt – Zugriff ist vollständig gesperrt.");
}
else
{
logger.Info("🌐 WebGUI deaktiviert (WebUi:Enabled = false).");
}
return Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
.ConfigureAppConfiguration((_, config) =>
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
.ConfigureServices((context, services) =>
{
services.AddSingleton(dcOptions);
services.AddSingleton(webOptions);
var databaseOptions = new DatabaseOptions
{
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
};
services.Configure(context.Configuration.GetSection(DatabaseOptions.SectionName));
services.AddCorePersistence(databaseOptions);
services.AddSingleton(serverSettings);
// #560: die eine Liste aller Module (auch der nicht geladenen) und der eine Weg, die
// Einstellungsdatei zur Laufzeit fortzuschreiben - gemeinsam fuer Desktop und WebUI.
services.AddSingleton(new KnownModules(allModules));
services.AddSingleton(new ServerSettingsFile(ServerSettingsPath));
services.AddSingleton(logger);
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddHttpClient();
// Polymarket-Zugriffe teilen sich einen langlebigen Verbindungspool.
services.AddSingleton(sp => new PolymarketApiService(
sp.GetRequiredService(), CreatePolymarketHttpClient()));
services.AddSingleton(sp => new PolymarketClobClient(
sp.GetRequiredService(), CreatePolymarketHttpClient()));
// Benachrichtigungen: neutrale Senke, bis RocketChat/Telegram angebunden sind.
services.AddSingleton(
sp => new LogNotificationSink(sp.GetRequiredService()));
services.AddSingleton(sp =>
{
var watchdog = new WatchdogHeartbeatService(sp.GetRequiredService());
// D-1: erster "checks"-Eintrag - beweist die Mechanik mit einem real
// verfügbaren Signal. db/market_feed folgen, sobald die jeweiligen Dienste
// ein Gesundheitssignal nach außen geben (siehe
// docs/archiv/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, D-1).
var vpn = sp.GetRequiredService();
var serverSettings = sp.GetRequiredService();
watchdog.RegisterCheck("vpn", () => serverSettings.VpnEnabled
? (vpn.IsConnected, vpn.IsConnected ? null : "Mullvad VPN nicht verbunden")
: (true, null)); // VPN bewusst aus -> kein Fehlalarm
return watchdog;
});
// MUSS vor den Modulen registriert werden: hydriert die Accounts, bevor die
// Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge).
services.AddHostedService();
foreach (var module in modules)
{
services.AddSingleton(module);
module.RegisterServices(services, context.Configuration);
}
// D-3: Fehler-Reporting - rein ereignisgesteuert (AppDomain.UnhandledException,
// TaskScheduler.UnobservedTaskException, TerminalLogger-Fehler), kein eigener Takt.
services.AddSingleton(sp => new DeploymentcenterErrorReporter(
sp.GetRequiredService(), sp.GetRequiredService()));
services.AddHostedService();
services.AddHostedService(sp => sp.GetRequiredService());
services.AddHostedService(sp => sp.GetRequiredService());
services.AddHostedService(sp => sp.GetRequiredService());
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
services.AddSingleton();
})
.ConfigurePolyTraderWeb(webOptions)
.Build();
}
///
/// Kopflose Konstruktionsprüfung aller registrierten Fenster – Nachfolger des
/// --smoke-ui-Laufs der WinForms-App, der Konstruktionsfehler zuverlässig gefangen hat.
/// Läuft ohne Fenster und ohne Message-Loop.
///
private static int RunSmokeUi(IHost host, TerminalLogger logger)
{
// Avalonia muss initialisiert sein, damit Fenster konstruiert werden koennen.
BuildAvaloniaApp().SetupWithoutStarting();
App.Services = host.Services;
ThemeManager.Initialize(host.Services.GetRequiredService(), ServerSettingsPath, logger);
var uiHost = host.Services.GetRequiredService();
CoreViews.Register(uiHost, host.Services);
foreach (var module in host.Services.GetServices())
module.RegisterUi(uiHost, host.Services);
ModuleViews.Register(uiHost, host.Services);
ViewIcons.AssignDefaults(uiHost);
int failures = 0;
Console.WriteLine("=== Smoke-UI: Seiten-Konstruktion (Avalonia) ===");
foreach (var view in uiHost.Views)
{
try
{
_ = view.CreateView();
Console.WriteLine($"[OK] {view.Id} ({view.Title})");
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}");
}
}
// Die Startseite steckt seit der Einfenster-Shell in der Schleife oben (core.start);
// separat geprueft wird nur noch das Shell-Fenster selbst.
try
{
_ = new Views.ShellWindow(uiHost, host.Services);
Console.WriteLine("[OK] ShellWindow konstruiert");
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] ShellWindow: {ex.GetType().Name}: {ex.Message}");
}
try
{
_ = new Views.ShutdownConfirmWindow();
Console.WriteLine("[OK] ShutdownConfirmWindow konstruiert");
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] ShutdownConfirmWindow: {ex.GetType().Name}: {ex.Message}");
}
// Der Einstellungs-Editor wird aus den Attributen des Modells aufgebaut. Ein Fenster kann
// fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen -
// deshalb hier gegen die tatsaechliche Feldzahl pruefen.
try
{
var sections = ViewModels.SettingsModelBuilder.Build(new ServerSettings());
int fieldCount = sections.Sum(x => x.Fields.Count);
if (sections.Count == 0 || fieldCount == 0)
{
failures++;
Console.WriteLine("[FEHLER] Einstellungs-Editor: keine Felder aus ServerSettings ermittelt " +
"(Category-/DisplayName-Attribute verloren?).");
}
else
{
Console.WriteLine($"[OK] Einstellungs-Editor: {sections.Count} Abschnitte, {fieldCount} Felder " +
$"({string.Join(", ", sections.Select(x => x.Title))})");
}
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] Einstellungs-Editor: {ex.GetType().Name}: {ex.Message}");
}
// Der Editor waehlt den Feldtyp nach dem Eigenschaftstyp. Fehlt ein Typ in dieser
// Zuordnung, faellt er still auf "nur Anzeige" zurueck - die Einstellung ist dann in
// der Oberflaeche nicht mehr aenderbar, ohne dass irgendwo ein Fehler auftaucht.
// Genau so waren saemtliche Dezimal-Einstellungen (Positionsgroessen, Preisgrenzen,
// Kill-Switch-Schwellen) unbemerkt zu reinen Anzeigen geworden. Deshalb hier gegen die
// Modelle mit den meisten Zahlenfeldern pruefen.
foreach (object model in new object[]
{
new PolyTraderSharp.Models.CopyTradingAccountSettings(),
new PolyTrader.Modules.ResolutionFarming.Models.RfSettings(),
new ServerSettings()
})
{
try
{
// Listen (z.B. "Deaktivierte Module") stehen bewusst nur zur Anzeige - die
// pflegt der Modul-Tab des Dashboards. Geprueft werden Einzelwerte.
var notEditable = ViewModels.SettingsModelBuilder.Build(model)
.SelectMany(sec => sec.Fields)
.Where(f => f.IsEditable
&& f is ViewModels.ReadOnlySettingsField
&& !typeof(System.Collections.IEnumerable).IsAssignableFrom(f.ValueType))
.Select(f => f.Label)
.ToList();
if (notEditable.Count > 0)
{
failures++;
Console.WriteLine($"[FEHLER] Einstellungs-Editor ({model.GetType().Name}): " +
$"{notEditable.Count} beschreibbare Felder werden nur angezeigt: " +
string.Join(", ", notEditable));
}
else
{
Console.WriteLine($"[OK] Einstellungs-Editor ({model.GetType().Name}): " +
"alle beschreibbaren Felder sind auch bearbeitbar");
}
}
catch (Exception ex)
{
failures++;
Console.WriteLine($"[FEHLER] Einstellungs-Editor ({model.GetType().Name}): {ex.GetType().Name}: {ex.Message}");
}
}
// Farbschema: jeder Token muss in BEIDEN Varianten aufloesen. Ein Tippfehler im
// Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau.
string[] themeKeys =
{
"AppBgBrush", "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush",
"AppBorderBrush", "AppDividerBrush",
"AppTextBrush", "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush",
"AppAccentBrush", "AppAccentAltBrush", "AppAccentStrongBrush", "AppAccentSoftBrush",
"AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush",
"AppNotifyWarnBrush", "AppNotifyErrorBrush",
"AppTradeLossBrush", "AppTradeSmallWinBrush", "AppTradeBigWinBrush",
"AppToggleActiveBrush", "AppToggleSellOnlyBrush", "AppToggleInactiveBrush",
"AppChatUserBrush", "AppChatAgentBrush"
};
foreach (var variant in new[] { global::Avalonia.Styling.ThemeVariant.Light,
global::Avalonia.Styling.ThemeVariant.Dark })
{
var missing = themeKeys.Where(k =>
!(global::Avalonia.Application.Current!.TryFindResource(k, variant, out object? v) && v is global::Avalonia.Media.IBrush))
.ToList();
if (missing.Count > 0)
{
failures++;
Console.WriteLine($"[FEHLER] Farbschema „{variant}“: fehlende Farben: {string.Join(", ", missing)}");
}
else
{
Console.WriteLine($"[OK] Farbschema „{variant}“: alle {themeKeys.Length} Farben vorhanden");
}
}
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
return failures == 0 ? 0 : 1;
}
///
/// HttpClient für die Polymarket-APIs: langlebiger Verbindungspool und die Header, die die
/// Gegenstelle erwartet (identisch zur bisherigen WinForms-Zusammenstellung).
///
private static System.Net.Http.HttpClient CreatePolymarketHttpClient() =>
new(new System.Net.Http.SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 100
})
{
DefaultRequestHeaders = { { "User-Agent", "py_clob_client" }, { "Accept", "*/*" } }
};
///
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte master.key, sonst wird
/// einer erzeugt – D-5/D-10) und aktiviert die at-rest-Verschlüsselung. Muss VOR jeder
/// Credential-Entschlüsselung laufen.
///
private static void ConfigureSecretProtection(TerminalLogger logger) =>
ConfigureSecretProtection(msg => logger.Warning(msg), msg => logger.Info(msg));
///
/// Wie oben, nur ohne : die Lizenz-CLI läuft VOR dem
/// Host-Aufbau und hat noch keinen. Bis zum 26.08.2026 fehlte dieser Aufruf dort ganz –
/// --license-set-key legte den Schlüssel deshalb im Klartext ab, obwohl der Code
/// aufrief: ohne Master-Key reicht der Aufruf den
/// Klartext unverändert durch (Befund B-12 des Linux-Sichttests).
///
internal static void ConfigureSecretProtection(Action warn, Action? info = null)
{
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
string? masterKey = MasterKeyResolver.Resolve(
keyFile,
() => Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"),
onGenerated: _ => warn(
"🔐 Kein Master-Key gefunden – ein neuer wurde erzeugt und in \"master.key\" " +
"gespeichert (App-Ordner, gitignored). WICHTIG: Diese Datei SOFORT separat sichern " +
"(Passwort-Manager/Offline-Backup) – ihr Verlust macht jedes ab jetzt verschlüsselte " +
"Secret unwiederbringlich unlesbar."),
onWarning: msg => warn($"⚠️ {msg}"));
SecretProtection.Configure(masterKey);
if (SecretProtection.IsConfigured)
info?.Invoke("🔐 Secret-Verschlüsselung aktiv – Account-Credentials werden at-rest verschlüsselt (AES-256-GCM).");
else
warn("⚠️ SICHERHEIT: Kein POLYTRADER_MASTER_KEY gesetzt – Account-Credentials liegen UNVERSCHLÜSSELT in der DB.");
}
}
}