mirror of
https://github.com/willchen96/mike.git
synced 2026-07-26 23:51:08 +02:00
1132 lines
36 KiB
TypeScript
1132 lines
36 KiB
TypeScript
import crypto from "crypto";
|
|
import { Router } from "express";
|
|
import { requireAuth, requireMfaIfEnrolled } from "../middleware/auth";
|
|
import { createServerSupabase } from "../lib/supabase";
|
|
import {
|
|
DEFAULT_TABULAR_MODEL,
|
|
DEFAULT_TITLE_MODEL,
|
|
CLAUDE_LOW_MODELS,
|
|
OPENAI_LOW_MODELS,
|
|
resolveModel,
|
|
} from "../lib/llm";
|
|
import {
|
|
type ApiKeyStatus,
|
|
getUserApiKeyStatus,
|
|
hasEnvApiKey,
|
|
normalizeApiKeyProvider,
|
|
saveUserApiKey,
|
|
} from "../lib/userApiKeys";
|
|
import {
|
|
completeUserMcpConnectorOAuth,
|
|
createUserMcpConnector,
|
|
deleteUserMcpConnector,
|
|
getUserMcpConnector,
|
|
listUserMcpConnectors,
|
|
McpOAuthRequiredError,
|
|
refreshUserMcpConnectorTools,
|
|
setUserMcpToolEnabled,
|
|
startUserMcpConnectorOAuth,
|
|
updateUserMcpConnector,
|
|
} from "../lib/mcpConnectors";
|
|
import {
|
|
deleteAllUserChats,
|
|
deleteAllUserTabularReviews,
|
|
deleteUserAccountData,
|
|
deleteUserProjects,
|
|
} from "../lib/userDataCleanup";
|
|
import {
|
|
buildUserAccountExport,
|
|
buildUserChatsExport,
|
|
buildUserTabularReviewsExport,
|
|
userExportFilename,
|
|
} from "../lib/userDataExport";
|
|
import { findProfileUserByEmail } from "../lib/userLookup";
|
|
|
|
export const userRouter = Router();
|
|
|
|
const MONTHLY_CREDIT_LIMIT = 999999;
|
|
|
|
type UserProfileRow = {
|
|
display_name: string | null;
|
|
organisation: string | null;
|
|
message_credits_used: number;
|
|
credits_reset_date: string;
|
|
tier: string;
|
|
title_model: string | null;
|
|
tabular_model: string;
|
|
mfa_on_login: boolean | null;
|
|
legal_research_us: boolean | null;
|
|
};
|
|
|
|
function errorMessage(error: unknown): string {
|
|
if (error instanceof Error && error.message) return error.message;
|
|
if (error && typeof error === "object") {
|
|
const record = error as {
|
|
message?: unknown;
|
|
details?: unknown;
|
|
hint?: unknown;
|
|
code?: unknown;
|
|
};
|
|
return (
|
|
[record.message, record.details, record.hint, record.code]
|
|
.filter(
|
|
(value): value is string =>
|
|
typeof value === "string" && !!value,
|
|
)
|
|
.join(" ") || JSON.stringify(error)
|
|
);
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
function backendPublicUrl(req: {
|
|
protocol: string;
|
|
get(name: string): string | undefined;
|
|
}) {
|
|
return (
|
|
process.env.API_PUBLIC_URL ||
|
|
process.env.BACKEND_URL ||
|
|
`${req.protocol}://${req.get("host")}`
|
|
).replace(/\/+$/, "");
|
|
}
|
|
|
|
function frontendUrl(path = "/account/connectors") {
|
|
const base = (process.env.FRONTEND_URL ?? "http://localhost:3000").replace(
|
|
/\/+$/,
|
|
"",
|
|
);
|
|
return `${base}${path}`;
|
|
}
|
|
|
|
function shortHash(value: string) {
|
|
return value
|
|
? crypto.createHash("sha256").update(value).digest("hex").slice(0, 12)
|
|
: null;
|
|
}
|
|
|
|
function mcpOAuthPopupHtml(payload: {
|
|
success: boolean;
|
|
connectorId?: string;
|
|
detail?: string;
|
|
}, nonce: string) {
|
|
const targetOrigin = new URL(frontendUrl()).origin;
|
|
const targetUrl = frontendUrl();
|
|
const message = JSON.stringify({
|
|
type: "mcp_oauth_result",
|
|
...payload,
|
|
});
|
|
return `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>MCP authorization</title>
|
|
<style>
|
|
body { margin: 0; min-height: 100vh; display: grid; place-items: center; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #111827; background: #f9fafb; }
|
|
main { max-width: 360px; padding: 24px; text-align: center; }
|
|
p { color: #6b7280; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<h1>${payload.success ? "Authorization complete" : "Authorization failed"}</h1>
|
|
<p>${payload.success ? "You can return to Mike." : "Return to Mike and try connecting again."}</p>
|
|
</main>
|
|
<script nonce="${nonce}">
|
|
const message = ${message};
|
|
const targetUrl = ${JSON.stringify(targetUrl)};
|
|
if (window.opener && !window.opener.closed) {
|
|
window.opener.postMessage(message, ${JSON.stringify(targetOrigin)});
|
|
}
|
|
setTimeout(() => window.close(), ${payload.success ? 600 : 2500});
|
|
${
|
|
payload.success
|
|
? "setTimeout(() => window.location.assign(targetUrl), 1000);"
|
|
: ""
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
function mcpOAuthPopupCsp(nonce: string) {
|
|
return [
|
|
"default-src 'none'",
|
|
`script-src 'nonce-${nonce}'`,
|
|
"style-src 'unsafe-inline'",
|
|
"base-uri 'none'",
|
|
"form-action 'none'",
|
|
"frame-ancestors 'none'",
|
|
].join("; ");
|
|
}
|
|
|
|
const PROFILE_SELECT =
|
|
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model, mfa_on_login, legal_research_us";
|
|
const PROFILE_SELECT_NO_LEGAL =
|
|
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model, mfa_on_login";
|
|
const LEGACY_PROFILE_SELECT =
|
|
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model";
|
|
const LEGACY_PROFILE_MODEL_SELECT =
|
|
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model";
|
|
|
|
function isMissingProfileColumn(error: unknown, column: string): boolean {
|
|
const record =
|
|
error && typeof error === "object"
|
|
? (error as { code?: unknown; message?: unknown })
|
|
: {};
|
|
const message = typeof record.message === "string" ? record.message : "";
|
|
return record.code === "42703" && message.includes(column);
|
|
}
|
|
|
|
// Loads a profile while tolerating older databases that lack the
|
|
// legal_research_us column. Tries the full select first, then falls back to
|
|
// the legacy cascade (which also handles missing title_model / mfa_on_login)
|
|
// and defaults the feature flag to enabled.
|
|
async function selectProfile(
|
|
db: ReturnType<typeof createServerSupabase>,
|
|
userId: string,
|
|
mode: "maybe" | "single",
|
|
) {
|
|
const fullQuery = db
|
|
.from("user_profiles")
|
|
.select(PROFILE_SELECT)
|
|
.eq("user_id", userId);
|
|
const full =
|
|
mode === "single"
|
|
? await fullQuery.single()
|
|
: await fullQuery.maybeSingle();
|
|
if (!full.error) return full;
|
|
|
|
const legacy = await selectProfileLegacy(db, userId, mode);
|
|
if (legacy.data && typeof legacy.data === "object") {
|
|
const row = legacy.data as Record<string, unknown>;
|
|
if (!("legal_research_us" in row)) {
|
|
Object.assign(row, { legal_research_us: true });
|
|
}
|
|
}
|
|
return legacy;
|
|
}
|
|
|
|
async function selectProfileLegacy(
|
|
db: ReturnType<typeof createServerSupabase>,
|
|
userId: string,
|
|
mode: "maybe" | "single",
|
|
) {
|
|
const query = db
|
|
.from("user_profiles")
|
|
.select(PROFILE_SELECT_NO_LEGAL)
|
|
.eq("user_id", userId);
|
|
const result =
|
|
mode === "single" ? await query.single() : await query.maybeSingle();
|
|
if (!result.error) {
|
|
return result;
|
|
}
|
|
|
|
const missingMfaOnLogin = isMissingProfileColumn(
|
|
result.error,
|
|
"mfa_on_login",
|
|
);
|
|
if (missingMfaOnLogin) {
|
|
const modelQuery = db
|
|
.from("user_profiles")
|
|
.select(LEGACY_PROFILE_MODEL_SELECT)
|
|
.eq("user_id", userId);
|
|
const modelLegacy =
|
|
mode === "single"
|
|
? await modelQuery.single()
|
|
: await modelQuery.maybeSingle();
|
|
if (
|
|
!modelLegacy.error ||
|
|
!isMissingProfileColumn(modelLegacy.error, "title_model")
|
|
) {
|
|
if (modelLegacy.data && typeof modelLegacy.data === "object") {
|
|
const row = modelLegacy.data as Record<string, unknown>;
|
|
Object.assign(row, {
|
|
mfa_on_login: false,
|
|
});
|
|
}
|
|
return modelLegacy;
|
|
}
|
|
}
|
|
|
|
if (
|
|
!missingMfaOnLogin &&
|
|
!isMissingProfileColumn(result.error, "title_model")
|
|
) {
|
|
return result;
|
|
}
|
|
|
|
const legacyQuery = db
|
|
.from("user_profiles")
|
|
.select(LEGACY_PROFILE_SELECT)
|
|
.eq("user_id", userId);
|
|
const legacy =
|
|
mode === "single"
|
|
? await legacyQuery.single()
|
|
: await legacyQuery.maybeSingle();
|
|
if (legacy.data && typeof legacy.data === "object") {
|
|
const row = legacy.data as Record<string, unknown>;
|
|
Object.assign(row, {
|
|
title_model: null,
|
|
mfa_on_login: false,
|
|
});
|
|
}
|
|
return legacy;
|
|
}
|
|
|
|
function serializeProfile(row: UserProfileRow, apiKeyStatus?: ApiKeyStatus) {
|
|
const creditsUsed = row.message_credits_used ?? 0;
|
|
const titleFallback = apiKeyStatus?.gemini
|
|
? DEFAULT_TITLE_MODEL
|
|
: apiKeyStatus?.openai
|
|
? OPENAI_LOW_MODELS[0]
|
|
: apiKeyStatus?.claude
|
|
? CLAUDE_LOW_MODELS[0]
|
|
: DEFAULT_TITLE_MODEL;
|
|
return {
|
|
displayName: row.display_name,
|
|
organisation: row.organisation,
|
|
messageCreditsUsed: creditsUsed,
|
|
creditsResetDate: row.credits_reset_date,
|
|
creditsRemaining: Math.max(MONTHLY_CREDIT_LIMIT - creditsUsed, 0),
|
|
tier: row.tier || "Free",
|
|
titleModel: resolveModel(row.title_model, titleFallback),
|
|
tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL),
|
|
mfaOnLogin: row.mfa_on_login === true,
|
|
legalResearchUs: row.legal_research_us !== false,
|
|
...(apiKeyStatus ? { apiKeyStatus } : {}),
|
|
};
|
|
}
|
|
|
|
function validateProfilePayload(body: unknown):
|
|
| {
|
|
ok: true;
|
|
update: {
|
|
display_name?: string | null;
|
|
organisation?: string | null;
|
|
title_model?: string;
|
|
tabular_model?: string;
|
|
legal_research_us?: boolean;
|
|
updated_at: string;
|
|
};
|
|
}
|
|
| { ok: false; detail: string } {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
return { ok: false, detail: "Expected a JSON object" };
|
|
}
|
|
|
|
const raw = body as Record<string, unknown>;
|
|
const allowedFields = new Set([
|
|
"displayName",
|
|
"organisation",
|
|
"titleModel",
|
|
"tabularModel",
|
|
"legalResearchUs",
|
|
]);
|
|
const invalidField = Object.keys(raw).find(
|
|
(key) => !allowedFields.has(key),
|
|
);
|
|
if (invalidField) {
|
|
return {
|
|
ok: false,
|
|
detail: `Unsupported profile field: ${invalidField}`,
|
|
};
|
|
}
|
|
|
|
const update: {
|
|
display_name?: string | null;
|
|
organisation?: string | null;
|
|
title_model?: string;
|
|
tabular_model?: string;
|
|
legal_research_us?: boolean;
|
|
updated_at: string;
|
|
} = { updated_at: new Date().toISOString() };
|
|
|
|
if ("displayName" in raw) {
|
|
if (raw.displayName !== null && typeof raw.displayName !== "string") {
|
|
return {
|
|
ok: false,
|
|
detail: "displayName must be a string or null",
|
|
};
|
|
}
|
|
update.display_name = raw.displayName?.trim() || null;
|
|
}
|
|
|
|
if ("organisation" in raw) {
|
|
if (raw.organisation !== null && typeof raw.organisation !== "string") {
|
|
return {
|
|
ok: false,
|
|
detail: "organisation must be a string or null",
|
|
};
|
|
}
|
|
update.organisation = raw.organisation?.trim() || null;
|
|
}
|
|
|
|
if ("tabularModel" in raw) {
|
|
if (typeof raw.tabularModel !== "string") {
|
|
return { ok: false, detail: "tabularModel must be a string" };
|
|
}
|
|
const resolved = resolveModel(raw.tabularModel, "");
|
|
if (!resolved) {
|
|
return { ok: false, detail: "Unsupported tabularModel" };
|
|
}
|
|
update.tabular_model = resolved;
|
|
}
|
|
|
|
if ("titleModel" in raw) {
|
|
if (typeof raw.titleModel !== "string") {
|
|
return { ok: false, detail: "titleModel must be a string" };
|
|
}
|
|
const resolved = resolveModel(raw.titleModel, "");
|
|
if (!resolved) {
|
|
return { ok: false, detail: "Unsupported titleModel" };
|
|
}
|
|
update.title_model = resolved;
|
|
}
|
|
|
|
if ("legalResearchUs" in raw) {
|
|
if (typeof raw.legalResearchUs !== "boolean") {
|
|
return {
|
|
ok: false,
|
|
detail: "legalResearchUs must be a boolean",
|
|
};
|
|
}
|
|
update.legal_research_us = raw.legalResearchUs;
|
|
}
|
|
|
|
return { ok: true, update };
|
|
}
|
|
|
|
function readBooleanBodyField(
|
|
body: unknown,
|
|
field: string,
|
|
): { ok: true; value: boolean } | { ok: false; detail: string } {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
return { ok: false, detail: "Expected a JSON object" };
|
|
}
|
|
|
|
const raw = body as Record<string, unknown>;
|
|
const invalidField = Object.keys(raw).find((key) => key !== field);
|
|
if (invalidField) {
|
|
return { ok: false, detail: `Unsupported field: ${invalidField}` };
|
|
}
|
|
if (typeof raw[field] !== "boolean") {
|
|
return { ok: false, detail: `${field} must be a boolean` };
|
|
}
|
|
|
|
return { ok: true, value: raw[field] };
|
|
}
|
|
|
|
async function userHasVerifiedTotpFactor(
|
|
db: ReturnType<typeof createServerSupabase>,
|
|
userId: string,
|
|
) {
|
|
const { data, error } = await db.auth.admin.getUserById(userId);
|
|
if (error) return { ok: false as const, error };
|
|
|
|
const factors = data.user?.factors ?? [];
|
|
return {
|
|
ok: true as const,
|
|
hasVerifiedTotp: factors.some(
|
|
(factor) =>
|
|
factor.factor_type === "totp" && factor.status === "verified",
|
|
),
|
|
};
|
|
}
|
|
|
|
async function ensureProfileRow(
|
|
db: ReturnType<typeof createServerSupabase>,
|
|
userId: string,
|
|
) {
|
|
const { error } = await db
|
|
.from("user_profiles")
|
|
.upsert(
|
|
{ user_id: userId },
|
|
{ onConflict: "user_id", ignoreDuplicates: true },
|
|
);
|
|
return error;
|
|
}
|
|
|
|
async function loadProfile(
|
|
db: ReturnType<typeof createServerSupabase>,
|
|
userId: string,
|
|
options: { repairMissing?: boolean; apiKeyStatus?: ApiKeyStatus } = {},
|
|
) {
|
|
let { data, error } = await selectProfile(db, userId, "maybe");
|
|
|
|
if (error) return { data: null, error };
|
|
if (!data) {
|
|
if (!options.repairMissing) {
|
|
return { data: null, error: new Error("Profile not found") };
|
|
}
|
|
|
|
const ensureError = await ensureProfileRow(db, userId);
|
|
if (ensureError) return { data: null, error: ensureError };
|
|
|
|
const created = await selectProfile(db, userId, "single");
|
|
if (created.error) return { data: null, error: created.error };
|
|
data = created.data;
|
|
}
|
|
|
|
let row = data as UserProfileRow;
|
|
if (
|
|
row.credits_reset_date &&
|
|
new Date() > new Date(row.credits_reset_date)
|
|
) {
|
|
const creditsResetDate = new Date();
|
|
creditsResetDate.setDate(creditsResetDate.getDate() + 30);
|
|
const { error: resetError } = await db
|
|
.from("user_profiles")
|
|
.update({
|
|
message_credits_used: 0,
|
|
credits_reset_date: creditsResetDate.toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq("user_id", userId);
|
|
|
|
if (resetError) return { data: null, error: resetError };
|
|
const { data: resetData, error: resetLoadError } = await selectProfile(
|
|
db,
|
|
userId,
|
|
"single",
|
|
);
|
|
if (resetLoadError) return { data: null, error: resetLoadError };
|
|
row = resetData as UserProfileRow;
|
|
}
|
|
|
|
return { data: serializeProfile(row, options.apiKeyStatus), error: null };
|
|
}
|
|
|
|
// POST /user/profile
|
|
userRouter.post("/profile", requireAuth, async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
const error = await ensureProfileRow(db, userId);
|
|
if (error) return void res.status(500).json({ detail: error.message });
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// GET /user/lookup?email=person@example.com
|
|
userRouter.get("/lookup", requireAuth, async (req, res) => {
|
|
const email = typeof req.query.email === "string" ? req.query.email : "";
|
|
if (!email.trim()) {
|
|
return void res.status(400).json({ detail: "email is required" });
|
|
}
|
|
|
|
const db = createServerSupabase();
|
|
const user = await findProfileUserByEmail(db, email);
|
|
res.json({
|
|
exists: !!user,
|
|
email: user?.email ?? email.trim().toLowerCase(),
|
|
display_name: user?.display_name ?? null,
|
|
});
|
|
});
|
|
|
|
// GET /user/profile
|
|
userRouter.get("/profile", requireAuth, async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
|
|
const { data, error } = await loadProfile(db, userId, {
|
|
repairMissing: true,
|
|
apiKeyStatus,
|
|
});
|
|
if (error) return void res.status(500).json({ detail: error.message });
|
|
res.json({ ...data, apiKeyStatus });
|
|
});
|
|
|
|
// PATCH /user/profile
|
|
userRouter.patch("/profile", requireAuth, async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const parsed = validateProfilePayload(req.body);
|
|
if (!parsed.ok) return void res.status(400).json({ detail: parsed.detail });
|
|
|
|
const db = createServerSupabase();
|
|
const ensureError = await ensureProfileRow(db, userId);
|
|
if (ensureError)
|
|
return void res.status(500).json({ detail: ensureError.message });
|
|
|
|
const { error: updateError } = await db
|
|
.from("user_profiles")
|
|
.update(parsed.update)
|
|
.eq("user_id", userId);
|
|
if (updateError)
|
|
return void res.status(500).json({ detail: updateError.message });
|
|
|
|
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
|
|
const { data, error } = await loadProfile(db, userId, { apiKeyStatus });
|
|
if (error) return void res.status(500).json({ detail: error.message });
|
|
res.json({ ...data, apiKeyStatus });
|
|
});
|
|
|
|
// PATCH /user/security/mfa-login
|
|
userRouter.patch(
|
|
"/security/mfa-login",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const parsed = readBooleanBodyField(req.body, "enabled");
|
|
if (!parsed.ok)
|
|
return void res.status(400).json({ detail: parsed.detail });
|
|
|
|
const db = createServerSupabase();
|
|
if (parsed.value) {
|
|
const factorCheck = await userHasVerifiedTotpFactor(db, userId);
|
|
if (!factorCheck.ok) {
|
|
return void res.status(500).json({
|
|
detail: factorCheck.error.message,
|
|
});
|
|
}
|
|
if (!factorCheck.hasVerifiedTotp) {
|
|
return void res.status(400).json({
|
|
detail: "Set up an authenticator app before requiring verification on login.",
|
|
});
|
|
}
|
|
}
|
|
|
|
const ensureError = await ensureProfileRow(db, userId);
|
|
if (ensureError)
|
|
return void res.status(500).json({ detail: ensureError.message });
|
|
|
|
const { error: updateError } = await db
|
|
.from("user_profiles")
|
|
.update({
|
|
mfa_on_login: parsed.value,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq("user_id", userId);
|
|
if (updateError)
|
|
return void res.status(500).json({ detail: updateError.message });
|
|
|
|
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
|
|
const { data, error } = await loadProfile(db, userId, { apiKeyStatus });
|
|
if (error) return void res.status(500).json({ detail: error.message });
|
|
res.json({ ...data, apiKeyStatus });
|
|
},
|
|
);
|
|
|
|
// GET /user/api-keys
|
|
userRouter.get("/api-keys", requireAuth, async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
const status = await getUserApiKeyStatus(userId, db);
|
|
res.json(status);
|
|
});
|
|
|
|
// PUT /user/api-keys/:provider
|
|
userRouter.put(
|
|
"/api-keys/:provider",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const provider = normalizeApiKeyProvider(req.params.provider);
|
|
if (!provider)
|
|
return void res
|
|
.status(400)
|
|
.json({ detail: "Unsupported provider" });
|
|
|
|
const apiKey =
|
|
typeof req.body?.api_key === "string" ? req.body.api_key : null;
|
|
const db = createServerSupabase();
|
|
try {
|
|
if (hasEnvApiKey(provider)) {
|
|
return void res.status(409).json({
|
|
detail: "This provider is configured by the server environment and cannot be changed from the browser.",
|
|
});
|
|
}
|
|
await saveUserApiKey(userId, provider, apiKey, db);
|
|
const status = await getUserApiKeyStatus(userId, db);
|
|
res.json(status);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/api-keys] save failed", {
|
|
provider,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// GET /user/mcp-connectors
|
|
userRouter.get("/mcp-connectors", requireAuth, async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
try {
|
|
res.json(
|
|
await listUserMcpConnectors(userId, db, { includeTools: false }),
|
|
);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] list failed", {
|
|
userId,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
});
|
|
|
|
// GET /user/mcp-connectors/:connectorId
|
|
userRouter.get(
|
|
"/mcp-connectors/:connectorId",
|
|
requireAuth,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
try {
|
|
res.json(
|
|
await getUserMcpConnector(userId, req.params.connectorId, db),
|
|
);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] get failed", {
|
|
userId,
|
|
connectorId: req.params.connectorId,
|
|
error: detail,
|
|
});
|
|
res.status(404).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// POST /user/mcp-connectors
|
|
userRouter.post(
|
|
"/mcp-connectors",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const name = typeof req.body?.name === "string" ? req.body.name : "";
|
|
const serverUrl =
|
|
typeof req.body?.serverUrl === "string" ? req.body.serverUrl : "";
|
|
const bearerToken =
|
|
typeof req.body?.bearerToken === "string"
|
|
? req.body.bearerToken
|
|
: null;
|
|
const headers =
|
|
req.body?.headers &&
|
|
typeof req.body.headers === "object" &&
|
|
!Array.isArray(req.body.headers)
|
|
? (req.body.headers as Record<string, unknown>)
|
|
: undefined;
|
|
const db = createServerSupabase();
|
|
try {
|
|
const connector = await createUserMcpConnector(
|
|
userId,
|
|
{ name, serverUrl, bearerToken, headers },
|
|
db,
|
|
);
|
|
res.status(201).json(connector);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] create failed", {
|
|
userId,
|
|
error: detail,
|
|
});
|
|
res.status(400).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// PATCH /user/mcp-connectors/:connectorId
|
|
userRouter.patch(
|
|
"/mcp-connectors/:connectorId",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
const body = req.body ?? {};
|
|
try {
|
|
const connector = await updateUserMcpConnector(
|
|
userId,
|
|
req.params.connectorId,
|
|
{
|
|
...(typeof body.name === "string"
|
|
? { name: body.name }
|
|
: {}),
|
|
...(typeof body.serverUrl === "string"
|
|
? { serverUrl: body.serverUrl }
|
|
: {}),
|
|
...(typeof body.enabled === "boolean"
|
|
? { enabled: body.enabled }
|
|
: {}),
|
|
...("bearerToken" in body
|
|
? {
|
|
bearerToken:
|
|
typeof body.bearerToken === "string"
|
|
? body.bearerToken
|
|
: null,
|
|
}
|
|
: {}),
|
|
...("headers" in body
|
|
? {
|
|
headers:
|
|
body.headers &&
|
|
typeof body.headers === "object" &&
|
|
!Array.isArray(body.headers)
|
|
? (body.headers as Record<
|
|
string,
|
|
unknown
|
|
>)
|
|
: {},
|
|
}
|
|
: {}),
|
|
},
|
|
db,
|
|
);
|
|
res.json(connector);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] update failed", {
|
|
userId,
|
|
connectorId: req.params.connectorId,
|
|
error: detail,
|
|
});
|
|
res.status(400).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// DELETE /user/mcp-connectors/:connectorId
|
|
userRouter.delete(
|
|
"/mcp-connectors/:connectorId",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
try {
|
|
await deleteUserMcpConnector(userId, req.params.connectorId, db);
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] delete failed", {
|
|
userId,
|
|
connectorId: req.params.connectorId,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// POST /user/mcp-connectors/:connectorId/oauth/start
|
|
userRouter.post(
|
|
"/mcp-connectors/:connectorId/oauth/start",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
try {
|
|
const redirectUri = `${backendPublicUrl(req)}/user/mcp-connectors/oauth/callback`;
|
|
const result = await startUserMcpConnectorOAuth(
|
|
userId,
|
|
req.params.connectorId,
|
|
redirectUri,
|
|
db,
|
|
);
|
|
res.json(result);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] oauth start failed", {
|
|
userId,
|
|
connectorId: req.params.connectorId,
|
|
error: detail,
|
|
});
|
|
res.status(400).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// GET /user/mcp-connectors/oauth/callback
|
|
userRouter.get("/mcp-connectors/oauth/callback", async (req, res) => {
|
|
const nonce = crypto.randomBytes(16).toString("base64");
|
|
const state = typeof req.query.state === "string" ? req.query.state : "";
|
|
const code = typeof req.query.code === "string" ? req.query.code : "";
|
|
const error =
|
|
typeof req.query.error === "string" ? req.query.error : undefined;
|
|
const db = createServerSupabase();
|
|
try {
|
|
if (error) throw new Error(error);
|
|
if (!state || !code)
|
|
throw new Error("OAuth callback is missing state or code.");
|
|
const result = await completeUserMcpConnectorOAuth(state, code, db);
|
|
res.set("Content-Security-Policy", mcpOAuthPopupCsp(nonce))
|
|
.type("html")
|
|
.send(
|
|
mcpOAuthPopupHtml(
|
|
{
|
|
success: true,
|
|
connectorId: result.connectorId,
|
|
},
|
|
nonce,
|
|
),
|
|
);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] oauth callback failed", {
|
|
error: detail,
|
|
stateHash: shortHash(state),
|
|
hasCode: !!code,
|
|
hasError: !!error,
|
|
issuer:
|
|
typeof req.query.iss === "string" ? req.query.iss : undefined,
|
|
scope:
|
|
typeof req.query.scope === "string"
|
|
? req.query.scope
|
|
: undefined,
|
|
});
|
|
res.status(400)
|
|
.set("Content-Security-Policy", mcpOAuthPopupCsp(nonce))
|
|
.type("html")
|
|
.send(mcpOAuthPopupHtml({ success: false, detail }, nonce));
|
|
}
|
|
});
|
|
|
|
// POST /user/mcp-connectors/:connectorId/refresh-tools
|
|
userRouter.post(
|
|
"/mcp-connectors/:connectorId/refresh-tools",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
try {
|
|
const connector = await refreshUserMcpConnectorTools(
|
|
userId,
|
|
req.params.connectorId,
|
|
db,
|
|
);
|
|
res.json(connector);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] refresh failed", {
|
|
userId,
|
|
connectorId: req.params.connectorId,
|
|
error: detail,
|
|
});
|
|
if (err instanceof McpOAuthRequiredError) {
|
|
return void res.status(401).json({
|
|
code: err.code,
|
|
detail,
|
|
});
|
|
}
|
|
res.status(400).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// PATCH /user/mcp-connectors/:connectorId/tools/:toolId
|
|
userRouter.patch(
|
|
"/mcp-connectors/:connectorId/tools/:toolId",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const parsed = readBooleanBodyField(req.body, "enabled");
|
|
if (!parsed.ok)
|
|
return void res.status(400).json({ detail: parsed.detail });
|
|
|
|
const db = createServerSupabase();
|
|
try {
|
|
const connector = await setUserMcpToolEnabled(
|
|
userId,
|
|
req.params.connectorId,
|
|
req.params.toolId,
|
|
parsed.value,
|
|
db,
|
|
);
|
|
res.json(connector);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/mcp-connectors] tool toggle failed", {
|
|
userId,
|
|
connectorId: req.params.connectorId,
|
|
toolId: req.params.toolId,
|
|
error: detail,
|
|
});
|
|
res.status(400).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// DELETE /user/account
|
|
userRouter.delete(
|
|
"/account",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const userEmail = res.locals.userEmail as string | undefined;
|
|
const db = createServerSupabase();
|
|
try {
|
|
await deleteUserAccountData(db, userId, userEmail);
|
|
const { error } = await db.auth.admin.deleteUser(userId);
|
|
if (error)
|
|
return void res.status(500).json({ detail: error.message });
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/account] delete failed", {
|
|
userId,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// DELETE /user/chats
|
|
userRouter.delete(
|
|
"/chats",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
try {
|
|
await deleteAllUserChats(db, userId);
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/chats] delete failed", {
|
|
userId,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// DELETE /user/projects
|
|
userRouter.delete(
|
|
"/projects",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
try {
|
|
await deleteUserProjects(db, userId);
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/projects] delete failed", {
|
|
userId,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// DELETE /user/tabular-reviews
|
|
userRouter.delete(
|
|
"/tabular-reviews",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const db = createServerSupabase();
|
|
try {
|
|
await deleteAllUserTabularReviews(db, userId);
|
|
res.status(204).send();
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/tabular-reviews] delete failed", {
|
|
userId,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// GET /user/export
|
|
userRouter.get(
|
|
"/export",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const userEmail = res.locals.userEmail as string | undefined;
|
|
const db = createServerSupabase();
|
|
try {
|
|
const data = await buildUserAccountExport(db, userId, userEmail);
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="${userExportFilename("account", userId)}"`,
|
|
);
|
|
res.json(data);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/export] failed", { userId, error: detail });
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// GET /user/chats/export
|
|
userRouter.get(
|
|
"/chats/export",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const userEmail = res.locals.userEmail as string | undefined;
|
|
const db = createServerSupabase();
|
|
try {
|
|
const data = await buildUserChatsExport(db, userId, userEmail);
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="${userExportFilename("chats", userId)}"`,
|
|
);
|
|
res.json(data);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/chats/export] failed", {
|
|
userId,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|
|
|
|
// GET /user/tabular-reviews/export
|
|
userRouter.get(
|
|
"/tabular-reviews/export",
|
|
requireAuth,
|
|
requireMfaIfEnrolled,
|
|
async (_req, res) => {
|
|
const userId = res.locals.userId as string;
|
|
const userEmail = res.locals.userEmail as string | undefined;
|
|
const db = createServerSupabase();
|
|
try {
|
|
const data = await buildUserTabularReviewsExport(
|
|
db,
|
|
userId,
|
|
userEmail,
|
|
);
|
|
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="${userExportFilename("tabular-reviews", userId)}"`,
|
|
);
|
|
res.json(data);
|
|
} catch (err) {
|
|
const detail = errorMessage(err);
|
|
console.error("[user/tabular-reviews/export] failed", {
|
|
userId,
|
|
error: detail,
|
|
});
|
|
res.status(500).json({ detail });
|
|
}
|
|
},
|
|
);
|