Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/genui/a2ui-playground/src/pages/AIChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ const DESKTOP_CHAT_MIN_WIDTH = 360;
const COMPACT_CHAT_MIN_HEIGHT = 280;
const COMPACT_PREVIEW_MIN_HEIGHT = 320;
const RESIZE_BREAKPOINT = 980;
const ONLINE_A2UI_CHAT_URL = '/a2ui/stream';
const ONLINE_A2UI_SERVER_ORIGIN = 'https://genui-server.vercel.app';
const ONLINE_A2UI_CHAT_URL = `${ONLINE_A2UI_SERVER_ORIGIN}/a2ui/stream`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const LOCAL_A2UI_SERVER_PORT = '3060';

function isDevHost(hostname: string): boolean {
Expand All @@ -82,12 +83,19 @@ function isDevHost(hostname: string): boolean {
);
}

function isTrustedOnlineEndpoint(endpoint: URL): boolean {
return endpoint.origin === ONLINE_A2UI_SERVER_ORIGIN;
}

function resolveTrustedA2UIEndpoint(raw: string): string | null {
try {
const endpoint = new URL(raw, window.location.origin);
if (endpoint.origin === window.location.origin) {
return endpoint.toString();
}
if (isTrustedOnlineEndpoint(endpoint)) {
return endpoint.toString();
}

const isTrustedDevEndpoint = endpoint.protocol === 'http:'
&& endpoint.port === LOCAL_A2UI_SERVER_PORT
Expand All @@ -109,7 +117,7 @@ function getA2UIChatEndpoint(): string {
if (
window.location.protocol === 'http:' && isDevHost(window.location.hostname)
) {
return `http://${window.location.hostname}:${LOCAL_A2UI_SERVER_PORT}/a2ui/chat`;
return `http://${window.location.hostname}:${LOCAL_A2UI_SERVER_PORT}/a2ui/stream`;
}
return ONLINE_A2UI_CHAT_URL;
}
Expand Down
122 changes: 120 additions & 2 deletions packages/genui/server/app/a2ui/_shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import type { A2UICatalog } from '../../agent/a2ui-catalog';
import type { ChatOptions } from '../../service/a2ui-agent';
import type { ChatMessage, ChatOptions } from '../../service/a2ui-agent';

export interface A2UIChatBody {
messages?: unknown;
Expand All @@ -16,10 +16,35 @@ export interface A2UIChatBody {
validate?: boolean;
}

function parsePositiveInt(
raw: string | undefined,
fallback: number,
): number {
if (!raw) return fallback;
const n = Number(raw);
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) return fallback;
return n;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function clientOverridesAllowed(): boolean {
return process.env['A2UI_ALLOW_CLIENT_OVERRIDE'] === '1';
return process.env.A2UI_ALLOW_CLIENT_OVERRIDE === '1';
}

export const MAX_BODY_BYTES = parsePositiveInt(
process.env.A2UI_MAX_BODY_BYTES,
64 * 1024,
);

export const MAX_MESSAGE_CHARS = parsePositiveInt(
process.env.A2UI_MAX_MESSAGE_CHARS,
8_000,
);

export const MAX_MESSAGES = parsePositiveInt(
process.env.A2UI_MAX_MESSAGES,
40,
);

export function pickChatOptions(body: {
threadId?: string;
resourceId?: string;
Expand Down Expand Up @@ -47,3 +72,96 @@ export function errorMessage(
if (err instanceof Error) return { message: err.message, name: err.name };
return { message: String(err) };
}

export interface ValidatedMessages {
ok: true;
messages: ChatMessage[];
}

export interface InvalidMessages {
ok: false;
status: number;
error: string;
}

export function validateMessages(
value: unknown,
): ValidatedMessages | InvalidMessages {
if (!Array.isArray(value) || value.length === 0) {
return { ok: false, status: 400, error: 'messages is required' };
}
if (value.length > MAX_MESSAGES) {
return {
ok: false,
status: 400,
error: `too many messages (max ${MAX_MESSAGES})`,
};
}
const messages: ChatMessage[] = [];
for (let i = 0; i < value.length; i++) {
const item = value[i] as unknown;
if (
item === null
|| typeof item !== 'object'
|| typeof (item as ChatMessage).role !== 'string'
|| typeof (item as ChatMessage).content !== 'string'
) {
return {
ok: false,
status: 400,
error: `messages[${i}] must be {role: string, content: string}`,
};
}
const message = item as ChatMessage;
if (message.content.length > MAX_MESSAGE_CHARS) {
return {
ok: false,
status: 413,
error: `messages[${i}].content exceeds ${MAX_MESSAGE_CHARS} characters`,
};
}
messages.push(message);
}
return { ok: true, messages };
}

export async function readJsonBodyWithLimit<T>(
req: Request,
): Promise<
| { ok: true; body: T }
| { ok: false; status: number; error: string }
> {
const declaredLength = req.headers.get('content-length');
if (declaredLength) {
const n = Number(declaredLength);
if (Number.isFinite(n) && n > MAX_BODY_BYTES) {
return {
ok: false,
status: 413,
error: `request body exceeds ${MAX_BODY_BYTES} bytes`,
};
}
}

let raw: string;
try {
raw = await req.text();
} catch {
return { ok: false, status: 400, error: 'failed to read request body' };
}

const rawByteLength = Buffer.byteLength(raw, 'utf8');
if (rawByteLength > MAX_BODY_BYTES) {
return {
ok: false,
status: 413,
error: `request body exceeds ${MAX_BODY_BYTES} bytes`,
};
}

try {
return { ok: true, body: JSON.parse(raw) as T };
} catch {
return { ok: false, status: 400, error: 'invalid JSON body' };
}
}
32 changes: 24 additions & 8 deletions packages/genui/server/app/a2ui/action/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
import type { A2UICatalog } from '../../../agent/a2ui-catalog';
import { getA2UIAgentService } from '../../../service/a2ui-agent';
import type { ChatMessage } from '../../../service/a2ui-agent';
import { errorMessage, pickChatOptions } from '../_shared';
import {
MAX_MESSAGE_CHARS,
errorMessage,
pickChatOptions,
readJsonBodyWithLimit,
} from '../_shared';
import { corsPreflight, jsonWithCors } from '../cors';
import { checkRateLimit, rateLimitJsonResponse } from '../rate-limit';

Expand Down Expand Up @@ -37,16 +42,15 @@ export async function POST(req: Request) {
return rateLimitJsonResponse(req, decision);
}

let body: A2UIActionBody;
try {
body = (await req.json()) as A2UIActionBody;
} catch {
const parsed = await readJsonBodyWithLimit<A2UIActionBody>(req);
if (!parsed.ok) {
return jsonWithCors(
req,
{ ok: false, error: 'invalid JSON body' },
{ status: 400 },
{ ok: false, error: parsed.error },
{ status: parsed.status },
);
}
const body = parsed.body;

if (!body || !body.threadId) {
return jsonWithCors(req, { ok: false, error: 'threadId is required' });
Expand Down Expand Up @@ -76,9 +80,21 @@ export async function POST(req: Request) {
surfaceId: body.surfaceId,
action: body.action,
};
const userContent = `A2UI_USER_ACTION: ${JSON.stringify(payload)}`;
if (userContent.length > MAX_MESSAGE_CHARS) {
return jsonWithCors(
req,
{
ok: false,
error:
`synthesized user action exceeds ${MAX_MESSAGE_CHARS} characters`,
},
{ status: 413 },
);
}
const userMessage: ChatMessage = {
role: 'user',
content: `A2UI_USER_ACTION: ${JSON.stringify(payload)}`,
content: userContent,
};

const opts = pickChatOptions(body);
Expand Down
43 changes: 18 additions & 25 deletions packages/genui/server/app/a2ui/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,19 @@
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { getA2UIAgentService } from '../../../service/a2ui-agent';
import type { ChatMessage } from '../../../service/a2ui-agent';
import { errorMessage, pickChatOptions } from '../_shared';
import {
errorMessage,
pickChatOptions,
readJsonBodyWithLimit,
validateMessages,
} from '../_shared';
import type { A2UIChatBody } from '../_shared';
import { corsPreflight, jsonWithCors } from '../cors';
import { checkRateLimit, rateLimitJsonResponse } from '../rate-limit';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

function isChatMessageArray(value: unknown): value is ChatMessage[] {
if (!Array.isArray(value) || value.length === 0) return false;
return value.every(
(item) =>
item !== null
&& typeof item === 'object'
&& typeof (item as ChatMessage).role === 'string'
&& typeof (item as ChatMessage).content === 'string',
);
}

export function OPTIONS(req: Request) {
return corsPreflight(req);
}
Expand All @@ -32,25 +25,25 @@ export async function POST(req: Request) {
return rateLimitJsonResponse(req, decision);
}

let body: A2UIChatBody;
try {
body = (await req.json()) as A2UIChatBody;
} catch {
const parsed = await readJsonBodyWithLimit<A2UIChatBody>(req);
if (!parsed.ok) {
return jsonWithCors(
req,
{ ok: false, error: 'invalid JSON body' },
{ status: 400 },
{ ok: false, error: parsed.error },
{ status: parsed.status },
);
}
const body = parsed.body;

if (!isChatMessageArray(body.messages)) {
const validated = validateMessages(body.messages);
if (!validated.ok) {
return jsonWithCors(
req,
{ ok: false, error: 'messages is required' },
{ status: 400 },
{ ok: false, error: validated.error },
{ status: validated.status },
);
}
const messages = body.messages;
const messages = validated.messages;

const service = getA2UIAgentService();
const opts = pickChatOptions(body);
Expand All @@ -64,8 +57,8 @@ export async function POST(req: Request) {
return jsonWithCors(req, { ok: true, text, usage, finishReason });
}

const validated = await service.generateValidated(messages, opts);
return jsonWithCors(req, validated);
const validatedResult = await service.generateValidated(messages, opts);
return jsonWithCors(req, validatedResult);
} catch (err: unknown) {
const { message, name } = errorMessage(err);
return jsonWithCors(req, { ok: false, error: message, name });
Expand Down
11 changes: 6 additions & 5 deletions packages/genui/server/app/a2ui/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ function isLocalDevOrigin(origin: string): boolean {

function resolveAllowedOrigin(req: Request): string | null {
const origin = req.headers.get('origin');
// Server-to-server traffic (no Origin header) is allowed to receive a
// wildcard ACAO. Browsers always send Origin on cross-origin requests, so
// this only applies to non-browser callers and does not weaken CORS for
// browser clients.
if (!origin) return '*';
// Defense-in-depth: never echo a wildcard `Access-Control-Allow-Origin`.
// Browsers always send an Origin header on cross-origin requests, so
// missing-Origin traffic is either same-origin (no CORS needed) or a
// non-browser caller (CORS is not enforced anyway). Returning null avoids
// handing out a permissive header that a browser could otherwise honor.
if (!origin) return null;
if (getConfiguredOrigins().has(origin) || isLocalDevOrigin(origin)) {
return origin;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/genui/server/app/a2ui/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ interface RateLimitConfig {

function getConfig(): RateLimitConfig {
const limit = parsePositiveInt(
process.env['A2UI_RATE_LIMIT_PER_MIN'],
process.env.A2UI_RATE_LIMIT_PER_MIN,
20,
);
const windowMs = parsePositiveInt(
process.env['A2UI_RATE_LIMIT_WINDOW_MS'],
process.env.A2UI_RATE_LIMIT_WINDOW_MS,
60_000,
);
return { limit, windowMs };
Expand Down
Loading
Loading