import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; import type { InputJsonValue } from "@prisma/client/runtime/library"; import { hash } from "bcryptjs"; import { PrismaService } from "../prisma/prisma.service"; import { CryptoService } from "../crypto/crypto.service"; const BCRYPT_ROUNDS = 12; const TEST_PROVIDER_TIMEOUT_MS = 8000; const ONBOARDING_COMPLETED_KEY = "onboarding.completed"; const OIDC_ISSUER_URL_KEY = "oidc.issuerUrl"; const OIDC_CLIENT_ID_KEY = "oidc.clientId"; const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret"; interface ProviderModelInput { id: string; name?: string; } interface AddProviderInput { name: string; displayName: string; type: string; baseUrl?: string; apiKey?: string; models?: ProviderModelInput[]; } @Injectable() export class OnboardingService { constructor( private readonly prisma: PrismaService, private readonly crypto: CryptoService ) {} // Check if onboarding is completed async isCompleted(): Promise { const completedFlag = await this.prisma.systemConfig.findUnique({ where: { key: ONBOARDING_COMPLETED_KEY }, }); return completedFlag?.value === "true"; } // Step 1: Create breakglass admin user async createBreakglassUser( username: string, password: string ): Promise<{ id: string; username: string }> { const breakglassCount = await this.prisma.breakglassUser.count(); if (breakglassCount > 0) { throw new ConflictException("Breakglass user already exists"); } const passwordHash = await hash(password, BCRYPT_ROUNDS); return this.prisma.breakglassUser.create({ data: { username, passwordHash, }, select: { id: true, username: true, }, }); } // Step 2: Configure OIDC provider (optional) async configureOidc(issuerUrl: string, clientId: string, clientSecret: string): Promise { const encryptedSecret = this.crypto.encrypt(clientSecret); await Promise.all([ this.upsertSystemConfig(OIDC_ISSUER_URL_KEY, issuerUrl, false), this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, clientId, false), this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true), ]); } // Step 3: Add first LLM provider async addProvider(userId: string, data: AddProviderInput): Promise<{ id: string }> { const encryptedApiKey = data.apiKey ? this.crypto.encrypt(data.apiKey) : undefined; return this.prisma.llmProvider.create({ data: { userId, name: data.name, displayName: data.displayName, type: data.type, baseUrl: data.baseUrl ?? null, apiKey: encryptedApiKey ?? null, models: (data.models ?? []) as unknown as InputJsonValue, }, select: { id: true, }, }); } // Step 3b: Test LLM provider connection async testProvider( type: string, baseUrl?: string, apiKey?: string ): Promise<{ success: boolean; error?: string }> { const normalizedType = type.trim().toLowerCase(); if (!normalizedType) { return { success: false, error: "Provider type is required" }; } let probeUrl: string; try { probeUrl = this.buildProbeUrl(normalizedType, baseUrl); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return { success: false, error: message }; } const headers: Record = { Accept: "application/json", }; if (apiKey) { headers.Authorization = `Bearer ${apiKey}`; } try { const response = await fetch(probeUrl, { method: "GET", headers, signal: AbortSignal.timeout(TEST_PROVIDER_TIMEOUT_MS), }); if (!response.ok) { return { success: false, error: `Provider returned ${String(response.status)} ${response.statusText}`.trim(), }; } return { success: true }; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); return { success: false, error: message }; } } // Step 4: Mark onboarding complete async complete(): Promise { await this.upsertSystemConfig(ONBOARDING_COMPLETED_KEY, "true", false); } async getBreakglassUserId(): Promise { const user = await this.prisma.breakglassUser.findFirst({ where: { isActive: true }, orderBy: { createdAt: "asc" }, select: { id: true }, }); if (!user) { throw new BadRequestException("Create a breakglass user before adding a provider"); } return user.id; } private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise { await this.prisma.systemConfig.upsert({ where: { key }, create: { key, value, encrypted }, update: { value, encrypted }, }); } private buildProbeUrl(type: string, baseUrl?: string): string { const resolvedBaseUrl = baseUrl ?? this.getDefaultProviderBaseUrl(type); const normalizedBaseUrl = resolvedBaseUrl.endsWith("/") ? resolvedBaseUrl : `${resolvedBaseUrl}/`; const endpointPath = type === "ollama" ? "api/tags" : "models"; return new URL(endpointPath, normalizedBaseUrl).toString(); } private getDefaultProviderBaseUrl(type: string): string { if (type === "ollama") { return "http://localhost:11434"; } return "https://api.openai.com/v1"; } }