fix(api): MS22 Phase 1 post-coding audit (#625)
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>
This commit was merged in pull request #625.
This commit is contained in:
2026-03-01 19:53:49 +00:00
committed by jason.woltje
parent a25a77a43c
commit 3607554902
5 changed files with 233 additions and 27 deletions

View File

@@ -1,4 +1,14 @@
import { Body, Controller, Post, Req, Res, UnauthorizedException, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
HttpException,
Logger,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import type { Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
@@ -8,6 +18,8 @@ import { ChatProxyService } from "./chat-proxy.service";
@Controller("chat")
@UseGuards(AuthGuard)
export class ChatProxyController {
private readonly logger = new Logger(ChatProxyController.name);
constructor(private readonly chatProxyService: ChatProxyService) {}
// POST /api/chat/stream
@@ -58,10 +70,11 @@ export class ChatProxyController {
res.write(Buffer.from(chunk));
}
} catch (error: unknown) {
this.logStreamError(error);
if (!res.writableEnded && !res.destroyed) {
const message = error instanceof Error ? error.message : String(error);
res.write("event: error\n");
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
}
} finally {
if (!res.writableEnded && !res.destroyed) {
@@ -69,4 +82,21 @@ export class ChatProxyController {
}
}
}
private toSafeClientMessage(error: unknown): string {
if (error instanceof HttpException && error.getStatus() < 500) {
return "Chat request was rejected";
}
return "Chat stream failed";
}
private logStreamError(error: unknown): void {
if (error instanceof Error) {
this.logger.warn(`Chat stream failed: ${error.message}`);
return;
}
this.logger.warn(`Chat stream failed: ${String(error)}`);
}
}

View File

@@ -64,6 +64,7 @@ describe("ChatProxyService", () => {
expect.objectContaining({
method: "POST",
headers: {
Authorization: "Bearer gateway-token",
"Content-Type": "application/json",
},
})

View File

@@ -1,12 +1,24 @@
import { BadGatewayException, Injectable, ServiceUnavailableException } from "@nestjs/common";
import {
BadGatewayException,
Injectable,
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
import { PrismaService } from "../prisma/prisma.service";
import type { ChatMessage } from "./chat-proxy.dto";
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
interface ContainerConnection {
url: string;
token: string;
}
@Injectable()
export class ChatProxyService {
private readonly logger = new Logger(ChatProxyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly containerLifecycle: ContainerLifecycleService
@@ -14,8 +26,7 @@ export class ChatProxyService {
// Get the user's OpenClaw container URL and mark it active.
async getContainerUrl(userId: string): Promise<string> {
const { url } = await this.containerLifecycle.ensureRunning(userId);
await this.containerLifecycle.touch(userId);
const { url } = await this.getContainerConnection(userId);
return url;
}
@@ -25,11 +36,14 @@ export class ChatProxyService {
messages: ChatMessage[],
signal?: AbortSignal
): Promise<Response> {
const containerUrl = await this.getContainerUrl(userId);
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
const model = await this.getPreferredModel(userId);
const requestInit: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gatewayToken}`,
},
body: JSON.stringify({
messages,
model,
@@ -47,10 +61,10 @@ export class ChatProxyService {
if (!response.ok) {
const detail = await this.readResponseText(response);
const status = `${String(response.status)} ${response.statusText}`.trim();
const message = detail
? `OpenClaw returned ${status}: ${detail}`
: `OpenClaw returned ${status}`;
throw new BadGatewayException(message);
this.logger.warn(
detail ? `OpenClaw returned ${status}: ${detail}` : `OpenClaw returned ${status}`
);
throw new BadGatewayException(`OpenClaw returned ${status}`);
}
return response;
@@ -60,10 +74,17 @@ export class ChatProxyService {
}
const message = error instanceof Error ? error.message : String(error);
throw new ServiceUnavailableException(`Failed to proxy chat to OpenClaw: ${message}`);
this.logger.warn(`Failed to proxy chat request: ${message}`);
throw new ServiceUnavailableException("Failed to proxy chat to OpenClaw");
}
}
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
const connection = await this.containerLifecycle.ensureRunning(userId);
await this.containerLifecycle.touch(userId);
return connection;
}
private async getPreferredModel(userId: string): Promise<string> {
const config = await this.prisma.userAgentConfig.findUnique({
where: { userId },