mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
* 🙈 feat: Let Config Profiles Hide librechat.yaml MCP Servers and Remove Their Fields Config profiles are the `__base__`, role, group and user documents written through `/api/admin/config`. A profile can tombstone `mcpServers.<name>`, and the merge then removes that server from the principal's resolved `mcpConfig`. * 🛡️ fix: Apply MCP Hiding to Server Instructions, Failed Removals and the Startup Audit - Server instructions bound for the model now leave out librechat.yaml servers hidden from the acting user, including one a model spec pins. `applyContextToAgent` passes the request user through `formatInstructionsForContext`, which drops the user's hidden set from the user-less catalog it reads. - A config-tier candidate that removes a librechat.yaml value (a field, or a key of a record such as `headers`) and fails inspection now stands as a failed config-source entry instead of falling back to the librechat.yaml base that still carries the removed value. A failed change that removes nothing still falls back, and an entry with no transport (fields written over a hidden server) keeps the documented librechat.yaml fallback. - The startup tombstone report reads every tenant's profiles in the system tenant scope, so it also runs from entry points without an outer system scope under strict tenant isolation, and names each profile's tenant. * 🗂️ fix: Project the Startup MCP Tombstone Report Query and Run It Alongside Connections * 🔑 fix: Keep Removed OAuth Headers Out of the MCP OAuth Callback * 📝 docs: Note That Removed OAuth Headers Stay Out of MCP Sign-In * 🔐 fix: Recheck the MCP OAuth Flow's Server Before Initiation Redirects OAuth initiation redirected to a flow's stored authorization URL, or started a new authorization from the flow's stored server URL, without resolving the server again, so a principal whose profile hid the server after the flow was created could still be sent to its authorization endpoint. The callback's active-server check and its OAuth header fallback lived in the CJS route. resolveMCPOAuthTarget (packages/api/src/mcp/oauth/target.ts) now owns both: it resolves the server in the principal's current config with a strict read, refuses a flow admitted for another server, a server the principal can no longer use, or a redefined one, and selects the headers the flow sends (the initiation snapshot, else the principal's config). Initiation calls it before either redirect (404 or 400); the callback calls it before the exchange and from the token-store fence. * 📝 docs: Note That a Hidden MCP Server Ends OAuth Sign-Ins Already Started * 🚩 feat: Publish the yamlMcpHide Admin Config Meta Flag Profiles can hide librechat.yaml MCP servers and a hidden server is an access boundary, so GET /api/admin/config/meta now reports features.yamlMcpHide for the admin panel to gate its hide UI on. The meta.spec probe checks the behaviour itself: the admin API stores an mcpServers.<name> tombstone, the merged config drops the server, the registry withholds it from principal lookups and checkout but not from app-level lookups, process-backed servers stay, and an unreadable principal config withholds every hideable server. * 🧪 test: Align MCP Tombstone Specs With the Portable Config Store The admin config handlers now write whole documents through writeConfigDocument, so the end-to-end tombstone spec wires it in place of the removed upsertConfig/patchConfigFields deps. A reset of a whole MCP record now also prunes the parents it leaves empty.
590 lines
22 KiB
JavaScript
590 lines
22 KiB
JavaScript
const mongoose = require('mongoose');
|
|
const { logger, runAsSystem, tenantStorage } = require('@librechat/data-schemas');
|
|
const {
|
|
getNewS3URL,
|
|
needsRefresh,
|
|
comparePassword,
|
|
checkEmailConfig,
|
|
createEmailChangeService,
|
|
createEmailChangeDeps,
|
|
resolveEmailChangeSettings,
|
|
GenerationJobManager,
|
|
getAppConfigOptionsFromUser,
|
|
normalizeHttpError,
|
|
getWebSearchInstallEntries,
|
|
getWebSearchUninstallFields,
|
|
openCheckpointDeletion,
|
|
stopUserGenerations,
|
|
deleteAllSharedLinksWithCleanup,
|
|
eraseUserData,
|
|
deleteUserMCPServers,
|
|
revokeUserCodeEnvironmentWorkers,
|
|
finalizeMCPAuthorizationMutation,
|
|
clearSessionHintCookie,
|
|
compareSecret,
|
|
} = require('@librechat/api');
|
|
const { Tools, Constants, FileSources } = 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, getMCPServersRegistry } = require('~/config');
|
|
const { maybeUninstallOAuthMCP } = require('~/server/services/MCP/oauthCleanup');
|
|
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
|
|
const {
|
|
clearMCPAuthorizationFenceRetry,
|
|
persistMCPAuthorizationFenceRetry,
|
|
} = require('~/server/services/MCPAuthorizationFenceRetry');
|
|
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, invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config');
|
|
const { randomUUID } = require('node:crypto');
|
|
const {
|
|
quiesceUserSchedules,
|
|
restoreUserSchedulesFromDeletion,
|
|
} = require('~/server/services/Schedules');
|
|
const { sendEmail } = require('~/server/utils');
|
|
const db = require('~/models');
|
|
|
|
const emailChangeService = createEmailChangeService(
|
|
createEmailChangeDeps({
|
|
store: {
|
|
findUser: db.findUser,
|
|
getUserById: db.getUserById,
|
|
updateUser: db.updateUser,
|
|
findToken: db.findToken,
|
|
replaceTokenIfCurrent: db.replaceTokenIfCurrent,
|
|
deleteTokens: db.deleteTokens,
|
|
},
|
|
withTenant: (tenantId, operation) =>
|
|
tenantId ? tenantStorage.run({ tenantId }, operation) : runAsSystem(operation),
|
|
comparePassword: (user, password) =>
|
|
comparePassword(user, password, { compare: compareSecret }),
|
|
sendEmail,
|
|
getAppConfig,
|
|
clientDomain: process.env.DOMAIN_CLIENT ?? 'http://localhost:3080',
|
|
appName: process.env.APP_TITLE || 'LibreChat',
|
|
}),
|
|
);
|
|
|
|
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' });
|
|
}
|
|
};
|
|
|
|
/** Runtime hooks for MCP servers removed with a deleted account. */
|
|
const createMCPServerCleanup = (userId) => ({
|
|
findUserErasableMCPServers: db.findUserErasableMCPServers,
|
|
deleteMCPServersWithAcl: db.deleteMCPServersWithAcl,
|
|
invalidateMCPServerTools: (serverName) => invalidateCachedTools({ userId, serverName }),
|
|
disconnectMCPServer: async (serverName) => {
|
|
await getMCPManager()?.disconnectUserConnection(userId, serverName);
|
|
},
|
|
findMCPServerOAuthTokenUserIds: db.findMCPServerOAuthTokenUserIds,
|
|
findMCPServerViewerEntries: db.findMCPServerViewerEntries,
|
|
mcpOAuth: {
|
|
getServerConfig: (serverName) =>
|
|
getMCPServersRegistry().getServerConfig(serverName, userId, undefined, undefined, {
|
|
includeHidden: true,
|
|
}),
|
|
getUserPrincipals: (candidateId) => db.getUserPrincipals({ userId: candidateId }),
|
|
resolveAllowlists: (candidateId) =>
|
|
getMCPServersRegistry().resolveAllowlists({ userId: candidateId }),
|
|
invalidateUserTools: (candidateId, serverName) =>
|
|
invalidateCachedTools({ userId: candidateId, serverName }),
|
|
disconnectUser: (candidateId, serverName) =>
|
|
getMCPManager()?.disconnectUserConnection(candidateId, serverName),
|
|
uninstallOAuthMCP: maybeUninstallOAuthMCP,
|
|
},
|
|
});
|
|
|
|
const deleteUserMcpServers = (userId) =>
|
|
deleteUserMCPServers(userId, createMCPServerCleanup(userId));
|
|
|
|
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;
|
|
const mcpCredentialMutationResults = [];
|
|
let mcpTeardown = false;
|
|
const mcpScope = pluginKey.startsWith(Constants.mcp_prefix)
|
|
? {
|
|
userId: user.id,
|
|
serverName: pluginKey.replace(Constants.mcp_prefix, ''),
|
|
}
|
|
: null;
|
|
/** Write durable fence intent before the first credential write. A crash or retry-marker
|
|
* outage therefore cannot commit credentials that other replicas continue to authorize. */
|
|
const publicationRetryVersion =
|
|
mcpScope == null ? undefined : await persistMCPAuthorizationFenceRetry(mcpScope);
|
|
|
|
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,
|
|
appConfig?.credentials?.writeFormat,
|
|
);
|
|
if (authService instanceof Error) {
|
|
logger.error('[authService]', authService);
|
|
({ status, message } = normalizeHttpError(authService));
|
|
if (pluginKey === Tools.web_search) {
|
|
break;
|
|
}
|
|
}
|
|
if (isMCPTool) {
|
|
mcpCredentialMutationResults.push(authService);
|
|
}
|
|
}
|
|
} 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));
|
|
}
|
|
mcpCredentialMutationResults.push(authService);
|
|
mcpTeardown = true;
|
|
} 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 (isMCPTool) {
|
|
mcpCredentialMutationResults.push(authService);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every committed MCP credential write advances the fence, including a partial batch whose
|
|
// later field failed. Otherwise another worker can retain a stale authorization decision.
|
|
if (mcpScope != null) {
|
|
try {
|
|
const mcpManager = getMCPManager();
|
|
await finalizeMCPAuthorizationMutation(
|
|
{
|
|
scope: mcpScope,
|
|
mutationResults: mcpCredentialMutationResults,
|
|
publicationRetryVersion,
|
|
teardown: mcpTeardown,
|
|
},
|
|
{
|
|
invalidateRecoveryGeneration: invalidateCachedTools,
|
|
persistPublicationRetry: persistMCPAuthorizationFenceRetry,
|
|
clearPublicationRetry: clearMCPAuthorizationFenceRetry,
|
|
clearLocalRecovery: (changedUserId, changedServerName) =>
|
|
mcpManager?.clearCatalogRecoveryState?.(changedUserId, changedServerName),
|
|
disconnectUserConnection: (changedUserId, changedServerName) =>
|
|
mcpManager?.disconnectUserConnection(changedUserId, changedServerName),
|
|
retryDelaysMs: appConfig?.mcpSettings?.catalogRecovery?.authorizationFenceRetryMs,
|
|
attemptTimeoutMs: appConfig?.mcpSettings?.catalogRecovery?.authorizationFenceTimeoutMs,
|
|
onDisconnectError: (error) =>
|
|
logger.error(
|
|
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
|
|
error,
|
|
),
|
|
...(mcpTeardown && {
|
|
afterDisconnect: async () => {
|
|
try {
|
|
await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
|
|
error,
|
|
);
|
|
status = 503;
|
|
message = 'OAuth credential cleanup is temporarily unavailable';
|
|
}
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
} 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;
|
|
}
|
|
}
|
|
if (status === 200) {
|
|
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;
|
|
const tenantId = user.tenantId || undefined;
|
|
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, tenantId);
|
|
}
|
|
const deletionAppConfig = await getAppConfig({ baseOnly: true });
|
|
await drainAgentTriggerDeliveriesForUser(user.id);
|
|
await subagentThreadTaskStore.cancelAndDrainForOwner(user.id, 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');
|
|
}
|
|
await stopUserGenerations(user.id, tenantId, GenerationJobManager);
|
|
|
|
const appConfig =
|
|
req.config ??
|
|
(await getAppConfig({
|
|
role: user.role,
|
|
userId: user.id,
|
|
tenantId,
|
|
}));
|
|
await eraseUserData(
|
|
{
|
|
userId: user.id,
|
|
tenantId,
|
|
checkpointer: appConfig?.endpoints?.agents?.checkpointer,
|
|
},
|
|
{
|
|
...db,
|
|
openCheckpointDeletion,
|
|
deleteSharedLinks: (id) => deleteAllSharedLinksWithCleanup(id, { awaitCleanup: true }),
|
|
/** The DELETE carries no body without 2FA, and account erasure targets no assistant. */
|
|
deleteStoredFiles: (files) =>
|
|
processDeleteRequest({ req: Object.assign(Object.create(req), { body: {} }), files }),
|
|
...createMCPServerCleanup(user.id),
|
|
onSessionsDeleted: () => clearSessionHintCookie(res),
|
|
},
|
|
);
|
|
const deleteResult = await db.deleteUserById(user.id);
|
|
if (deleteResult.deletedCount !== 1) {
|
|
throw new Error('User disappeared before account deletion could commit');
|
|
}
|
|
userDeleted = true;
|
|
let codeEnvironmentCleanupSafe = true;
|
|
try {
|
|
await revokeUserCodeEnvironmentWorkers({
|
|
mongoose,
|
|
userId: user.id,
|
|
appConfig: deletionAppConfig,
|
|
});
|
|
} catch (error) {
|
|
codeEnvironmentCleanupSafe = false;
|
|
logger.error('[deleteUserController] Failed to revoke code environment workers', error);
|
|
}
|
|
if (codeEnvironmentCleanupSafe) {
|
|
try {
|
|
await db.deleteUserCodeEnvironments(user.id);
|
|
} catch (error) {
|
|
logger.error('[deleteUserController] Failed to delete code environments', error);
|
|
}
|
|
}
|
|
await invalidateCodeEnvironmentConfigCache(tenantId).catch((error) => {
|
|
logger.error('[deleteUserController] code environment cache invalidation failed:', error);
|
|
});
|
|
await purgeAgentTriggerDeliveriesForUser(user.id);
|
|
logger.info(`User deleted account. ID: ${user.id} Tenant: ${tenantId ?? 'none'}`);
|
|
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.' });
|
|
}
|
|
};
|
|
|
|
const requestEmailChangeController = async (req, res) => {
|
|
try {
|
|
const userId = req.user?._id?.toString?.() ?? req.user?.id?.toString?.();
|
|
if (!userId) {
|
|
return res.status(401).json({ message: 'Unauthorized' });
|
|
}
|
|
const result = await emailChangeService.requestEmailChange({
|
|
body: req.body,
|
|
userId,
|
|
tenantId: req.user?.tenantId,
|
|
allowedDomains: req.config?.registration?.allowedDomains,
|
|
settings: resolveEmailChangeSettings(req.config?.emailChange),
|
|
emailEnabled: checkEmailConfig(),
|
|
ip: req.ip,
|
|
});
|
|
return res.status(result.status).json({ message: result.message, code: result.code });
|
|
} catch (error) {
|
|
logger.error('[requestEmailChangeController]', error);
|
|
return res.status(500).json({ message: 'Something went wrong.' });
|
|
}
|
|
};
|
|
|
|
const confirmEmailChangeController = async (req, res) => {
|
|
try {
|
|
const result = await emailChangeService.confirmEmailChange({ body: req.body, ip: req.ip });
|
|
return res.status(result.status).json({ message: result.message, code: result.code });
|
|
} catch (error) {
|
|
logger.error('[confirmEmailChangeController]', error);
|
|
return res.status(500).json({ message: 'Something went wrong.' });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
getUserController,
|
|
getTermsStatusController,
|
|
acceptTermsController,
|
|
deleteUserController,
|
|
verifyEmailController,
|
|
requestEmailChangeController,
|
|
confirmEmailChangeController,
|
|
updateUserPluginsController,
|
|
resendVerificationController,
|
|
deleteUserMcpServers,
|
|
maybeUninstallOAuthMCP,
|
|
};
|