Compare commits

...

4 Commits

Author SHA1 Message Date
e85df938a5 fix(web): correct Add Provider form field mapping to match API DTO
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-01 13:18:19 -06:00
99a4567e32 fix(api): skip CSRF for Bearer-authenticated API clients (#622)
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>
2026-03-01 19:06:14 +00:00
559c6b3831 fix(api): add AuthModule to FleetSettingsModule and ChatProxyModule (#621)
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>
2026-03-01 18:06:49 +00:00
631e5010b5 fix(api): add ConfigModule to ContainerLifecycleModule imports (#620)
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>
2026-03-01 17:52:10 +00:00
8 changed files with 72 additions and 40 deletions

View File

@@ -1,4 +1,5 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { AgentConfigModule } from "../agent-config/agent-config.module";
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
import { PrismaModule } from "../prisma/prisma.module";
@@ -6,7 +7,7 @@ import { ChatProxyController } from "./chat-proxy.controller";
import { ChatProxyService } from "./chat-proxy.service";
@Module({
imports: [PrismaModule, ContainerLifecycleModule, AgentConfigModule],
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule],
controllers: [ChatProxyController],
providers: [ChatProxyService],
exports: [ChatProxyService],

View File

@@ -87,6 +87,17 @@ describe("CsrfGuard", () => {
});
describe("State-changing methods requiring CSRF", () => {
it("should allow POST with Bearer auth without CSRF token", () => {
const context = createContext(
"POST",
{},
{ authorization: "Bearer api-token" },
false,
"user-123"
);
expect(guard.canActivate(context)).toBe(true);
});
it("should reject POST without CSRF token", () => {
const context = createContext("POST", {}, {}, false, "user-123");
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);

View File

@@ -57,6 +57,11 @@ export class CsrfGuard implements CanActivate {
return true;
}
const authHeader = request.headers.authorization;
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
return true;
}
// Get CSRF token from cookie and header
const cookies = request.cookies as Record<string, string> | undefined;
const cookieToken = cookies?.["csrf-token"];

View File

@@ -1,10 +1,11 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { ContainerLifecycleService } from "./container-lifecycle.service";
@Module({
imports: [PrismaModule, CryptoModule],
imports: [ConfigModule, PrismaModule, CryptoModule],
providers: [ContainerLifecycleService],
exports: [ContainerLifecycleService],
})

View File

@@ -1,11 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
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],
imports: [AuthModule, PrismaModule, CryptoModule],
controllers: [FleetSettingsController],
providers: [FleetSettingsService],
exports: [FleetSettingsService],

View File

@@ -85,6 +85,14 @@ const INITIAL_FORM: ProviderFormState = {
isActive: true,
};
function mapProviderTypeToApi(type: string): "ollama" | "openai" | "claude" {
if (type === "ollama" || type === "claude") {
return type;
}
return "openai";
}
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
@@ -93,18 +101,6 @@ function getErrorMessage(error: unknown, fallback: string): string {
return fallback;
}
function buildProviderName(displayName: string, type: string): string {
const slug = displayName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
const candidate = `${type}-${slug.length > 0 ? slug : "provider"}`;
return candidate.slice(0, 100);
}
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
if (!Array.isArray(models)) {
return [];
@@ -153,11 +149,11 @@ function modelsToEditorText(models: unknown): string {
.join("\n");
}
function parseModelsText(value: string): FleetProviderModel[] {
function parseModelsText(value: string): string[] {
const seen = new Set<string>();
return value
.split(/\n|,/g)
.split(/\r?\n/g)
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0)
.filter((segment) => {
@@ -166,8 +162,7 @@ function parseModelsText(value: string): FleetProviderModel[] {
}
seen.add(segment);
return true;
})
.map((id) => ({ id, name: id }));
});
}
function maskApiKey(value: string): string {
@@ -279,6 +274,7 @@ export default function ProvidersSettingsPage(): ReactElement {
}
const models = parseModelsText(form.modelsText);
const providerModels = models.map((id) => ({ id, name: id }));
const baseUrl = form.baseUrl.trim();
const apiKey = form.apiKey.trim();
@@ -289,7 +285,7 @@ export default function ProvidersSettingsPage(): ReactElement {
const updatePayload: UpdateFleetProviderRequest = {
displayName,
isActive: form.isActive,
models,
models: providerModels,
};
if (baseUrl.length > 0) {
@@ -303,21 +299,27 @@ export default function ProvidersSettingsPage(): ReactElement {
await updateFleetProvider(editingProvider.id, updatePayload);
setSuccessMessage(`Updated provider "${displayName}".`);
} else {
const createPayload: CreateFleetProviderRequest = {
name: buildProviderName(displayName, form.type),
displayName,
type: form.type,
models,
};
const config: CreateFleetProviderRequest["config"] = {};
if (baseUrl.length > 0) {
createPayload.baseUrl = baseUrl;
config.endpoint = baseUrl;
}
if (apiKey.length > 0) {
createPayload.apiKey = apiKey;
config.apiKey = apiKey;
}
if (models.length > 0) {
config.models = models;
}
const createPayload: CreateFleetProviderRequest = {
displayName,
providerType: mapProviderTypeToApi(form.type),
config,
isEnabled: form.isActive,
};
await createFleetProvider(createPayload);
setSuccessMessage(`Added provider "${displayName}".`);
}

View File

@@ -34,17 +34,25 @@ describe("createFleetProvider", (): void => {
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "provider-1" } as never);
await createFleetProvider({
name: "openai-main",
providerType: "openai",
displayName: "OpenAI Main",
type: "openai",
apiKey: "sk-test",
config: {
endpoint: "https://api.openai.com/v1",
apiKey: "sk-test",
models: ["gpt-4.1-mini", "gpt-4o-mini"],
},
isEnabled: true,
});
expect(client.apiPost).toHaveBeenCalledWith("/api/fleet-settings/providers", {
name: "openai-main",
providerType: "openai",
displayName: "OpenAI Main",
type: "openai",
apiKey: "sk-test",
config: {
endpoint: "https://api.openai.com/v1",
apiKey: "sk-test",
models: ["gpt-4.1-mini", "gpt-4o-mini"],
},
isEnabled: true,
});
});
});

View File

@@ -16,13 +16,16 @@ export interface FleetProvider {
}
export interface CreateFleetProviderRequest {
name: string;
providerType: "ollama" | "openai" | "claude";
displayName: string;
type: string;
baseUrl?: string;
apiKey?: string;
apiType?: string;
models?: FleetProviderModel[];
config: {
endpoint?: string;
apiKey?: string;
models?: string[];
timeout?: number;
};
isDefault?: boolean;
isEnabled?: boolean;
}
export interface UpdateFleetProviderRequest {