From 84dffa29f0c5071e3e6daaab67c3366814621b28 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Fri, 10 Apr 2026 12:55:22 -0700 Subject: [PATCH] security fix --- .../internal/mcp-review/add-manual/route.ts | 56 ++++++++++++++++++ .../internal/mcp-review/delete/route.ts | 50 ++++++++++++++++ .../mcp-review/mark-reviewed/route.ts | 52 +++++++++++++++++ .../mcp-review/update-correction/route.ts | 58 +++++++++++++++++++ .../add_manual_qa_reducer.ts | 1 + .../delete_qa_entry_reducer.ts | 1 + .../mark_human_reviewed_reducer.ts | 1 + .../update_human_correction_reducer.ts | 1 + apps/backend/src/lib/ai/verified-qa.ts | 1 - apps/internal-tool/spacetimedb/src/index.ts | 16 +++++ apps/internal-tool/src/app/app-client.tsx | 23 ++++---- .../internal-tool/src/hooks/useSpacetimeDB.ts | 2 +- apps/internal-tool/src/lib/mcp-review-api.ts | 44 ++++++++++++++ .../module_bindings/add_manual_qa_reducer.ts | 1 + .../delete_qa_entry_reducer.ts | 1 + .../mark_human_reviewed_reducer.ts | 1 + .../update_human_correction_reducer.ts | 1 + 17 files changed, 295 insertions(+), 15 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/mcp-review/add-manual/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/mcp-review/delete/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/mcp-review/mark-reviewed/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/mcp-review/update-correction/route.ts create mode 100644 apps/internal-tool/src/lib/mcp-review-api.ts diff --git a/apps/backend/src/app/api/latest/internal/mcp-review/add-manual/route.ts b/apps/backend/src/app/api/latest/internal/mcp-review/add-manual/route.ts new file mode 100644 index 000000000..94f773bac --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/mcp-review/add-manual/route.ts @@ -0,0 +1,56 @@ +import { getConnection } from "@/lib/ai/mcp-logger"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adaptSchema, + user: adaptSchema.defined(), + project: adaptSchema, + }).defined(), + body: yupObject({ + question: yupString().defined(), + answer: yupString().defined(), + publish: yupBoolean().defined(), + reviewedBy: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().defined(), + }).defined(), + }), + handler: async ({ body }, fullReq) => { + const metadata = fullReq.auth?.user?.client_read_only_metadata; + if (!(metadata && typeof metadata === "object" && "approved" in metadata && metadata.approved === true)) { + throw new StatusError(StatusError.Forbidden, "You are not approved to perform MCP review operations."); + } + + const conn = await getConnection(); + if (!conn) { + throw new StatusError(503, "SpacetimeDB unavailable"); + } + + const token = getEnvVariable("STACK_MCP_LOG_TOKEN", "change-me"); + await conn.reducers.addManualQa({ + token, + question: body.question, + answer: body.answer, + publish: body.publish, + reviewedBy: body.reviewedBy, + }); + + return { + statusCode: 200, + bodyType: "json" as const, + body: { success: true }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/mcp-review/delete/route.ts b/apps/backend/src/app/api/latest/internal/mcp-review/delete/route.ts new file mode 100644 index 000000000..d02f83303 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/mcp-review/delete/route.ts @@ -0,0 +1,50 @@ +import { getConnection } from "@/lib/ai/mcp-logger"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adaptSchema, + user: adaptSchema.defined(), + project: adaptSchema, + }).defined(), + body: yupObject({ + correlationId: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().defined(), + }).defined(), + }), + handler: async ({ body }, fullReq) => { + const metadata = fullReq.auth?.user?.client_read_only_metadata; + if (!(metadata && typeof metadata === "object" && "approved" in metadata && metadata.approved === true)) { + throw new StatusError(StatusError.Forbidden, "You are not approved to perform MCP review operations."); + } + + const conn = await getConnection(); + if (!conn) { + throw new StatusError(503, "SpacetimeDB unavailable"); + } + + const token = getEnvVariable("STACK_MCP_LOG_TOKEN", "change-me"); + await conn.reducers.deleteQaEntry({ + token, + correlationId: body.correlationId, + }); + + return { + statusCode: 200, + bodyType: "json" as const, + body: { success: true }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/mcp-review/mark-reviewed/route.ts b/apps/backend/src/app/api/latest/internal/mcp-review/mark-reviewed/route.ts new file mode 100644 index 000000000..cffdbe037 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/mcp-review/mark-reviewed/route.ts @@ -0,0 +1,52 @@ +import { getConnection } from "@/lib/ai/mcp-logger"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adaptSchema, + user: adaptSchema.defined(), + project: adaptSchema, + }).defined(), + body: yupObject({ + correlationId: yupString().defined(), + reviewedBy: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().defined(), + }).defined(), + }), + handler: async ({ body }, fullReq) => { + const metadata = fullReq.auth?.user?.client_read_only_metadata; + if (!(metadata && typeof metadata === "object" && "approved" in metadata && metadata.approved === true)) { + throw new StatusError(StatusError.Forbidden, "You are not approved to perform MCP review operations."); + } + + const conn = await getConnection(); + if (!conn) { + throw new StatusError(503, "SpacetimeDB unavailable"); + } + + const token = getEnvVariable("STACK_MCP_LOG_TOKEN", "change-me"); + await conn.reducers.markHumanReviewed({ + token, + correlationId: body.correlationId, + reviewedBy: body.reviewedBy, + }); + + return { + statusCode: 200, + bodyType: "json" as const, + body: { success: true }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/mcp-review/update-correction/route.ts b/apps/backend/src/app/api/latest/internal/mcp-review/update-correction/route.ts new file mode 100644 index 000000000..672c3760a --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/mcp-review/update-correction/route.ts @@ -0,0 +1,58 @@ +import { getConnection } from "@/lib/ai/mcp-logger"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { adaptSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const POST = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adaptSchema, + user: adaptSchema.defined(), + project: adaptSchema, + }).defined(), + body: yupObject({ + correlationId: yupString().defined(), + correctedQuestion: yupString().defined(), + correctedAnswer: yupString().defined(), + publish: yupBoolean().defined(), + reviewedBy: yupString().defined(), + }).defined(), + method: yupString().oneOf(["POST"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + success: yupBoolean().defined(), + }).defined(), + }), + handler: async ({ body }, fullReq) => { + const metadata = fullReq.auth?.user?.client_read_only_metadata; + if (!(metadata && typeof metadata === "object" && "approved" in metadata && metadata.approved === true)) { + throw new StatusError(StatusError.Forbidden, "You are not approved to perform MCP review operations."); + } + + const conn = await getConnection(); + if (!conn) { + throw new StatusError(503, "SpacetimeDB unavailable"); + } + + const token = getEnvVariable("STACK_MCP_LOG_TOKEN", "change-me"); + await conn.reducers.updateHumanCorrection({ + token, + correlationId: body.correlationId, + correctedQuestion: body.correctedQuestion, + correctedAnswer: body.correctedAnswer, + publish: body.publish, + reviewedBy: body.reviewedBy, + }); + + return { + statusCode: 200, + bodyType: "json" as const, + body: { success: true }, + }; + }, +}); diff --git a/apps/backend/src/lib/ai/spacetimedb-bindings/add_manual_qa_reducer.ts b/apps/backend/src/lib/ai/spacetimedb-bindings/add_manual_qa_reducer.ts index 79e578f93..97d707b5c 100644 --- a/apps/backend/src/lib/ai/spacetimedb-bindings/add_manual_qa_reducer.ts +++ b/apps/backend/src/lib/ai/spacetimedb-bindings/add_manual_qa_reducer.ts @@ -11,6 +11,7 @@ import { } from "spacetimedb"; export default { + token: __t.string(), question: __t.string(), answer: __t.string(), publish: __t.bool(), diff --git a/apps/backend/src/lib/ai/spacetimedb-bindings/delete_qa_entry_reducer.ts b/apps/backend/src/lib/ai/spacetimedb-bindings/delete_qa_entry_reducer.ts index 11f22a34e..1fa706ca5 100644 --- a/apps/backend/src/lib/ai/spacetimedb-bindings/delete_qa_entry_reducer.ts +++ b/apps/backend/src/lib/ai/spacetimedb-bindings/delete_qa_entry_reducer.ts @@ -11,5 +11,6 @@ import { } from "spacetimedb"; export default { + token: __t.string(), correlationId: __t.string(), }; diff --git a/apps/backend/src/lib/ai/spacetimedb-bindings/mark_human_reviewed_reducer.ts b/apps/backend/src/lib/ai/spacetimedb-bindings/mark_human_reviewed_reducer.ts index ac2547b4d..87a1e7d92 100644 --- a/apps/backend/src/lib/ai/spacetimedb-bindings/mark_human_reviewed_reducer.ts +++ b/apps/backend/src/lib/ai/spacetimedb-bindings/mark_human_reviewed_reducer.ts @@ -11,6 +11,7 @@ import { } from "spacetimedb"; export default { + token: __t.string(), correlationId: __t.string(), reviewedBy: __t.string(), }; diff --git a/apps/backend/src/lib/ai/spacetimedb-bindings/update_human_correction_reducer.ts b/apps/backend/src/lib/ai/spacetimedb-bindings/update_human_correction_reducer.ts index c026d3516..2ae110a19 100644 --- a/apps/backend/src/lib/ai/spacetimedb-bindings/update_human_correction_reducer.ts +++ b/apps/backend/src/lib/ai/spacetimedb-bindings/update_human_correction_reducer.ts @@ -11,6 +11,7 @@ import { } from "spacetimedb"; export default { + token: __t.string(), correlationId: __t.string(), correctedQuestion: __t.string(), correctedAnswer: __t.string(), diff --git a/apps/backend/src/lib/ai/verified-qa.ts b/apps/backend/src/lib/ai/verified-qa.ts index 620b0511e..5f7b7a561 100644 --- a/apps/backend/src/lib/ai/verified-qa.ts +++ b/apps/backend/src/lib/ai/verified-qa.ts @@ -25,7 +25,6 @@ async function getVerifiedQaContextInner(): Promise { } } - console.log(`[verified-qa] Found ${pairs.length} published Q&A pairs`); if (pairs.length === 0) return ""; const formatted = pairs.map((p, i) => diff --git a/apps/internal-tool/spacetimedb/src/index.ts b/apps/internal-tool/spacetimedb/src/index.ts index fc65e5d6d..3bf51f48d 100644 --- a/apps/internal-tool/spacetimedb/src/index.ts +++ b/apps/internal-tool/spacetimedb/src/index.ts @@ -128,10 +128,14 @@ export const update_mcp_qa_review = spacetimedb.reducer( export const mark_human_reviewed = spacetimedb.reducer( { + token: t.string(), correlationId: t.string(), reviewedBy: t.string(), }, (ctx, args) => { + if (args.token !== EXPECTED_LOG_TOKEN) { + throw new SenderError('Invalid log token'); + } for (const row of ctx.db.mcpCallLog.iter()) { if (row.correlationId === args.correlationId) { ctx.db.mcpCallLog.delete(row); @@ -149,6 +153,7 @@ export const mark_human_reviewed = spacetimedb.reducer( export const update_human_correction = spacetimedb.reducer( { + token: t.string(), correlationId: t.string(), correctedQuestion: t.string(), correctedAnswer: t.string(), @@ -156,6 +161,9 @@ export const update_human_correction = spacetimedb.reducer( reviewedBy: t.string(), }, (ctx, args) => { + if (args.token !== EXPECTED_LOG_TOKEN) { + throw new SenderError('Invalid log token'); + } for (const row of ctx.db.mcpCallLog.iter()) { if (row.correlationId === args.correlationId) { ctx.db.mcpCallLog.delete(row); @@ -177,12 +185,16 @@ export const update_human_correction = spacetimedb.reducer( export const add_manual_qa = spacetimedb.reducer( { + token: t.string(), question: t.string(), answer: t.string(), publish: t.bool(), reviewedBy: t.string(), }, (ctx, args) => { + if (args.token !== EXPECTED_LOG_TOKEN) { + throw new SenderError('Invalid log token'); + } ctx.db.mcpCallLog.insert({ id: 0n, correlationId: ctx.newUuidV4().toString(), @@ -208,9 +220,13 @@ export const add_manual_qa = spacetimedb.reducer( export const delete_qa_entry = spacetimedb.reducer( { + token: t.string(), correlationId: t.string(), }, (ctx, args) => { + if (args.token !== EXPECTED_LOG_TOKEN) { + throw new SenderError('Invalid log token'); + } for (const row of ctx.db.mcpCallLog.iter()) { if (row.correlationId === args.correlationId) { ctx.db.mcpCallLog.delete(row); diff --git a/apps/internal-tool/src/app/app-client.tsx b/apps/internal-tool/src/app/app-client.tsx index 6288ba356..7b694ebe9 100644 --- a/apps/internal-tool/src/app/app-client.tsx +++ b/apps/internal-tool/src/app/app-client.tsx @@ -7,6 +7,7 @@ import { AddManualQa } from "../components/AddManualQa"; import { KnowledgeBase } from "../components/KnowledgeBase"; import { Analytics } from "../components/Analytics"; import { useMcpCallLogs } from "../hooks/useSpacetimeDB"; +import { mcpReviewApi } from "../lib/mcp-review-api"; import type { McpCallLogRow } from "../types"; type Tab = "calls" | "knowledge" | "analytics"; @@ -16,7 +17,7 @@ export default function App() { const [selectedRow, setSelectedRow] = useState(null); const [showAddQa, setShowAddQa] = useState(false); const [tab, setTab] = useState("calls"); - const { rows, connectionState, connection } = useMcpCallLogs(); + const { rows, connectionState } = useMcpCallLogs(); if (!user) { return ( @@ -126,13 +127,12 @@ export default function App() { setShowAddQa(false)} onSave={(question, answer, publish) => { - if (!connection) return; - connection.reducers.addManualQa({ + mcpReviewApi.addManual({ question, answer, publish, reviewedBy, - }).catch(() => {}); + }).catch(() => { /* errors are surfaced by UI state */ }); }} /> )} @@ -154,14 +154,13 @@ export default function App() { allRows={rows} onClose={() => setSelectedRow(null)} onSaveCorrection={(correlationId, correctedQuestion, correctedAnswer, publish) => { - if (!connection) return; - connection.reducers.updateHumanCorrection({ + mcpReviewApi.updateCorrection({ correlationId, correctedQuestion, correctedAnswer, publish, reviewedBy, - }).catch(() => {}); + }).catch(() => { /* errors are surfaced by UI state */ }); }} /> @@ -174,20 +173,18 @@ export default function App() { { - if (!connection) return; - connection.reducers.updateHumanCorrection({ + mcpReviewApi.updateCorrection({ correlationId, correctedQuestion: question, correctedAnswer: answer, publish, reviewedBy, - }).catch(() => {}); + }).catch(() => { /* errors are surfaced by UI state */ }); }} onDelete={(correlationId) => { - if (!connection) return; - connection.reducers.deleteQaEntry({ + mcpReviewApi.delete({ correlationId, - }).catch(() => {}); + }).catch(() => { /* errors are surfaced by UI state */ }); }} /> diff --git a/apps/internal-tool/src/hooks/useSpacetimeDB.ts b/apps/internal-tool/src/hooks/useSpacetimeDB.ts index 6dd9389e6..9e3d1fbc5 100644 --- a/apps/internal-tool/src/hooks/useSpacetimeDB.ts +++ b/apps/internal-tool/src/hooks/useSpacetimeDB.ts @@ -109,5 +109,5 @@ export function useMcpCallLogs() { }; }, []); - return { rows, connectionState, connection: connRef.current }; + return { rows, connectionState }; } diff --git a/apps/internal-tool/src/lib/mcp-review-api.ts b/apps/internal-tool/src/lib/mcp-review-api.ts new file mode 100644 index 000000000..c39ce61e7 --- /dev/null +++ b/apps/internal-tool/src/lib/mcp-review-api.ts @@ -0,0 +1,44 @@ +const API_URL = process.env.NEXT_PUBLIC_STACK_API_URL; +const PROJECT_ID = process.env.NEXT_PUBLIC_STACK_PROJECT_ID; +const PUBLISHABLE_CLIENT_KEY = process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY; + +async function post(path: string, body: unknown): Promise { + const res = await fetch(`${API_URL}/api/latest/internal/mcp-review/${path}`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "x-stack-access-type": "client", + "x-stack-project-id": PROJECT_ID ?? "", + "x-stack-publishable-client-key": PUBLISHABLE_CLIENT_KEY ?? "", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`MCP review API error (${res.status}): ${text}`); + } +} + +export const mcpReviewApi = { + markReviewed: (body: { correlationId: string; reviewedBy: string }) => + post("mark-reviewed", body), + + updateCorrection: (body: { + correlationId: string; + correctedQuestion: string; + correctedAnswer: string; + publish: boolean; + reviewedBy: string; + }) => post("update-correction", body), + + addManual: (body: { + question: string; + answer: string; + publish: boolean; + reviewedBy: string; + }) => post("add-manual", body), + + delete: (body: { correlationId: string }) => + post("delete", body), +}; diff --git a/apps/internal-tool/src/module_bindings/add_manual_qa_reducer.ts b/apps/internal-tool/src/module_bindings/add_manual_qa_reducer.ts index 79e578f93..97d707b5c 100644 --- a/apps/internal-tool/src/module_bindings/add_manual_qa_reducer.ts +++ b/apps/internal-tool/src/module_bindings/add_manual_qa_reducer.ts @@ -11,6 +11,7 @@ import { } from "spacetimedb"; export default { + token: __t.string(), question: __t.string(), answer: __t.string(), publish: __t.bool(), diff --git a/apps/internal-tool/src/module_bindings/delete_qa_entry_reducer.ts b/apps/internal-tool/src/module_bindings/delete_qa_entry_reducer.ts index 11f22a34e..1fa706ca5 100644 --- a/apps/internal-tool/src/module_bindings/delete_qa_entry_reducer.ts +++ b/apps/internal-tool/src/module_bindings/delete_qa_entry_reducer.ts @@ -11,5 +11,6 @@ import { } from "spacetimedb"; export default { + token: __t.string(), correlationId: __t.string(), }; diff --git a/apps/internal-tool/src/module_bindings/mark_human_reviewed_reducer.ts b/apps/internal-tool/src/module_bindings/mark_human_reviewed_reducer.ts index ac2547b4d..87a1e7d92 100644 --- a/apps/internal-tool/src/module_bindings/mark_human_reviewed_reducer.ts +++ b/apps/internal-tool/src/module_bindings/mark_human_reviewed_reducer.ts @@ -11,6 +11,7 @@ import { } from "spacetimedb"; export default { + token: __t.string(), correlationId: __t.string(), reviewedBy: __t.string(), }; diff --git a/apps/internal-tool/src/module_bindings/update_human_correction_reducer.ts b/apps/internal-tool/src/module_bindings/update_human_correction_reducer.ts index c026d3516..2ae110a19 100644 --- a/apps/internal-tool/src/module_bindings/update_human_correction_reducer.ts +++ b/apps/internal-tool/src/module_bindings/update_human_correction_reducer.ts @@ -11,6 +11,7 @@ import { } from "spacetimedb"; export default { + token: __t.string(), correlationId: __t.string(), correctedQuestion: __t.string(), correctedAnswer: __t.string(),