Skip to content
Open
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
207 changes: 107 additions & 100 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,100 +1,107 @@
import { NextRequest } from "next/server";
import type { ChatMessage } from "@/types";
import { createSSEStream } from "@/lib/stream";
import { orchestrate } from "@/lib/orchestrator";
import { validateAgentAndRule } from "@/lib/governance";

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

export async function POST(req: NextRequest) {
let body: { messages?: unknown; sessionId?: unknown; phoneNumber?: unknown };
try {
body = await req.json();
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}

const { messages, sessionId, phoneNumber } = body;

if (!Array.isArray(messages) || messages.length === 0) {
return new Response(
JSON.stringify({ error: "messages must be a non-empty array" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}

// Basic validation: each message needs role and content
for (const msg of messages) {
if (
typeof msg !== "object" ||
msg === null ||
!("role" in msg) ||
!("content" in msg) ||
typeof msg.content !== "string"
) {
return new Response(
JSON.stringify({ error: "Each message must have role and content fields" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
}

const validatedMessages = messages as ChatMessage[];
const validatedSessionId = typeof sessionId === "string" ? sessionId : null;
const validatedPhoneNumber = typeof phoneNumber === "string" ? phoneNumber : null;

// Gate: verified requests must have a registered agent + spending rule.
// This prevents session spoofing — only phone numbers that completed
// verification (which creates the agent) pass this check.
if (validatedSessionId) {
// A sessionId without a phoneNumber is invalid — legitimate clients always
// send both after verification. Reject to prevent unmetered API calls.
if (!validatedPhoneNumber) {
return new Response(
JSON.stringify({ error: "Session requires a phone number. Please clear your session and verify again." }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
}

try {
const isRegistered = await validateAgentAndRule(validatedPhoneNumber);
if (!isRegistered) {
return new Response(
JSON.stringify({ error: "Session not recognized. Please clear your session and verify again." }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
}
} catch (err) {
console.error("Governance check failed for", validatedPhoneNumber, err);
return new Response(
JSON.stringify({ error: "Unable to verify session. Please try again." }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}

const { stream, emit, close } = createSSEStream();

// Run orchestration in background — don't await, let it stream
orchestrate(validatedMessages, validatedSessionId, emit, validatedPhoneNumber)
.then(() => close())
.catch((err) => {
emit({
type: "error",
message: err instanceof Error ? err.message : "Internal server error",
});
close();
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
import { NextRequest } from "next/server";
import type { ChatMessage } from "@/types";
import { createSSEStream } from "@/lib/stream";
import { orchestrate } from "@/lib/orchestrator";
import { validateAgentAndRule } from "@/lib/governance";

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

export async function POST(req: NextRequest) {
// SECURITY FIX: Enforce request body size limits to prevent memory exhaustion
const contentLength = req.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > 50000) {
return new Response(
JSON.stringify({ error: "Payload too large" }),
{ status: 413, headers: { "Content-Type": "application/json" } }
);
}

let body: { messages?: unknown; sessionId?: unknown; phoneNumber?: unknown };
try {
body = await req.json();
} catch {
return new Response(
JSON.stringify({ error: "Invalid JSON body" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}

const { messages, sessionId, phoneNumber } = body;

if (!Array.isArray(messages) || messages.length === 0) {
return new Response(
JSON.stringify({ error: "messages must be a non-empty array" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}

// Basic validation: each message needs role and content
for (const msg of messages) {
if (
typeof msg !== "object" ||
msg === null ||
!("role" in msg) ||
!("content" in msg) ||
typeof msg.content !== "string"
) {
return new Response(
JSON.stringify({ error: "Each message must have role and content fields" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
}

const validatedMessages = messages as ChatMessage[];
const validatedSessionId = typeof sessionId === "string" ? sessionId : null;
const validatedPhoneNumber = typeof phoneNumber === "string" ? phoneNumber : null;

// SECURITY FIX: Unauthenticated access bypass prevented.
// We strictly require both a sessionId and a phoneNumber for ANY orchestration.
// The previous implementation used `if (validatedSessionId)`, which allowed callers
// to bypass governance simply by omitting the sessionId in the request payload.
if (!validatedSessionId || !validatedPhoneNumber) {
return new Response(
JSON.stringify({ error: "Unauthorized: A valid verified session and phone number are strictly required." }),
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}

try {
// Gate: verified requests must have a registered agent + spending rule.
const isRegistered = await validateAgentAndRule(validatedPhoneNumber);
if (!isRegistered) {
return new Response(
JSON.stringify({ error: "Session not recognized. Please clear your session and verify again." }),
{ status: 403, headers: { "Content-Type": "application/json" } }
);
}
} catch (err) {
console.error("Governance check failed for", validatedPhoneNumber, err);
return new Response(
JSON.stringify({ error: "Unable to verify session. Please try again." }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}

const { stream, emit, close } = createSSEStream();

// Run orchestration in background
orchestrate(validatedMessages, validatedSessionId, emit, validatedPhoneNumber)
.then(() => close())
.catch((err) => {
emit({
type: "error",
message: err instanceof Error ? err.message : "Internal server error",
});
close();
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
93 changes: 47 additions & 46 deletions lib/sapiom.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,47 @@
import { createFetch } from "@sapiom/fetch";

const defaultClient = createFetch({
apiKey: process.env.SAPIOM_API_KEY,
});

// Cache clients by agentName to avoid recreating on every call
const agentClients = new Map<string, typeof fetch>();

function getClient(agentName?: string): typeof fetch {
if (!agentName) return defaultClient;
let client = agentClients.get(agentName);
if (!client) {
client = createFetch({
apiKey: process.env.SAPIOM_API_KEY,
agentName,
});
agentClients.set(agentName, client);
}
return client;
}

export async function sapiomFetch(
service: string,
path: string,
options: {
method?: string;
body?: unknown;
headers?: Record<string, string>;
agentName?: string;
} = {}
): Promise<Response> {
const { method = "POST", body, headers = {}, agentName } = options;

const url = `https://${service}.services.sapiom.ai${path}`;
const client = getClient(agentName);

return client(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
}
import { createFetch } from "@sapiom/fetch";

// Cache clients by agentName to avoid recreating on every call
const agentClients = new Map<string, typeof fetch>();

function getClient(agentName: string): typeof fetch {
let client = agentClients.get(agentName);
if (!client) {
client = createFetch({
apiKey: process.env.SAPIOM_API_KEY,
agentName,
});
agentClients.set(agentName, client);
}
return client;
}

export async function sapiomFetch(
service: string,
path: string,
options: {
method?: string;
body?: unknown;
headers?: Record<string, string>;
agentName?: string;
} = {}
): Promise<Response> {
const { method = "POST", body, headers = {}, agentName } = options;

// SECURITY FIX: Prevent unauthenticated paid-API abuse by strictly requiring a verified session (agentName).
// This ensures that the server's default SAPIOM_API_KEY cannot be consumed by unauthenticated callers.
if (!agentName) {
throw new Error("Unauthorized: Paid API requests require a verified session/agent.");
}

const url = `https://${service}.services.sapiom.ai${path}`;
const client = getClient(agentName);

return client(url, {
method,
headers: {
"Content-Type": "application/json",
...headers,
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
}
Loading