mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-07 15:09:41 +00:00
* feat: wire Keenable web-search provider into config, schema, and UI Keenable landed as a search provider in @librechat/agents (#285, shipped in 3.2.58+), but LibreChat did not yet expose it. This adds the config/schema/UI glue so it can be selected, mirroring the existing Tavily provider. - data-provider: add `keenable` to SearchProvider type + SearchProviders enum, keenableApiKey/keenableApiUrl schema fields, and a keenableSearchOptions block (maxResults, site, attributionTitle, timeout). - data-schemas: register keenable in webSearchAuth.providers and default the key/URL placeholders in loadWebSearchConfig. - api/web: pass keenableSearchOptions through to the provider and handle Keenable's keyless model. Unlike other providers it authenticates with no key (the public endpoint), picking up an optional key/URL when set; the URL override is SSRF-preflighted like other user-provided URLs. - client: add Keenable to the provider dropdown with an optional API-key input. - docs: document KEENABLE_API_KEY/KEENABLE_API_URL in .env.example and a webSearch example in librechat.example.yaml. - tests: keyless + keyed auth resolution, config defaults, and schema parsing. * fix: ESLint no-unused-vars and clarify Keenable yaml example - Remove the now-unused RerankerTypes import in data-schemas web.ts (the lint job runs with --max-warnings 0 on changed files, so this latent warning failed CI once the file was touched). - Note in the librechat.example.yaml Keenable stanza that a scraper (and reranker) is still required for web search to load, and include a Firecrawl scraper in the example. * chore: fix import order drift (sort-imports) * feat: add Keenable as a keyless scraper and select it without a pinned provider The Keenable scraper landed in @librechat/agents#337, so wire the scraper category the same way the search provider already is: `scraperProvider: keenable` reads pages through Keenable's public fetch endpoint with no key (a key only lifts rate limits, and the endpoint is overridden with KEENABLE_FETCH_URL). Paired with `rerankerType: none` this makes a fully keyless web-search stack possible for the first time. Also closes the Codex finding on this PR: because none of Keenable's auth fields are required, the generic auth loop skips it whenever it isn't pinned, so a key submitted through the API-key dialog (which cannot pin a provider) left the providers category unauthenticated. Keenable is now selected in that case, gated on one of its values actually being present so installs that configured nothing keep their current behavior. The scraper gets the same fallback, additionally gated on Keenable being the resolved search provider, so it never silently scrapes for another provider. * fix: select the Keenable scraper from a supplied key, not only for Keenable search The API-key dialog submits credentials and cannot pin a provider, so choosing Keenable as the scraper while search stays on Serper/SearXNG/Tavily had no effect: the unpinned-scraper fallback required Keenable to also be the resolved search provider. A supplied Keenable value now triggers it as well, which is the only signal the dialog can send. The fallback still runs only when no keyed scraper authenticated, and with neither trigger the category stays unauthenticated, so a deployment that never configured Keenable is unaffected. Note the fully keyless choice still cannot be expressed through the dialog: Keenable's key is optional, so picking it with no key submits nothing at all. librechat.example.yaml now documents pinning scraperProvider: keenable for that case. * style: Sort Keenable imports * fix: Harden Keenable auth resolution * fix: Preserve Keenable selection intent * fix: Fail closed on invalid web search auth * fix: close keenable auth gaps * style: sort web auth imports * fix: preserve web search selection integrity * fix: isolate web search auth ordering * fix: silence expected credential misses * chore: bump agents sdk * fix: preserve web search preference ownership * fix: forward cleared Keenable endpoint * style: sort web search hook imports * fix: require intent for credential clears --------- Co-authored-by: Ilya Bogin <ilya.bogin@keenable.ai>
765 lines
27 KiB
JavaScript
765 lines
27 KiB
JavaScript
const mongoose = require('mongoose');
|
|
const { logger, getTenantId } = require('@librechat/data-schemas');
|
|
const {
|
|
getNewS3URL,
|
|
needsRefresh,
|
|
GenerationJobManager,
|
|
MCPOAuthHandler,
|
|
MCPTokenStorage,
|
|
getAppConfigOptionsFromUser,
|
|
normalizeHttpError,
|
|
getWebSearchInstallEntries,
|
|
getWebSearchUninstallFields,
|
|
deleteAgentCheckpoints,
|
|
deleteAllSharedLinksWithCleanup,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Tools,
|
|
CacheKeys,
|
|
Constants,
|
|
FileSources,
|
|
ResourceType,
|
|
} = require('librechat-data-provider');
|
|
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
|
const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
|
|
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
|
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
|
|
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
|
|
const { processDeleteRequest } = require('~/server/services/Files/process');
|
|
const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore');
|
|
const {
|
|
drainAgentTriggerDeliveriesForUser,
|
|
prepareAgentTriggerUserPurge,
|
|
cancelAgentTriggerUserPurge,
|
|
purgeAgentTriggerDeliveriesForUser,
|
|
} = require('~/server/services/Agents/triggers');
|
|
const { getAppConfig } = require('~/server/services/Config');
|
|
const { randomUUID } = require('node:crypto');
|
|
const {
|
|
quiesceUserSchedules,
|
|
restoreUserSchedulesFromDeletion,
|
|
} = require('~/server/services/Schedules');
|
|
const { getLogStores } = require('~/cache');
|
|
const db = require('~/models');
|
|
|
|
const PUBLIC_USER_RESPONSE_FIELDS = [
|
|
'_id',
|
|
'id',
|
|
'name',
|
|
'username',
|
|
'email',
|
|
'emailVerified',
|
|
'avatar',
|
|
'provider',
|
|
'role',
|
|
'plugins',
|
|
'twoFactorEnabled',
|
|
'termsAccepted',
|
|
'personalization',
|
|
'favorites',
|
|
'skillStates',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'tenantId',
|
|
];
|
|
|
|
const sanitizeUserForResponse = (user) => {
|
|
const source = user.toObject != null ? user.toObject() : user;
|
|
return PUBLIC_USER_RESPONSE_FIELDS.reduce((userData, field) => {
|
|
if (source[field] !== undefined) {
|
|
userData[field] = source[field];
|
|
}
|
|
return userData;
|
|
}, {});
|
|
};
|
|
|
|
const getUserController = async (req, res) => {
|
|
const appConfig = req.config ?? (await getAppConfig(getAppConfigOptionsFromUser(req.user)));
|
|
/** @type {IUser} */
|
|
const userData = sanitizeUserForResponse(req.user);
|
|
if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) {
|
|
const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600);
|
|
if (!avatarNeedsRefresh) {
|
|
return res.status(200).send(userData);
|
|
}
|
|
const originalAvatar = userData.avatar;
|
|
try {
|
|
userData.avatar = await getNewS3URL(userData.avatar);
|
|
await db.updateUser(userData.id, { avatar: userData.avatar });
|
|
} catch (error) {
|
|
userData.avatar = originalAvatar;
|
|
logger.error('Error getting new S3 URL for avatar:', error);
|
|
}
|
|
}
|
|
res.status(200).send(userData);
|
|
};
|
|
|
|
const getTermsStatusController = async (req, res) => {
|
|
try {
|
|
const user = await db.getUserById(req.user.id, 'termsAccepted termsAcceptedAt');
|
|
if (!user) {
|
|
return res.status(404).json({ message: 'User not found' });
|
|
}
|
|
res.status(200).json({
|
|
termsAccepted: !!user.termsAccepted,
|
|
termsAcceptedAt: user.termsAcceptedAt || null,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error fetching terms acceptance status:', error);
|
|
res.status(500).json({ message: 'Error fetching terms acceptance status' });
|
|
}
|
|
};
|
|
|
|
const acceptTermsController = async (req, res) => {
|
|
try {
|
|
const user = await db.acceptTerms(req.user.id);
|
|
if (!user) {
|
|
return res.status(404).json({ message: 'User not found' });
|
|
}
|
|
res.status(200).json({
|
|
message: 'Terms accepted successfully',
|
|
termsAcceptedAt: user.termsAcceptedAt,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error accepting terms:', error);
|
|
res.status(500).json({ message: 'Error accepting terms' });
|
|
}
|
|
};
|
|
|
|
const deleteUserFiles = async (req) => {
|
|
try {
|
|
const userFiles = await db.getFiles({ user: req.user.id });
|
|
await processDeleteRequest({
|
|
req,
|
|
files: userFiles,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[deleteUserFiles]', error);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Deletes MCP servers solely owned by the user and cleans up their ACLs.
|
|
* Disconnects live sessions for deleted servers before removing DB records.
|
|
* Servers with other owners are left intact; the caller is responsible for
|
|
* removing the user's own ACL principal entries separately.
|
|
*
|
|
* Also handles legacy (pre-ACL) MCP servers that only have the author field set,
|
|
* ensuring they are not orphaned if no permission migration has been run.
|
|
* @param {string} userId - The ID of the user.
|
|
*/
|
|
const deleteUserMcpServers = async (userId) => {
|
|
try {
|
|
const MCPServer = mongoose.models.MCPServer;
|
|
const AclEntry = mongoose.models.AclEntry;
|
|
if (!MCPServer) {
|
|
return;
|
|
}
|
|
|
|
const userObjectId = new mongoose.Types.ObjectId(userId);
|
|
const soleOwnedIds = await db.getSoleOwnedResourceIds(userObjectId, ResourceType.MCPSERVER);
|
|
|
|
const authoredServers = await MCPServer.find({ author: userObjectId })
|
|
.select('_id serverName')
|
|
.lean();
|
|
|
|
const migratedEntries =
|
|
authoredServers.length > 0
|
|
? await AclEntry.find({
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: { $in: authoredServers.map((s) => s._id) },
|
|
})
|
|
.select('resourceId')
|
|
.lean()
|
|
: [];
|
|
const migratedIds = new Set(migratedEntries.map((e) => e.resourceId.toString()));
|
|
const legacyServers = authoredServers.filter((s) => !migratedIds.has(s._id.toString()));
|
|
const legacyServerIds = legacyServers.map((s) => s._id);
|
|
|
|
const allServerIdsToDelete = [...soleOwnedIds, ...legacyServerIds];
|
|
|
|
if (allServerIdsToDelete.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const aclOwnedServers =
|
|
soleOwnedIds.length > 0
|
|
? await MCPServer.find({ _id: { $in: soleOwnedIds } })
|
|
.select('serverName')
|
|
.lean()
|
|
: [];
|
|
const allServersToDelete = [...aclOwnedServers, ...legacyServers];
|
|
|
|
const mcpManager = getMCPManager();
|
|
await Promise.allSettled(
|
|
allServersToDelete.map(async (s) => {
|
|
try {
|
|
await invalidateCachedTools({ userId, serverName: s.serverName });
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[deleteUserMcpServers] Failed to invalidate tools for ${s.serverName}:`,
|
|
error,
|
|
);
|
|
} finally {
|
|
try {
|
|
await mcpManager?.disconnectUserConnection(userId, s.serverName);
|
|
} catch (error) {
|
|
logger.warn(`[deleteUserMcpServers] Failed to disconnect ${s.serverName}:`, error);
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
await AclEntry.deleteMany({
|
|
resourceType: ResourceType.MCPSERVER,
|
|
resourceId: { $in: allServerIdsToDelete },
|
|
});
|
|
|
|
await MCPServer.deleteMany({ _id: { $in: allServerIdsToDelete } });
|
|
} catch (error) {
|
|
logger.error('[deleteUserMcpServers] General error:', error);
|
|
}
|
|
};
|
|
|
|
const updateUserPluginsController = async (req, res) => {
|
|
const appConfig = req.config ?? (await getAppConfig(getAppConfigOptionsFromUser(req.user)));
|
|
const { user } = req;
|
|
const { pluginKey, action, auth, isEntityTool } = req.body;
|
|
try {
|
|
if (!isEntityTool) {
|
|
await db.updateUserPlugins(user._id, user.plugins, pluginKey, action);
|
|
}
|
|
|
|
if (auth == null) {
|
|
return res.status(200).send();
|
|
}
|
|
|
|
let authEntries = Object.entries(auth);
|
|
|
|
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
|
|
|
|
// Early exit condition:
|
|
// If auth is empty (meaning auth: {} was likely sent for uninstall or install)
|
|
// AND it's not web_search (which expands its uninstall fields)
|
|
// AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
|
|
// THEN return.
|
|
if (
|
|
authEntries.length === 0 &&
|
|
pluginKey !== Tools.web_search &&
|
|
!(action === 'uninstall' && isMCPTool)
|
|
) {
|
|
return res.status(200).send();
|
|
}
|
|
|
|
/** @type {number} */
|
|
let status = 200;
|
|
/** @type {string} */
|
|
let message;
|
|
/** @type {IPluginAuth | Error} */
|
|
let authService;
|
|
|
|
if (pluginKey === Tools.web_search) {
|
|
/** @type {TCustomConfig['webSearch']} */
|
|
const webSearchConfig = appConfig?.webSearch;
|
|
authEntries =
|
|
action === 'install'
|
|
? getWebSearchInstallEntries({ auth, config: webSearchConfig })
|
|
: getWebSearchUninstallFields(webSearchConfig).map((field) => [field, '']);
|
|
}
|
|
|
|
if (action === 'install') {
|
|
for (const [field, value] of authEntries) {
|
|
authService =
|
|
pluginKey === Tools.web_search && value === ''
|
|
? await deleteUserPluginAuth(user.id, field)
|
|
: await updateUserPluginAuth(user.id, field, pluginKey, value);
|
|
if (authService instanceof Error) {
|
|
logger.error('[authService]', authService);
|
|
({ status, message } = normalizeHttpError(authService));
|
|
if (pluginKey === Tools.web_search) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else if (action === 'uninstall') {
|
|
// const isMCPTool was defined earlier
|
|
if (isMCPTool && authEntries.length === 0) {
|
|
// This handles the case where auth: {} is sent for an MCP tool uninstall.
|
|
// It means "delete all credentials associated with this MCP pluginKey".
|
|
authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
|
|
if (authService instanceof Error) {
|
|
logger.error(
|
|
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
|
|
authService,
|
|
);
|
|
({ status, message } = normalizeHttpError(authService));
|
|
}
|
|
try {
|
|
// if the MCP server uses OAuth, perform a full cleanup and token revocation
|
|
await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
|
|
error,
|
|
);
|
|
}
|
|
} else {
|
|
// This handles:
|
|
// 1. Web_search uninstall (entries include every configured field).
|
|
// 2. Other tools uninstall (if auth fields were provided).
|
|
// 3. MCP tool uninstall if specific fields were provided in `auth`.
|
|
for (const [field] of authEntries) {
|
|
authService = await deleteUserPluginAuth(user.id, field); // Deletes by authField name
|
|
if (authService instanceof Error) {
|
|
logger.error('[authService] Error deleting specific auth key:', authService);
|
|
({ status, message } = normalizeHttpError(authService));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (status === 200) {
|
|
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
|
|
if (pluginKey.startsWith(Constants.mcp_prefix)) {
|
|
try {
|
|
const mcpManager = getMCPManager();
|
|
// Extract server name from pluginKey (format: "mcp_<serverName>")
|
|
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
|
if (mcpManager) {
|
|
logger.info(
|
|
`[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
|
|
);
|
|
}
|
|
let invalidationError;
|
|
try {
|
|
await invalidateCachedTools({ userId: user.id, serverName });
|
|
} catch (error) {
|
|
invalidationError = error;
|
|
}
|
|
try {
|
|
await mcpManager?.disconnectUserConnection(user.id, serverName);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
|
|
error,
|
|
);
|
|
}
|
|
if (invalidationError) {
|
|
throw invalidationError;
|
|
}
|
|
} catch (disconnectError) {
|
|
logger.error(
|
|
`[updateUserPluginsController] Error fencing MCP connection for user ${user.id} after plugin auth update:`,
|
|
disconnectError,
|
|
);
|
|
// A credential mutation is not safely published until the shared generation fence moves.
|
|
throw disconnectError;
|
|
}
|
|
}
|
|
return res.status(status).send();
|
|
}
|
|
|
|
const normalized = normalizeHttpError({ status, message });
|
|
return res.status(normalized.status).send({ message: normalized.message });
|
|
} catch (err) {
|
|
logger.error('[updateUserPluginsController]', err);
|
|
return res.status(500).json({ message: 'Something went wrong.' });
|
|
}
|
|
};
|
|
|
|
const deleteUserController = async (req, res) => {
|
|
const { user } = req;
|
|
let triggerDeletionFence;
|
|
let scheduleSuspensionToken;
|
|
let userDeleted = false;
|
|
|
|
try {
|
|
const existingUser = await db.getUserById(
|
|
user.id,
|
|
'+totpSecret +backupCodes _id twoFactorEnabled',
|
|
);
|
|
if (existingUser && existingUser.twoFactorEnabled) {
|
|
const { token, backupCode } = req.body;
|
|
const result = await verifyOTPOrBackupCode({ user: existingUser, token, backupCode });
|
|
|
|
if (!result.verified) {
|
|
const msg =
|
|
result.message ??
|
|
'TOTP token or backup code is required to delete account with 2FA enabled';
|
|
return res.status(result.status ?? 400).json({ message: msg });
|
|
}
|
|
}
|
|
|
|
// Block new trigger admissions across replicas while preserving the user
|
|
// principal so a transient cleanup failure remains retryable.
|
|
triggerDeletionFence = new Date();
|
|
const fenceState = await db.beginAgentTriggerUserDeletion(user.id, triggerDeletionFence);
|
|
if (fenceState === 'in_progress') {
|
|
triggerDeletionFence = undefined;
|
|
throw new Error('Agent trigger account deletion is already in progress');
|
|
}
|
|
if (fenceState === 'missing') {
|
|
triggerDeletionFence = undefined;
|
|
}
|
|
if (triggerDeletionFence != null) {
|
|
await prepareAgentTriggerUserPurge(user.id, triggerDeletionFence, user.tenantId);
|
|
}
|
|
await drainAgentTriggerDeliveriesForUser(user.id);
|
|
await subagentThreadTaskStore.cancelAndDrainForOwner(user.id, user.tenantId);
|
|
// Reversibly suspend the user's schedules under a per-attempt token BEFORE draining.
|
|
// A later cascade step (or this drain) can still fail and cancel the deletion, and the
|
|
// catch below restores exactly this attempt's rows — so a failed deletion never leaves
|
|
// a live user with silently disabled, erasure-eligible schedules.
|
|
scheduleSuspensionToken = randomUUID();
|
|
if (!(await quiesceUserSchedules(user.id, scheduleSuspensionToken))) {
|
|
throw new Error('Scheduled executions could not be confirmed stopped');
|
|
}
|
|
const activeAgentRuns = await GenerationJobManager.getCleanupBlockingJobIdsForUser(
|
|
user.id,
|
|
user.tenantId,
|
|
);
|
|
await Promise.all(
|
|
activeAgentRuns.map((streamId) =>
|
|
GenerationJobManager.abortJob(streamId, { awaitProviderDrain: true }),
|
|
),
|
|
);
|
|
|
|
await db.deleteMessages({ user: user.id });
|
|
await db.deleteAllUserSessions({ userId: user.id });
|
|
await db.deleteTransactions({ user: user.id });
|
|
await db.deleteUserKey({ userId: user.id, all: true });
|
|
await db.deleteBalances({ user: user._id });
|
|
await db.deletePresets(user.id);
|
|
try {
|
|
const convoDeletion = await db.deleteConvos(user.id);
|
|
// HITL: prune the deleted conversations' durable checkpoints — a paused run's
|
|
// checkpoint would otherwise persist until the Mongo TTL. Never throws.
|
|
const appConfig =
|
|
req.config ??
|
|
(await getAppConfig({
|
|
role: req.user?.role,
|
|
userId: req.user?.id,
|
|
tenantId: req.user?.tenantId,
|
|
}));
|
|
await deleteAgentCheckpoints(
|
|
convoDeletion?.conversationIds,
|
|
appConfig?.endpoints?.agents?.checkpointer,
|
|
);
|
|
} catch (error) {
|
|
logger.error('[deleteUserController] Error deleting user convos, likely no convos', error);
|
|
}
|
|
await deleteUserPluginAuth(user.id, null, true);
|
|
await deleteAllSharedLinksWithCleanup(user.id);
|
|
await deleteUserFiles(req);
|
|
await db.deleteFiles(null, user.id);
|
|
await db.deleteToolCalls(user.id);
|
|
await db.deleteUserAgents(user.id);
|
|
await db.deleteAllAgentApiKeys(user._id);
|
|
await db.deleteAssistants({ user: user.id });
|
|
await db.deleteConversationTags({ user: user.id });
|
|
await db.deleteAllUserMemories(user.id);
|
|
await db.deleteUserPrompts(user.id);
|
|
await db.deleteUserSkills(user.id);
|
|
await deleteUserMcpServers(user.id);
|
|
await db.deleteActions({ user: user.id });
|
|
await db.deleteTokens({ userId: user.id });
|
|
await db.removeUserFromAllGroups(user.id);
|
|
await db.deleteAclEntries({ principalId: user._id });
|
|
await db.deleteSchedulesByUser(user.id);
|
|
const deleteResult = await db.deleteUserById(user.id);
|
|
if (deleteResult.deletedCount !== 1) {
|
|
throw new Error('User disappeared before account deletion could commit');
|
|
}
|
|
userDeleted = true;
|
|
await purgeAgentTriggerDeliveriesForUser(user.id);
|
|
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
|
|
res.status(200).send({ message: 'User deleted' });
|
|
} catch (err) {
|
|
// The account survives this failed attempt, so its schedules must too: restore the
|
|
// exact rows this attempt suspended (re-enabled/re-armed from their snapshot). Fenced
|
|
// to the token, so a schedule the owner deleted meanwhile is not resurrected. A
|
|
// successful deletion never reaches here (userDeleted short-circuits it).
|
|
//
|
|
// RESTORE BEFORE RELEASING THE DELETION FENCE. That fence is what refuses new schedule
|
|
// writes/claims for this user; releasing it first opens a window where an owner PATCH
|
|
// could edit a still-suspended row and then have its enabled/next-run state overwritten
|
|
// by this older snapshot, and where a second deletion attempt could re-suspend these
|
|
// rows under a new token — making this restore a no-op and stranding the disabled
|
|
// snapshot permanently.
|
|
if (scheduleSuspensionToken != null && !userDeleted) {
|
|
try {
|
|
await restoreUserSchedulesFromDeletion(user.id, scheduleSuspensionToken);
|
|
} catch (restoreError) {
|
|
// Every retry is exhausted at this point. The fence is still released below on
|
|
// purpose: retaining it would refuse this live account's schedule writes AND make
|
|
// `beginAgentTriggerUserDeletion` report `in_progress` forever, blocking the retry
|
|
// that is the convergence path — a later attempt re-suspends by ADOPTING this
|
|
// snapshot, so its cancel restores these exact rows. Log the token so the state is
|
|
// recoverable directly if that never happens.
|
|
logger.error(
|
|
`[deleteUserController] Failed to restore suspended schedules after a cancelled deletion; they remain disabled for user ${user.id} under suspension token ${scheduleSuspensionToken}`,
|
|
restoreError,
|
|
);
|
|
}
|
|
}
|
|
if (triggerDeletionFence != null && !userDeleted) {
|
|
try {
|
|
await cancelAgentTriggerUserPurge(user.id, triggerDeletionFence);
|
|
} catch (purgeFenceError) {
|
|
logger.error(
|
|
'[deleteUserController] Failed to disarm trigger purge recovery',
|
|
purgeFenceError,
|
|
);
|
|
}
|
|
try {
|
|
await db.cancelAgentTriggerUserDeletion(user.id, triggerDeletionFence);
|
|
} catch (fenceError) {
|
|
logger.error('[deleteUserController] Failed to release trigger deletion fence', fenceError);
|
|
}
|
|
}
|
|
logger.error('[deleteUserController]', err);
|
|
return res.status(500).json({ message: 'Something went wrong.' });
|
|
}
|
|
};
|
|
|
|
const verifyEmailController = async (req, res) => {
|
|
try {
|
|
const verifyEmailService = await verifyEmail(req);
|
|
if (verifyEmailService instanceof Error) {
|
|
return res.status(400).json({ message: verifyEmailService.message });
|
|
} else {
|
|
return res.status(200).json(verifyEmailService);
|
|
}
|
|
} catch (e) {
|
|
logger.error('[verifyEmailController]', e);
|
|
return res.status(500).json({ message: 'Something went wrong.' });
|
|
}
|
|
};
|
|
|
|
const resendVerificationController = async (req, res) => {
|
|
try {
|
|
const result = await resendVerificationEmail(req);
|
|
if (result instanceof Error) {
|
|
return res.status(400).json({ message: result.message });
|
|
} else {
|
|
return res.status(result.status ?? 200).json({ message: result.message });
|
|
}
|
|
} catch (e) {
|
|
logger.error('[verifyEmailController]', e);
|
|
return res.status(500).json({ message: 'Something went wrong.' });
|
|
}
|
|
};
|
|
|
|
/** Best-effort cleanup of stored MCP OAuth tokens and flow state. */
|
|
const clearStoredMCPOAuthState = async (userId, serverName) => {
|
|
try {
|
|
await MCPTokenStorage.deleteUserTokens({
|
|
userId,
|
|
serverName,
|
|
deleteToken: async (filter) => {
|
|
await db.deleteTokens(filter);
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for ${serverName}:`,
|
|
error,
|
|
);
|
|
}
|
|
|
|
try {
|
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
|
const flowManager = getFlowStateManager(flowsCache);
|
|
const baseFlowId = MCPOAuthHandler.generateFlowId(userId, serverName);
|
|
const tenantId = getTenantId();
|
|
const tokenFlowId = MCPOAuthHandler.generateTokenFlowId(userId, serverName, tenantId);
|
|
const oauthFlowId = MCPOAuthHandler.generateFlowId(userId, serverName, tenantId);
|
|
const flowDeletes = [
|
|
[tokenFlowId, 'mcp_get_tokens'],
|
|
[oauthFlowId, 'mcp_oauth'],
|
|
[baseFlowId, 'mcp_get_tokens'],
|
|
[baseFlowId, 'mcp_oauth'],
|
|
].filter(
|
|
([flowId, type], index, deletes) =>
|
|
deletes.findIndex(([candidateId, candidateType]) => {
|
|
return candidateId === flowId && candidateType === type;
|
|
}) === index,
|
|
);
|
|
const results = await Promise.allSettled(
|
|
flowDeletes.map(([flowId, type]) =>
|
|
type === 'mcp_oauth'
|
|
? MCPOAuthHandler.deleteFlowAndStateMapping(flowId, flowManager)
|
|
: flowManager.deleteFlow(flowId, type),
|
|
),
|
|
);
|
|
for (const result of results) {
|
|
if (result.status === 'rejected') {
|
|
logger.warn(
|
|
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
|
|
result.reason,
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Revokes MCP OAuth tokens at the provider when possible, then clears local state. */
|
|
const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
|
if (!pluginKey.startsWith(Constants.mcp_prefix)) {
|
|
// this is not an MCP server, so nothing to do here
|
|
return;
|
|
}
|
|
|
|
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
|
const serverConfig =
|
|
(await getMCPServersRegistry().getServerConfig(serverName, userId)) ??
|
|
appConfig?.mcpServers?.[serverName];
|
|
const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
|
|
if (!oauthServers.has(serverName) || !serverConfig) {
|
|
await clearStoredMCPOAuthState(userId, serverName);
|
|
return;
|
|
}
|
|
|
|
// 1. get client info used for revocation (client id, secret)
|
|
let clientTokenData = null;
|
|
try {
|
|
clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
|
|
userId,
|
|
serverName,
|
|
findToken: db.findToken,
|
|
});
|
|
} catch (error) {
|
|
logger.warn(
|
|
`[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
|
|
error,
|
|
);
|
|
await clearStoredMCPOAuthState(userId, serverName);
|
|
return;
|
|
}
|
|
if (clientTokenData == null) {
|
|
logger.info(
|
|
`[maybeUninstallOAuthMCP] Missing OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
|
|
);
|
|
await clearStoredMCPOAuthState(userId, serverName);
|
|
return;
|
|
}
|
|
const { clientInfo, clientMetadata } = clientTokenData;
|
|
const storedServerUrl = clientMetadata.server_url;
|
|
const storedClientSource = clientMetadata.client_source;
|
|
if (
|
|
typeof storedServerUrl !== 'string' ||
|
|
typeof clientMetadata.token_endpoint !== 'string' ||
|
|
typeof clientMetadata.revocation_endpoint !== 'string' ||
|
|
typeof clientMetadata.credential_set_id !== 'string' ||
|
|
(storedClientSource !== 'configured' && storedClientSource !== 'dynamic')
|
|
) {
|
|
logger.warn(
|
|
`[maybeUninstallOAuthMCP] Stored OAuth binding metadata is incomplete for ${serverName}; clearing local MCP OAuth state without remote revocation.`,
|
|
);
|
|
await clearStoredMCPOAuthState(userId, serverName);
|
|
return;
|
|
}
|
|
|
|
// 2. get decrypted tokens before deletion
|
|
let tokens = null;
|
|
try {
|
|
tokens = await MCPTokenStorage.getTokens({
|
|
userId,
|
|
serverName,
|
|
findToken: db.findToken,
|
|
});
|
|
if (tokens) {
|
|
MCPTokenStorage.assertCredentialSetBinding(
|
|
serverName,
|
|
tokens.credential_set_id,
|
|
clientMetadata,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
tokens = null;
|
|
logger.warn(
|
|
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
|
|
error,
|
|
);
|
|
}
|
|
|
|
// 3. revoke OAuth tokens at the provider
|
|
const revocationEndpoint = clientMetadata.revocation_endpoint;
|
|
const revocationEndpointAuthMethodsSupported =
|
|
clientMetadata.revocation_endpoint_auth_methods_supported;
|
|
const oauthHeaders = serverConfig.oauth_headers ?? {};
|
|
// Use the request's merged (tenant/principal-scoped) allowlists so admin-panel mcpSettings
|
|
// overrides are honored for OAuth revocation, consistent with inspection/connection.
|
|
const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
|
|
const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
|
|
|
|
if (tokens?.access_token) {
|
|
try {
|
|
await MCPOAuthHandler.revokeOAuthToken(
|
|
serverName,
|
|
tokens.access_token,
|
|
'access',
|
|
{
|
|
serverUrl: storedServerUrl,
|
|
clientId: clientInfo.client_id,
|
|
clientSecret: clientInfo.client_secret ?? '',
|
|
revocationEndpoint,
|
|
revocationEndpointAuthMethodsSupported,
|
|
},
|
|
oauthHeaders,
|
|
allowedDomains,
|
|
allowedAddresses,
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[maybeUninstallOAuthMCP] Error revoking OAuth access token for ${serverName}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (tokens?.refresh_token) {
|
|
try {
|
|
await MCPOAuthHandler.revokeOAuthToken(
|
|
serverName,
|
|
tokens.refresh_token,
|
|
'refresh',
|
|
{
|
|
serverUrl: storedServerUrl,
|
|
clientId: clientInfo.client_id,
|
|
clientSecret: clientInfo.client_secret ?? '',
|
|
revocationEndpoint,
|
|
revocationEndpointAuthMethodsSupported,
|
|
},
|
|
oauthHeaders,
|
|
allowedDomains,
|
|
allowedAddresses,
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[maybeUninstallOAuthMCP] Error revoking OAuth refresh token for ${serverName}:`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 4. delete tokens from the DB and clear the flow state after revocation attempts
|
|
await clearStoredMCPOAuthState(userId, serverName);
|
|
};
|
|
|
|
module.exports = {
|
|
getUserController,
|
|
getTermsStatusController,
|
|
acceptTermsController,
|
|
deleteUserController,
|
|
verifyEmailController,
|
|
updateUserPluginsController,
|
|
resendVerificationController,
|
|
deleteUserMcpServers,
|
|
maybeUninstallOAuthMCP,
|
|
};
|