From cbb0dc8affdd341c811770d484e054e7ee6dba1b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 09:35:57 -0600 Subject: [PATCH] feat(api): fleet settings CRUD API (MS22-P1g) --- apps/api/src/app.module.ts | 2 + .../fleet-settings.controller.ts | 115 +++++++ .../src/fleet-settings/fleet-settings.dto.ts | 122 ++++++++ .../fleet-settings/fleet-settings.module.ts | 13 + .../fleet-settings.service.spec.ts | 200 ++++++++++++ .../fleet-settings/fleet-settings.service.ts | 296 ++++++++++++++++++ 6 files changed, 748 insertions(+) create mode 100644 apps/api/src/fleet-settings/fleet-settings.controller.ts create mode 100644 apps/api/src/fleet-settings/fleet-settings.dto.ts create mode 100644 apps/api/src/fleet-settings/fleet-settings.module.ts create mode 100644 apps/api/src/fleet-settings/fleet-settings.service.spec.ts create mode 100644 apps/api/src/fleet-settings/fleet-settings.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 660497c..923e5f3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -53,6 +53,7 @@ import { ConversationArchiveModule } from "./conversation-archive/conversation-a import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; import { AgentConfigModule } from "./agent-config/agent-config.module"; import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module"; +import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module"; @Module({ imports: [ @@ -127,6 +128,7 @@ import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecy ConversationArchiveModule, AgentConfigModule, ContainerLifecycleModule, + FleetSettingsModule, ], controllers: [AppController, CsrfController], providers: [ diff --git a/apps/api/src/fleet-settings/fleet-settings.controller.ts b/apps/api/src/fleet-settings/fleet-settings.controller.ts new file mode 100644 index 0000000..6ffc000 --- /dev/null +++ b/apps/api/src/fleet-settings/fleet-settings.controller.ts @@ -0,0 +1,115 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + UseGuards, +} from "@nestjs/common"; +import type { AuthUser } from "@mosaic/shared"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import { AdminGuard } from "../auth/guards/admin.guard"; +import { AuthGuard } from "../auth/guards/auth.guard"; +import type { + CreateProviderDto, + ResetPasswordDto, + UpdateAgentConfigDto, + UpdateOidcDto, + UpdateProviderDto, +} from "./fleet-settings.dto"; +import { FleetSettingsService } from "./fleet-settings.service"; + +@Controller("fleet-settings") +@UseGuards(AuthGuard) +export class FleetSettingsController { + constructor(private readonly fleetSettingsService: FleetSettingsService) {} + + // --- Provider endpoints (user-scoped) --- + // GET /api/fleet-settings/providers — list user's providers + @Get("providers") + async listProviders(@CurrentUser() user: AuthUser) { + return this.fleetSettingsService.listProviders(user.id); + } + + // GET /api/fleet-settings/providers/:id — get single provider + @Get("providers/:id") + async getProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) { + return this.fleetSettingsService.getProvider(user.id, id); + } + + // POST /api/fleet-settings/providers — create provider + @Post("providers") + async createProvider(@CurrentUser() user: AuthUser, @Body() dto: CreateProviderDto) { + return this.fleetSettingsService.createProvider(user.id, dto); + } + + // PATCH /api/fleet-settings/providers/:id — update provider + @Patch("providers/:id") + @HttpCode(HttpStatus.NO_CONTENT) + async updateProvider( + @CurrentUser() user: AuthUser, + @Param("id") id: string, + @Body() dto: UpdateProviderDto + ) { + await this.fleetSettingsService.updateProvider(user.id, id, dto); + } + + // DELETE /api/fleet-settings/providers/:id — delete provider + @Delete("providers/:id") + @HttpCode(HttpStatus.NO_CONTENT) + async deleteProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) { + await this.fleetSettingsService.deleteProvider(user.id, id); + } + + // --- Agent config endpoints (user-scoped) --- + // GET /api/fleet-settings/agent-config — get user's agent config + @Get("agent-config") + async getAgentConfig(@CurrentUser() user: AuthUser) { + return this.fleetSettingsService.getAgentConfig(user.id); + } + + // PATCH /api/fleet-settings/agent-config — update user's agent config + @Patch("agent-config") + @HttpCode(HttpStatus.NO_CONTENT) + async updateAgentConfig(@CurrentUser() user: AuthUser, @Body() dto: UpdateAgentConfigDto) { + await this.fleetSettingsService.updateAgentConfig(user.id, dto); + } + + // --- OIDC endpoints (admin only — use AdminGuard) --- + // GET /api/fleet-settings/oidc — get OIDC config + @Get("oidc") + @UseGuards(AdminGuard) + async getOidcConfig() { + return this.fleetSettingsService.getOidcConfig(); + } + + // PUT /api/fleet-settings/oidc — update OIDC config + @Put("oidc") + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.NO_CONTENT) + async updateOidcConfig(@Body() dto: UpdateOidcDto) { + await this.fleetSettingsService.updateOidcConfig(dto); + } + + // DELETE /api/fleet-settings/oidc — remove OIDC config + @Delete("oidc") + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.NO_CONTENT) + async deleteOidcConfig() { + await this.fleetSettingsService.deleteOidcConfig(); + } + + // --- Breakglass endpoints (admin only) --- + // POST /api/fleet-settings/breakglass/reset-password — reset admin password + @Post("breakglass/reset-password") + @UseGuards(AdminGuard) + @HttpCode(HttpStatus.NO_CONTENT) + async resetBreakglassPassword(@Body() dto: ResetPasswordDto) { + await this.fleetSettingsService.resetBreakglassPassword(dto.username, dto.newPassword); + } +} diff --git a/apps/api/src/fleet-settings/fleet-settings.dto.ts b/apps/api/src/fleet-settings/fleet-settings.dto.ts new file mode 100644 index 0000000..a1eb29c --- /dev/null +++ b/apps/api/src/fleet-settings/fleet-settings.dto.ts @@ -0,0 +1,122 @@ +import { + ArrayNotEmpty, + IsArray, + IsBoolean, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUrl, + MaxLength, + MinLength, +} from "class-validator"; + +export class CreateProviderDto { + @IsString({ message: "name must be a string" }) + @IsNotEmpty({ message: "name is required" }) + @MaxLength(100, { message: "name must not exceed 100 characters" }) + name!: string; + + @IsString({ message: "displayName must be a string" }) + @IsNotEmpty({ message: "displayName is required" }) + @MaxLength(255, { message: "displayName must not exceed 255 characters" }) + displayName!: string; + + @IsString({ message: "type must be a string" }) + @IsNotEmpty({ message: "type is required" }) + @MaxLength(100, { message: "type must not exceed 100 characters" }) + type!: string; + + @IsOptional() + @IsUrl( + { require_tld: false }, + { message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" } + ) + baseUrl?: string; + + @IsOptional() + @IsString({ message: "apiKey must be a string" }) + apiKey?: string; + + @IsOptional() + @IsString({ message: "apiType must be a string" }) + @MaxLength(100, { message: "apiType must not exceed 100 characters" }) + apiType?: string; + + @IsOptional() + @IsArray({ message: "models must be an array" }) + @IsObject({ each: true, message: "each model must be an object" }) + models?: Record[]; +} + +export class UpdateProviderDto { + @IsOptional() + @IsString({ message: "displayName must be a string" }) + @MaxLength(255, { message: "displayName must not exceed 255 characters" }) + displayName?: string; + + @IsOptional() + @IsUrl( + { require_tld: false }, + { message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" } + ) + baseUrl?: string; + + @IsOptional() + @IsString({ message: "apiKey must be a string" }) + apiKey?: string; + + @IsOptional() + @IsBoolean({ message: "isActive must be a boolean" }) + isActive?: boolean; + + @IsOptional() + @IsArray({ message: "models must be an array" }) + @IsObject({ each: true, message: "each model must be an object" }) + models?: Record[]; +} + +export class UpdateAgentConfigDto { + @IsOptional() + @IsString({ message: "primaryModel must be a string" }) + @MaxLength(255, { message: "primaryModel must not exceed 255 characters" }) + primaryModel?: string; + + @IsOptional() + @IsArray({ message: "fallbackModels must be an array" }) + @ArrayNotEmpty({ message: "fallbackModels cannot be empty" }) + @IsString({ each: true, message: "each fallback model must be a string" }) + fallbackModels?: string[]; + + @IsOptional() + @IsString({ message: "personality must be a string" }) + personality?: string; +} + +export class UpdateOidcDto { + @IsString({ message: "issuerUrl must be a string" }) + @IsNotEmpty({ message: "issuerUrl is required" }) + @IsUrl( + { require_tld: false }, + { message: "issuerUrl must be a valid URL (for example: https://issuer.example.com)" } + ) + issuerUrl!: string; + + @IsString({ message: "clientId must be a string" }) + @IsNotEmpty({ message: "clientId is required" }) + clientId!: string; + + @IsString({ message: "clientSecret must be a string" }) + @IsNotEmpty({ message: "clientSecret is required" }) + clientSecret!: string; +} + +export class ResetPasswordDto { + @IsString({ message: "username must be a string" }) + @IsNotEmpty({ message: "username is required" }) + username!: string; + + @IsString({ message: "newPassword must be a string" }) + @MinLength(8, { message: "newPassword must be at least 8 characters" }) + newPassword!: string; +} diff --git a/apps/api/src/fleet-settings/fleet-settings.module.ts b/apps/api/src/fleet-settings/fleet-settings.module.ts new file mode 100644 index 0000000..d46cde6 --- /dev/null +++ b/apps/api/src/fleet-settings/fleet-settings.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module"; +import { CryptoModule } from "../crypto/crypto.module"; +import { FleetSettingsController } from "./fleet-settings.controller"; +import { FleetSettingsService } from "./fleet-settings.service"; + +@Module({ + imports: [PrismaModule, CryptoModule], + controllers: [FleetSettingsController], + providers: [FleetSettingsService], + exports: [FleetSettingsService], +}) +export class FleetSettingsModule {} diff --git a/apps/api/src/fleet-settings/fleet-settings.service.spec.ts b/apps/api/src/fleet-settings/fleet-settings.service.spec.ts new file mode 100644 index 0000000..a7d4f10 --- /dev/null +++ b/apps/api/src/fleet-settings/fleet-settings.service.spec.ts @@ -0,0 +1,200 @@ +import { NotFoundException } from "@nestjs/common"; +import { compare } from "bcryptjs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FleetSettingsService } from "./fleet-settings.service"; +import type { PrismaService } from "../prisma/prisma.service"; +import type { CryptoService } from "../crypto/crypto.service"; + +describe("FleetSettingsService", () => { + let service: FleetSettingsService; + + const mockPrisma = { + llmProvider: { + findMany: vi.fn(), + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + userAgentConfig: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + systemConfig: { + findMany: vi.fn(), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + breakglassUser: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }; + + const mockCrypto = { + encrypt: vi.fn((value: string) => `enc:${value}`), + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = new FleetSettingsService( + mockPrisma as unknown as PrismaService, + mockCrypto as unknown as CryptoService + ); + }); + + it("listProviders returns only providers for the given userId", async () => { + mockPrisma.llmProvider.findMany.mockResolvedValue([ + { + id: "prov-1", + name: "openai-main", + displayName: "OpenAI", + type: "openai", + baseUrl: "https://api.openai.com/v1", + isActive: true, + models: [{ id: "gpt-4.1" }], + }, + ]); + + const result = await service.listProviders("user-1"); + + expect(mockPrisma.llmProvider.findMany).toHaveBeenCalledWith({ + where: { userId: "user-1" }, + select: { + id: true, + name: true, + displayName: true, + type: true, + baseUrl: true, + isActive: true, + models: true, + }, + orderBy: { createdAt: "asc" }, + }); + expect(result).toEqual([ + { + id: "prov-1", + name: "openai-main", + displayName: "OpenAI", + type: "openai", + baseUrl: "https://api.openai.com/v1", + isActive: true, + models: [{ id: "gpt-4.1" }], + }, + ]); + }); + + it("createProvider encrypts apiKey", async () => { + mockPrisma.llmProvider.create.mockResolvedValue({ + id: "prov-2", + }); + + const result = await service.createProvider("user-1", { + name: "zai-main", + displayName: "Z.ai", + type: "zai", + apiKey: "plaintext-key", + models: [], + }); + + expect(mockCrypto.encrypt).toHaveBeenCalledWith("plaintext-key"); + expect(mockPrisma.llmProvider.create).toHaveBeenCalledWith({ + data: { + userId: "user-1", + name: "zai-main", + displayName: "Z.ai", + type: "zai", + baseUrl: null, + apiKey: "enc:plaintext-key", + apiType: "openai-completions", + models: [], + }, + select: { + id: true, + }, + }); + expect(result).toEqual({ id: "prov-2" }); + }); + + it("updateProvider rejects if not owned by user", async () => { + mockPrisma.llmProvider.findFirst.mockResolvedValue(null); + + await expect( + service.updateProvider("user-1", "provider-1", { + displayName: "New Name", + }) + ).rejects.toBeInstanceOf(NotFoundException); + + expect(mockPrisma.llmProvider.update).not.toHaveBeenCalled(); + }); + + it("deleteProvider rejects if not owned by user", async () => { + mockPrisma.llmProvider.findFirst.mockResolvedValue(null); + + await expect(service.deleteProvider("user-1", "provider-1")).rejects.toBeInstanceOf( + NotFoundException + ); + + expect(mockPrisma.llmProvider.delete).not.toHaveBeenCalled(); + }); + + it("getOidcConfig never returns clientSecret", async () => { + mockPrisma.systemConfig.findMany.mockResolvedValue([ + { + key: "oidc.issuerUrl", + value: "https://issuer.example.com", + }, + { + key: "oidc.clientId", + value: "client-id-1", + }, + { + key: "oidc.clientSecret", + value: "enc:very-secret", + }, + ]); + + const result = await service.getOidcConfig(); + + expect(result).toEqual({ + issuerUrl: "https://issuer.example.com", + clientId: "client-id-1", + configured: true, + }); + expect(result).not.toHaveProperty("clientSecret"); + }); + + it("updateOidcConfig encrypts clientSecret", async () => { + await service.updateOidcConfig({ + issuerUrl: "https://issuer.example.com", + clientId: "client-id-1", + clientSecret: "super-secret", + }); + + expect(mockCrypto.encrypt).toHaveBeenCalledWith("super-secret"); + expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledTimes(3); + expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledWith({ + where: { key: "oidc.clientSecret" }, + update: { value: "enc:super-secret", encrypted: true }, + create: { key: "oidc.clientSecret", value: "enc:super-secret", encrypted: true }, + }); + }); + + it("resetBreakglassPassword hashes new password", async () => { + mockPrisma.breakglassUser.findUnique.mockResolvedValue({ + id: "bg-1", + username: "admin", + passwordHash: "old-hash", + }); + + await service.resetBreakglassPassword("admin", "new-password-123"); + + expect(mockPrisma.breakglassUser.update).toHaveBeenCalledOnce(); + const updateCall = mockPrisma.breakglassUser.update.mock.calls[0]?.[0]; + const newHash = updateCall?.data?.passwordHash; + expect(newHash).toBeTypeOf("string"); + expect(newHash).not.toBe("new-password-123"); + expect(await compare("new-password-123", newHash as string)).toBe(true); + }); +}); diff --git a/apps/api/src/fleet-settings/fleet-settings.service.ts b/apps/api/src/fleet-settings/fleet-settings.service.ts new file mode 100644 index 0000000..0244ebd --- /dev/null +++ b/apps/api/src/fleet-settings/fleet-settings.service.ts @@ -0,0 +1,296 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { hash } from "bcryptjs"; +import type { Prisma } from "@prisma/client"; +import { PrismaService } from "../prisma/prisma.service"; +import { CryptoService } from "../crypto/crypto.service"; +import type { + CreateProviderDto, + ResetPasswordDto, + UpdateAgentConfigDto, + UpdateOidcDto, + UpdateProviderDto, +} from "./fleet-settings.dto"; + +const BCRYPT_ROUNDS = 12; +const DEFAULT_PROVIDER_API_TYPE = "openai-completions"; +const OIDC_ISSUER_KEY = "oidc.issuerUrl"; +const OIDC_CLIENT_ID_KEY = "oidc.clientId"; +const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret"; +const OIDC_KEYS = [OIDC_ISSUER_KEY, OIDC_CLIENT_ID_KEY, OIDC_CLIENT_SECRET_KEY] as const; + +export interface FleetProviderResponse { + id: string; + name: string; + displayName: string; + type: string; + baseUrl: string | null; + isActive: boolean; + models: unknown; +} + +export interface FleetAgentConfigResponse { + primaryModel: string | null; + fallbackModels: unknown[]; + personality: string | null; +} + +export interface OidcConfigResponse { + issuerUrl?: string; + clientId?: string; + configured: boolean; +} + +@Injectable() +export class FleetSettingsService { + constructor( + private readonly prisma: PrismaService, + private readonly crypto: CryptoService + ) {} + + // --- LLM Provider CRUD (per-user scoped) --- + + async listProviders(userId: string): Promise { + return this.prisma.llmProvider.findMany({ + where: { userId }, + select: { + id: true, + name: true, + displayName: true, + type: true, + baseUrl: true, + isActive: true, + models: true, + }, + orderBy: { createdAt: "asc" }, + }); + } + + async getProvider(userId: string, providerId: string): Promise { + const provider = await this.prisma.llmProvider.findFirst({ + where: { + id: providerId, + userId, + }, + select: { + id: true, + name: true, + displayName: true, + type: true, + baseUrl: true, + isActive: true, + models: true, + }, + }); + + if (!provider) { + throw new NotFoundException(`Provider ${providerId} not found`); + } + + return provider; + } + + async createProvider(userId: string, data: CreateProviderDto): Promise<{ id: string }> { + const provider = await this.prisma.llmProvider.create({ + data: { + userId, + name: data.name, + displayName: data.displayName, + type: data.type, + baseUrl: data.baseUrl ?? null, + apiKey: data.apiKey ? this.crypto.encrypt(data.apiKey) : null, + apiType: data.apiType ?? DEFAULT_PROVIDER_API_TYPE, + models: (data.models ?? []) as Prisma.InputJsonValue, + }, + select: { + id: true, + }, + }); + + return provider; + } + + async updateProvider(userId: string, providerId: string, data: UpdateProviderDto): Promise { + await this.assertProviderOwnership(userId, providerId); + + const updateData: Prisma.LlmProviderUpdateInput = {}; + if (data.displayName !== undefined) { + updateData.displayName = data.displayName; + } + if (data.baseUrl !== undefined) { + updateData.baseUrl = data.baseUrl; + } + if (data.isActive !== undefined) { + updateData.isActive = data.isActive; + } + if (data.models !== undefined) { + updateData.models = data.models as Prisma.InputJsonValue; + } + if (data.apiKey !== undefined) { + updateData.apiKey = data.apiKey.length > 0 ? this.crypto.encrypt(data.apiKey) : null; + } + + await this.prisma.llmProvider.update({ + where: { id: providerId }, + data: updateData, + }); + } + + async deleteProvider(userId: string, providerId: string): Promise { + await this.assertProviderOwnership(userId, providerId); + + await this.prisma.llmProvider.delete({ + where: { id: providerId }, + }); + } + + // --- User Agent Config --- + + async getAgentConfig(userId: string): Promise { + const config = await this.prisma.userAgentConfig.findUnique({ + where: { userId }, + select: { + primaryModel: true, + fallbackModels: true, + personality: true, + }, + }); + + if (!config) { + return { + primaryModel: null, + fallbackModels: [], + personality: null, + }; + } + + return { + primaryModel: config.primaryModel, + fallbackModels: this.normalizeJsonArray(config.fallbackModels), + personality: config.personality, + }; + } + + async updateAgentConfig(userId: string, data: UpdateAgentConfigDto): Promise { + const updateData: Prisma.UserAgentConfigUpdateInput = {}; + if (data.primaryModel !== undefined) { + updateData.primaryModel = data.primaryModel; + } + if (data.personality !== undefined) { + updateData.personality = data.personality; + } + if (data.fallbackModels !== undefined) { + updateData.fallbackModels = data.fallbackModels as Prisma.InputJsonValue; + } + + const createData: Prisma.UserAgentConfigCreateInput = { + userId, + fallbackModels: (data.fallbackModels ?? []) as Prisma.InputJsonValue, + ...(data.primaryModel !== undefined ? { primaryModel: data.primaryModel } : {}), + ...(data.personality !== undefined ? { personality: data.personality } : {}), + }; + + await this.prisma.userAgentConfig.upsert({ + where: { userId }, + create: createData, + update: updateData, + }); + } + + // --- OIDC Config (admin only) --- + + async getOidcConfig(): Promise { + const entries = await this.prisma.systemConfig.findMany({ + where: { + key: { + in: [...OIDC_KEYS], + }, + }, + select: { + key: true, + value: true, + }, + }); + + const byKey = new Map(entries.map((entry) => [entry.key, entry.value])); + const issuerUrl = byKey.get(OIDC_ISSUER_KEY); + const clientId = byKey.get(OIDC_CLIENT_ID_KEY); + const hasSecret = byKey.has(OIDC_CLIENT_SECRET_KEY); + + return { + ...(issuerUrl ? { issuerUrl } : {}), + ...(clientId ? { clientId } : {}), + configured: Boolean(issuerUrl && clientId && hasSecret), + }; + } + + async updateOidcConfig(data: UpdateOidcDto): Promise { + const encryptedSecret = this.crypto.encrypt(data.clientSecret); + + await Promise.all([ + this.upsertSystemConfig(OIDC_ISSUER_KEY, data.issuerUrl, false), + this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, data.clientId, false), + this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true), + ]); + } + + async deleteOidcConfig(): Promise { + await this.prisma.systemConfig.deleteMany({ + where: { + key: { + in: [...OIDC_KEYS], + }, + }, + }); + } + + // --- Breakglass (admin only) --- + + async resetBreakglassPassword( + username: ResetPasswordDto["username"], + newPassword: ResetPasswordDto["newPassword"] + ): Promise { + const user = await this.prisma.breakglassUser.findUnique({ + where: { username }, + select: { id: true }, + }); + + if (!user) { + throw new NotFoundException(`Breakglass user ${username} not found`); + } + + const passwordHash = await hash(newPassword, BCRYPT_ROUNDS); + + await this.prisma.breakglassUser.update({ + where: { id: user.id }, + data: { passwordHash }, + }); + } + + private async assertProviderOwnership(userId: string, providerId: string): Promise { + const provider = await this.prisma.llmProvider.findFirst({ + where: { + id: providerId, + userId, + }, + select: { + id: true, + }, + }); + + if (!provider) { + throw new NotFoundException(`Provider ${providerId} not found`); + } + } + + private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise { + await this.prisma.systemConfig.upsert({ + where: { key }, + update: { value, encrypted }, + create: { key, value, encrypted }, + }); + } + + private normalizeJsonArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; + } +}