From 3d45216ce5ce89cdeabf7c11b5ade495b7066a30 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 09:40:51 -0600 Subject: [PATCH] feat(api): onboarding API endpoints (MS22-P1e) --- apps/api/src/app.module.ts | 2 + .../src/onboarding/onboarding.controller.ts | 63 ++++++ apps/api/src/onboarding/onboarding.dto.ts | 71 ++++++ apps/api/src/onboarding/onboarding.guard.ts | 17 ++ apps/api/src/onboarding/onboarding.module.ts | 15 ++ .../src/onboarding/onboarding.service.spec.ts | 206 ++++++++++++++++++ apps/api/src/onboarding/onboarding.service.ts | 191 ++++++++++++++++ 7 files changed, 565 insertions(+) create mode 100644 apps/api/src/onboarding/onboarding.controller.ts create mode 100644 apps/api/src/onboarding/onboarding.dto.ts create mode 100644 apps/api/src/onboarding/onboarding.guard.ts create mode 100644 apps/api/src/onboarding/onboarding.module.ts create mode 100644 apps/api/src/onboarding/onboarding.service.spec.ts create mode 100644 apps/api/src/onboarding/onboarding.service.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 923e5f3..e58f32c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -54,6 +54,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce import { AgentConfigModule } from "./agent-config/agent-config.module"; import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module"; import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module"; +import { OnboardingModule } from "./onboarding/onboarding.module"; @Module({ imports: [ @@ -129,6 +130,7 @@ import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module"; AgentConfigModule, ContainerLifecycleModule, FleetSettingsModule, + OnboardingModule, ], controllers: [AppController, CsrfController], providers: [ diff --git a/apps/api/src/onboarding/onboarding.controller.ts b/apps/api/src/onboarding/onboarding.controller.ts new file mode 100644 index 0000000..e34e4fb --- /dev/null +++ b/apps/api/src/onboarding/onboarding.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from "@nestjs/common"; +import { + AddProviderDto, + ConfigureOidcDto, + CreateBreakglassDto, + TestProviderDto, +} from "./onboarding.dto"; +import { OnboardingGuard } from "./onboarding.guard"; +import { OnboardingService } from "./onboarding.service"; + +@Controller("onboarding") +export class OnboardingController { + constructor(private readonly onboardingService: OnboardingService) {} + + // GET /api/onboarding/status — returns { completed: boolean } + @Get("status") + async status(): Promise<{ completed: boolean }> { + return { + completed: await this.onboardingService.isCompleted(), + }; + } + + // POST /api/onboarding/breakglass — body: { username, password } → create admin + @Post("breakglass") + @UseGuards(OnboardingGuard) + async createBreakglass( + @Body() body: CreateBreakglassDto + ): Promise<{ id: string; username: string }> { + return this.onboardingService.createBreakglassUser(body.username, body.password); + } + + // POST /api/onboarding/oidc — body: { issuerUrl, clientId, clientSecret } → save OIDC + @Post("oidc") + @UseGuards(OnboardingGuard) + @HttpCode(HttpStatus.NO_CONTENT) + async configureOidc(@Body() body: ConfigureOidcDto): Promise { + await this.onboardingService.configureOidc(body.issuerUrl, body.clientId, body.clientSecret); + } + + // POST /api/onboarding/provider — body: { name, displayName, type, baseUrl?, apiKey?, models? } → add provider + @Post("provider") + @UseGuards(OnboardingGuard) + async addProvider(@Body() body: AddProviderDto): Promise<{ id: string }> { + const userId = await this.onboardingService.getBreakglassUserId(); + + return this.onboardingService.addProvider(userId, body); + } + + // POST /api/onboarding/provider/test — body: { type, baseUrl?, apiKey? } → test connection + @Post("provider/test") + @UseGuards(OnboardingGuard) + async testProvider(@Body() body: TestProviderDto): Promise<{ success: boolean; error?: string }> { + return this.onboardingService.testProvider(body.type, body.baseUrl, body.apiKey); + } + + // POST /api/onboarding/complete — mark done + @Post("complete") + @UseGuards(OnboardingGuard) + @HttpCode(HttpStatus.NO_CONTENT) + async complete(): Promise { + await this.onboardingService.complete(); + } +} diff --git a/apps/api/src/onboarding/onboarding.dto.ts b/apps/api/src/onboarding/onboarding.dto.ts new file mode 100644 index 0000000..60274dd --- /dev/null +++ b/apps/api/src/onboarding/onboarding.dto.ts @@ -0,0 +1,71 @@ +import { Type } from "class-transformer"; +import { IsArray, IsOptional, IsString, IsUrl, MinLength, ValidateNested } from "class-validator"; + +export class CreateBreakglassDto { + @IsString() + @MinLength(3) + username!: string; + + @IsString() + @MinLength(8) + password!: string; +} + +export class ConfigureOidcDto { + @IsString() + @IsUrl({ require_tld: false }) + issuerUrl!: string; + + @IsString() + clientId!: string; + + @IsString() + clientSecret!: string; +} + +export class ProviderModelDto { + @IsString() + id!: string; + + @IsOptional() + @IsString() + name?: string; +} + +export class AddProviderDto { + @IsString() + name!: string; + + @IsString() + displayName!: string; + + @IsString() + type!: string; + + @IsOptional() + @IsString() + baseUrl?: string; + + @IsOptional() + @IsString() + apiKey?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ProviderModelDto) + models?: ProviderModelDto[]; +} + +export class TestProviderDto { + @IsString() + type!: string; + + @IsOptional() + @IsString() + baseUrl?: string; + + @IsOptional() + @IsString() + apiKey?: string; +} diff --git a/apps/api/src/onboarding/onboarding.guard.ts b/apps/api/src/onboarding/onboarding.guard.ts new file mode 100644 index 0000000..4040652 --- /dev/null +++ b/apps/api/src/onboarding/onboarding.guard.ts @@ -0,0 +1,17 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"; +import { OnboardingService } from "./onboarding.service"; + +@Injectable() +export class OnboardingGuard implements CanActivate { + constructor(private readonly onboardingService: OnboardingService) {} + + async canActivate(_context: ExecutionContext): Promise { + const completed = await this.onboardingService.isCompleted(); + + if (completed) { + throw new ForbiddenException("Onboarding already completed"); + } + + return true; + } +} diff --git a/apps/api/src/onboarding/onboarding.module.ts b/apps/api/src/onboarding/onboarding.module.ts new file mode 100644 index 0000000..3c65a2f --- /dev/null +++ b/apps/api/src/onboarding/onboarding.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { PrismaModule } from "../prisma/prisma.module"; +import { CryptoModule } from "../crypto/crypto.module"; +import { OnboardingController } from "./onboarding.controller"; +import { OnboardingService } from "./onboarding.service"; +import { OnboardingGuard } from "./onboarding.guard"; + +@Module({ + imports: [PrismaModule, CryptoModule, ConfigModule], + controllers: [OnboardingController], + providers: [OnboardingService, OnboardingGuard], + exports: [OnboardingService], +}) +export class OnboardingModule {} diff --git a/apps/api/src/onboarding/onboarding.service.spec.ts b/apps/api/src/onboarding/onboarding.service.spec.ts new file mode 100644 index 0000000..8c2c37c --- /dev/null +++ b/apps/api/src/onboarding/onboarding.service.spec.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { hash } from "bcryptjs"; +import { OnboardingService } from "./onboarding.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { CryptoService } from "../crypto/crypto.service"; + +vi.mock("bcryptjs", () => ({ + hash: vi.fn(), +})); + +describe("OnboardingService", () => { + let service: OnboardingService; + + const mockPrismaService = { + systemConfig: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + breakglassUser: { + count: vi.fn(), + create: vi.fn(), + findFirst: vi.fn(), + }, + llmProvider: { + create: vi.fn(), + }, + }; + + const mockCryptoService = { + encrypt: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + + service = new OnboardingService( + mockPrismaService as unknown as PrismaService, + mockCryptoService as unknown as CryptoService + ); + }); + + it("isCompleted returns false when no config exists", async () => { + mockPrismaService.systemConfig.findUnique.mockResolvedValue(null); + + await expect(service.isCompleted()).resolves.toBe(false); + expect(mockPrismaService.systemConfig.findUnique).toHaveBeenCalledWith({ + where: { key: "onboarding.completed" }, + }); + }); + + it("isCompleted returns true when completed", async () => { + mockPrismaService.systemConfig.findUnique.mockResolvedValue({ + id: "cfg-1", + key: "onboarding.completed", + value: "true", + encrypted: false, + updatedAt: new Date(), + }); + + await expect(service.isCompleted()).resolves.toBe(true); + }); + + it("createBreakglassUser hashes password and creates record", async () => { + const mockedHash = vi.mocked(hash); + mockedHash.mockResolvedValue("hashed-password"); + + mockPrismaService.breakglassUser.count.mockResolvedValue(0); + mockPrismaService.breakglassUser.create.mockResolvedValue({ + id: "breakglass-1", + username: "admin", + }); + + const result = await service.createBreakglassUser("admin", "supersecret123"); + + expect(mockedHash).toHaveBeenCalledWith("supersecret123", 12); + expect(mockPrismaService.breakglassUser.create).toHaveBeenCalledWith({ + data: { + username: "admin", + passwordHash: "hashed-password", + }, + select: { + id: true, + username: true, + }, + }); + expect(result).toEqual({ id: "breakglass-1", username: "admin" }); + }); + + it("createBreakglassUser rejects if user already exists", async () => { + mockPrismaService.breakglassUser.count.mockResolvedValue(1); + + await expect(service.createBreakglassUser("admin", "supersecret123")).rejects.toThrow( + "Breakglass user already exists" + ); + }); + + it("configureOidc encrypts secret and saves to SystemConfig", async () => { + mockCryptoService.encrypt.mockReturnValue("enc:oidc-secret"); + mockPrismaService.systemConfig.upsert.mockResolvedValue({ + id: "cfg", + key: "oidc.clientSecret", + value: "enc:oidc-secret", + encrypted: true, + updatedAt: new Date(), + }); + + await service.configureOidc("https://auth.example.com", "client-id", "client-secret"); + + expect(mockCryptoService.encrypt).toHaveBeenCalledWith("client-secret"); + expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledTimes(3); + expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({ + where: { key: "oidc.issuerUrl" }, + create: { + key: "oidc.issuerUrl", + value: "https://auth.example.com", + encrypted: false, + }, + update: { + value: "https://auth.example.com", + encrypted: false, + }, + }); + expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({ + where: { key: "oidc.clientId" }, + create: { + key: "oidc.clientId", + value: "client-id", + encrypted: false, + }, + update: { + value: "client-id", + encrypted: false, + }, + }); + expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({ + where: { key: "oidc.clientSecret" }, + create: { + key: "oidc.clientSecret", + value: "enc:oidc-secret", + encrypted: true, + }, + update: { + value: "enc:oidc-secret", + encrypted: true, + }, + }); + }); + + it("addProvider encrypts apiKey and creates LlmProvider", async () => { + mockCryptoService.encrypt.mockReturnValue("enc:api-key"); + mockPrismaService.llmProvider.create.mockResolvedValue({ + id: "provider-1", + }); + + const result = await service.addProvider("breakglass-1", { + name: "my-openai", + displayName: "OpenAI", + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-test", + models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }], + }); + + expect(mockCryptoService.encrypt).toHaveBeenCalledWith("sk-test"); + expect(mockPrismaService.llmProvider.create).toHaveBeenCalledWith({ + data: { + userId: "breakglass-1", + name: "my-openai", + displayName: "OpenAI", + type: "openai", + baseUrl: "https://api.openai.com/v1", + apiKey: "enc:api-key", + models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }], + }, + select: { + id: true, + }, + }); + expect(result).toEqual({ id: "provider-1" }); + }); + + it("complete sets SystemConfig flag", async () => { + mockPrismaService.systemConfig.upsert.mockResolvedValue({ + id: "cfg-1", + key: "onboarding.completed", + value: "true", + encrypted: false, + updatedAt: new Date(), + }); + + await service.complete(); + + expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({ + where: { key: "onboarding.completed" }, + create: { + key: "onboarding.completed", + value: "true", + encrypted: false, + }, + update: { + value: "true", + encrypted: false, + }, + }); + }); +}); diff --git a/apps/api/src/onboarding/onboarding.service.ts b/apps/api/src/onboarding/onboarding.service.ts new file mode 100644 index 0000000..e6906a0 --- /dev/null +++ b/apps/api/src/onboarding/onboarding.service.ts @@ -0,0 +1,191 @@ +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"; + } +} -- 2.49.1