All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
192 lines
5.4 KiB
TypeScript
192 lines
5.4 KiB
TypeScript
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<boolean> {
|
|
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<void> {
|
|
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<string, string> = {
|
|
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<void> {
|
|
await this.upsertSystemConfig(ONBOARDING_COMPLETED_KEY, "true", false);
|
|
}
|
|
|
|
async getBreakglassUserId(): Promise<string> {
|
|
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<void> {
|
|
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";
|
|
}
|
|
}
|