security fixes

This commit is contained in:
Aadesh Kheria 2026-04-10 17:42:26 -07:00
parent 84dffa29f0
commit a0486e9a27
14 changed files with 177 additions and 107 deletions

View File

@ -1,7 +1,7 @@
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 { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
export const POST = createSmartRouteHandler({
@ -28,9 +28,11 @@ export const POST = createSmartRouteHandler({
}).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.");
if (getNodeEnvironment() !== "development") {
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();

View File

@ -1,7 +1,7 @@
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 { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
export const POST = createSmartRouteHandler({
@ -25,9 +25,11 @@ export const POST = createSmartRouteHandler({
}).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.");
if (getNodeEnvironment() !== "development") {
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();

View File

@ -1,7 +1,7 @@
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 { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
export const POST = createSmartRouteHandler({
@ -26,9 +26,11 @@ export const POST = createSmartRouteHandler({
}).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.");
if (getNodeEnvironment() !== "development") {
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();

View File

@ -1,7 +1,7 @@
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 { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
export const POST = createSmartRouteHandler({
@ -29,9 +29,11 @@ export const POST = createSmartRouteHandler({
}).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.");
if (getNodeEnvironment() !== "development") {
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();

View File

@ -15,7 +15,7 @@ function getDocsToolsBaseUrl(): string {
}
if (getNodeEnvironment() === "development") {
const portPrefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81");
return `http://localhost:${portPrefix}04`;
return `http://localhost:${portPrefix}26`;
}
return "https://mcp.stack-auth.com";
}

View File

@ -1,10 +1,9 @@
# Stack Auth
NEXT_PUBLIC_STACK_API_URL=
NEXT_PUBLIC_STACK_PROJECT_ID=internal
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=
NEXT_PUBLIC_STACK_HOSTED_COMPONENTS_URL=
NEXT_PUBLIC_STACK_INTERNAL_TOOL_URL=
NEXT_PUBLIC_STACK_DASHBOARD_URL=
NEXT_PUBLIC_STACK_API_URL=REPLACE_ME
NEXT_PUBLIC_STACK_PROJECT_ID=REPLACE_ME
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=REPLACE_ME
STACK_SECRET_SERVER_KEY=REPLACE_ME
NEXT_PUBLIC_STACK_DASHBOARD_URL=REPLACE_ME
# SpacetimeDB
NEXT_PUBLIC_SPACETIMEDB_HOST=
NEXT_PUBLIC_SPACETIMEDB_DB_NAME=
NEXT_PUBLIC_SPACETIMEDB_HOST=REPLACE_ME
NEXT_PUBLIC_SPACETIMEDB_DB_NAME=REPLACE_ME

View File

@ -1,8 +1,6 @@
NEXT_PUBLIC_STACK_API_URL=http://localhost:8102
NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02
NEXT_PUBLIC_STACK_PROJECT_ID=internal
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
NEXT_PUBLIC_STACK_HOSTED_COMPONENTS_URL=http://internal.localhost:8109
NEXT_PUBLIC_STACK_INTERNAL_TOOL_URL=http://localhost:8141
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101
NEXT_PUBLIC_SPACETIMEDB_HOST=ws://localhost:8139
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01
NEXT_PUBLIC_SPACETIMEDB_HOST=ws://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}39
NEXT_PUBLIC_SPACETIMEDB_DB_NAME=stack-auth-llm

View File

@ -1,19 +1,19 @@
import { useUser } from "@stackframe/react";
import { useState } from "react";
import { clsx } from "clsx";
import { CallLogList } from "../components/CallLogList";
import { CallLogDetail } from "../components/CallLogDetail";
import { useState } from "react";
import { AddManualQa } from "../components/AddManualQa";
import { KnowledgeBase } from "../components/KnowledgeBase";
import { Analytics } from "../components/Analytics";
import { CallLogDetail } from "../components/CallLogDetail";
import { CallLogList } from "../components/CallLogList";
import { KnowledgeBase } from "../components/KnowledgeBase";
import { useMcpCallLogs } from "../hooks/useSpacetimeDB";
import { mcpReviewApi } from "../lib/mcp-review-api";
import { makeMcpReviewApi } from "../lib/mcp-review-api";
import type { McpCallLogRow } from "../types";
type Tab = "calls" | "knowledge" | "analytics";
export default function App() {
const user = useUser();
const user = useUser({ or: process.env.NODE_ENV === "development" ? "redirect" : "return-null" });
const [selectedRow, setSelectedRow] = useState<McpCallLogRow | null>(null);
const [showAddQa, setShowAddQa] = useState(false);
const [tab, setTab] = useState<Tab>("calls");
@ -52,9 +52,6 @@ export default function App() {
<p className="text-sm text-gray-500 mb-1">
You are signed in as {user.displayName ?? user.primaryEmail}, but your account is not approved.
</p>
<p className="text-xs text-gray-400">
An admin needs to set <code className="bg-gray-100 px-1 rounded">approved: true</code> in your client read-only metadata.
</p>
</div>
</div>
);
@ -65,7 +62,16 @@ export default function App() {
? rows.find(r => r.id === selectedRow.id) ?? selectedRow
: null;
const reviewedBy = user.displayName ?? user.primaryEmail ?? "unknown";
const currentUser = user;
const reviewedBy = currentUser.displayName ?? currentUser.primaryEmail ?? "unknown";
async function getApi() {
const { accessToken, refreshToken } = await currentUser.getAuthJson();
const authHeaders: Record<string, string> = {};
if (accessToken) authHeaders["x-stack-access-token"] = accessToken;
if (refreshToken) authHeaders["x-stack-refresh-token"] = refreshToken;
return makeMcpReviewApi(authHeaders);
}
return (
<div className="min-h-screen bg-gray-50">
@ -127,12 +133,9 @@ export default function App() {
<AddManualQa
onClose={() => setShowAddQa(false)}
onSave={(question, answer, publish) => {
mcpReviewApi.addManual({
question,
answer,
publish,
reviewedBy,
}).catch(() => { /* errors are surfaced by UI state */ });
getApi()
.then(api => api.addManual({ question, answer, publish, reviewedBy }))
.catch(() => { /* errors are surfaced by UI state */ });
}}
/>
)}
@ -154,13 +157,14 @@ export default function App() {
allRows={rows}
onClose={() => setSelectedRow(null)}
onSaveCorrection={(correlationId, correctedQuestion, correctedAnswer, publish) => {
mcpReviewApi.updateCorrection({
correlationId,
correctedQuestion,
correctedAnswer,
publish,
reviewedBy,
}).catch(() => { /* errors are surfaced by UI state */ });
getApi()
.then(api => api.updateCorrection({ correlationId, correctedQuestion, correctedAnswer, publish, reviewedBy }))
.catch(() => { /* errors are surfaced by UI state */ });
}}
onMarkReviewed={(correlationId) => {
getApi()
.then(api => api.markReviewed({ correlationId, reviewedBy }))
.catch(() => { /* errors are surfaced by UI state */ });
}}
/>
</aside>
@ -173,18 +177,14 @@ export default function App() {
<KnowledgeBase
rows={rows}
onSave={(correlationId, question, answer, publish) => {
mcpReviewApi.updateCorrection({
correlationId,
correctedQuestion: question,
correctedAnswer: answer,
publish,
reviewedBy,
}).catch(() => { /* errors are surfaced by UI state */ });
getApi()
.then(api => api.updateCorrection({ correlationId, correctedQuestion: question, correctedAnswer: answer, publish, reviewedBy }))
.catch(() => { /* errors are surfaced by UI state */ });
}}
onDelete={(correlationId) => {
mcpReviewApi.delete({
correlationId,
}).catch(() => { /* errors are surfaced by UI state */ });
getApi()
.then(api => api.delete({ correlationId }))
.catch(() => { /* errors are surfaced by UI state */ });
}}
/>
</main>

View File

@ -0,0 +1,11 @@
"use client";
import { StackHandler } from "@stackframe/react";
import { useEffect, useState } from "react";
export default function Handler() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return <StackHandler fullPage />;
}

View File

@ -61,9 +61,6 @@ export default function QuestionsPage() {
{row.publishedAt && (
<span>{format(toDate(row.publishedAt), "MMM d, yyyy")}</span>
)}
{row.humanReviewedBy && (
<span>Reviewed by {row.humanReviewedBy}</span>
)}
</div>
</article>
))}

View File

@ -28,13 +28,15 @@ function CopyButton({ text }: { text: string }) {
// ─── Main Component ────────────────────────────────────
export function CallLogDetail({ row, allRows, onClose, onSaveCorrection }: {
export function CallLogDetail({ row, allRows, onClose, onSaveCorrection, onMarkReviewed }: {
row: McpCallLogRow;
allRows: McpCallLogRow[];
onClose: () => void;
onSaveCorrection?: (correlationId: string, correctedQuestion: string, correctedAnswer: string, publish: boolean) => void;
onMarkReviewed?: (correlationId: string) => void;
}) {
const [showReplay, setShowReplay] = useState(false);
const isReviewed = row.humanReviewedAt != null;
return (
<div className="p-4 space-y-4">
@ -44,8 +46,26 @@ export function CallLogDetail({ row, allRows, onClose, onSaveCorrection }: {
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-900">Call Detail</h2>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-gray-900">Call Detail</h2>
{isReviewed && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-green-100 text-green-800"
title={`Reviewed ${row.humanReviewedAt ? format(toDate(row.humanReviewedAt), "PPpp") : ""}${row.humanReviewedBy ? ` by ${row.humanReviewedBy}` : ""}`}
>
&#10003; Reviewed{row.humanReviewedBy ? ` by ${row.humanReviewedBy}` : ""}
</span>
)}
</div>
<div className="flex items-center gap-2">
{!isReviewed && onMarkReviewed && (
<button
onClick={() => onMarkReviewed(row.correlationId)}
className="px-2.5 py-1 text-xs font-medium text-green-700 bg-green-50 rounded-md hover:bg-green-100 border border-green-200"
>
Mark as reviewed
</button>
)}
<button
onClick={() => setShowReplay(true)}
className="px-2.5 py-1 text-xs font-medium text-purple-600 bg-purple-50 rounded-md hover:bg-purple-100"

View File

@ -2,8 +2,12 @@ import { useEffect, useState, useRef } from "react";
import { DbConnection, type EventContext, type SubscriptionEventContext } from "../module_bindings";
import type { McpCallLogRow } from "../types";
const HOST = process.env.NEXT_PUBLIC_SPACETIMEDB_HOST ?? "";
const DB_NAME = process.env.NEXT_PUBLIC_SPACETIMEDB_DB_NAME ?? "";
const IS_DEV = process.env.NODE_ENV === "development";
const PLACEHOLDER = "REPLACE_ME";
const rawHost = process.env.NEXT_PUBLIC_SPACETIMEDB_HOST;
const rawDbName = process.env.NEXT_PUBLIC_SPACETIMEDB_DB_NAME;
const HOST = (!rawHost || rawHost === PLACEHOLDER) ? (IS_DEV ? "ws://localhost:8139" : "") : rawHost;
const DB_NAME = (!rawDbName || rawDbName === PLACEHOLDER) ? (IS_DEV ? "stack-auth-llm" : "") : rawDbName;
const TOKEN_KEY = `spacetimedb_${HOST}/${DB_NAME}/auth_token`;
const MAX_RETRIES = 5;

View File

@ -1,16 +1,30 @@
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;
const IS_DEV = process.env.NODE_ENV === "development";
const PLACEHOLDER = "REPLACE_ME";
async function post(path: string, body: unknown): Promise<void> {
function envOrDevDefault(value: string | undefined, devDefault: string): string {
if (!value || value === PLACEHOLDER) {
return IS_DEV ? devDefault : "";
}
return value;
}
const PORT_PREFIX = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81";
const API_URL = envOrDevDefault(process.env.NEXT_PUBLIC_STACK_API_URL, `http://localhost:${PORT_PREFIX}02`);
const PROJECT_ID = envOrDevDefault(process.env.NEXT_PUBLIC_STACK_PROJECT_ID, "internal");
const PUBLISHABLE_CLIENT_KEY = envOrDevDefault(
process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY,
"this-publishable-client-key-is-for-local-development-only",
);
async function post(path: string, body: unknown, authHeaders: Record<string, string>): Promise<void> {
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 ?? "",
"x-stack-project-id": PROJECT_ID,
"x-stack-publishable-client-key": PUBLISHABLE_CLIENT_KEY,
...authHeaders,
},
body: JSON.stringify(body),
});
@ -20,25 +34,27 @@ async function post(path: string, body: unknown): Promise<void> {
}
}
export const mcpReviewApi = {
markReviewed: (body: { correlationId: string; reviewedBy: string }) =>
post("mark-reviewed", body),
export function makeMcpReviewApi(authHeaders: Record<string, string>) {
return {
markReviewed: (body: { correlationId: string; reviewedBy: string }) =>
post("mark-reviewed", body, authHeaders),
updateCorrection: (body: {
correlationId: string;
correctedQuestion: string;
correctedAnswer: string;
publish: boolean;
reviewedBy: string;
}) => post("update-correction", body),
updateCorrection: (body: {
correlationId: string;
correctedQuestion: string;
correctedAnswer: string;
publish: boolean;
reviewedBy: string;
}) => post("update-correction", body, authHeaders),
addManual: (body: {
question: string;
answer: string;
publish: boolean;
reviewedBy: string;
}) => post("add-manual", body),
addManual: (body: {
question: string;
answer: string;
publish: boolean;
reviewedBy: string;
}) => post("add-manual", body, authHeaders),
delete: (body: { correlationId: string }) =>
post("delete", body),
};
delete: (body: { correlationId: string }) =>
post("delete", body, authHeaders),
};
}

View File

@ -1,20 +1,37 @@
import { StackClientApp } from "@stackframe/react";
const hostedComponentsUrl = process.env.NEXT_PUBLIC_STACK_HOSTED_COMPONENTS_URL;
const internalToolUrl = process.env.NEXT_PUBLIC_STACK_INTERNAL_TOOL_URL;
const IS_DEV = process.env.NODE_ENV === "development";
const PLACEHOLDER = "REPLACE_ME";
// In dev, fall back to the seeded "internal" project if env vars are placeholders.
// In prod, the real values must be set via hosting platform env vars.
function envOrDevDefault(value: string | undefined, devDefault: string): string {
if (!value || value === PLACEHOLDER) {
if (IS_DEV) return devDefault;
throw new Error("Stack Auth env var is not configured. Set the NEXT_PUBLIC_STACK_* vars in .env.local or hosting platform env.");
}
return value;
}
const portPrefix = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81";
const projectId = envOrDevDefault(process.env.NEXT_PUBLIC_STACK_PROJECT_ID, "internal");
const publishableClientKey = envOrDevDefault(
process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY,
"this-publishable-client-key-is-for-local-development-only",
);
const apiUrl = envOrDevDefault(process.env.NEXT_PUBLIC_STACK_API_URL, `http://localhost:${portPrefix}02`);
export const stackClientApp = new StackClientApp({
projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID,
publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY,
projectId,
publishableClientKey,
tokenStore: "cookie",
redirectMethod: "window",
baseUrl: process.env.NEXT_PUBLIC_STACK_API_URL,
baseUrl: apiUrl,
urls: {
handler: `${hostedComponentsUrl}/handler`,
signIn: `${hostedComponentsUrl}/handler/sign-in`,
signUp: `${hostedComponentsUrl}/handler/sign-up`,
afterSignIn: internalToolUrl,
afterSignUp: internalToolUrl,
afterSignOut: `${hostedComponentsUrl}/handler/sign-in`,
handler: "/handler",
afterSignIn: "/",
afterSignUp: "/",
afterSignOut: "/handler/sign-in",
},
});