mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
* fix: Route Delete-User CLI Through the Shared Account Erasure Cascade The operator CLI ran raw deleteMany calls that dropped File metadata while leaving storage objects, skipped skills, MCP servers and agent checkpoints, and used the wrong field for agent API keys. Account self-deletion and the CLI now share eraseUserData in packages/api, so both remove stored file objects before metadata, open checkpoint deletion around conversations, and clear skills, MCP servers and shared links with ACL cleanup. * fix: Erase Favorites, Projects, Queued Turns and Refresh Bridges With the Account Account erasure left tool favorites, chat projects, queued agent turns and their sequences, and OpenID refresh token bridges behind. The shared eraseUserData cascade now clears them, adding deleteUserToolFavorites and deleteUserChatProjects where no delete-by-user method existed. * fix: Move MCP Server Erasure Queries Into Data Methods and Propagate Their Failures * test: Cover Delete-User CLI and Self-Service Erasure Scenarios * fix: Delete Stored Files When Account Deletion Arrives Without a Body * fix: Delete Stored Files Before User Keys and Keep Provider Failures Best Effort * fix: Propagate Agent and Prompt Erasure Failures Before Deleting the User * fix: Delete Erasure Retry Anchors Last When Removing User-Owned Resources * fix: Retire Session Hint From the Shared Erasure Cascade After Sessions Are Revoked * fix: Propagate Skill and Agent Reference Cleanup Failures Before Deleting the User * fix: Scope CLI Account Erasure to the Deleted User's Tenant and Legacy Refresh Bridges * fix: Clean Shared-User MCP OAuth State When Account Erasure Removes a Server * fix: Keep CLI Erasure System-Scoped Except ID-Keyed Cleanup and Await Shared-Link ACL Cleanup * fix: Scope CLI MCP OAuth Lookups to the Tenant and Fail Erasure When Shared-User Cleanup Fails * fix: Run CLI MCP OAuth Teardown and Allowlist Resolution Per User in the Tenant * fix: Tear Down Shared-User MCP OAuth State Before Removing the Server Retry Anchors * fix: Pin Agent Reference and MCP Token Cleanup to the Deleted User's Own Tenant
620 lines
24 KiB
JavaScript
620 lines
24 KiB
JavaScript
const bcrypt = require('bcryptjs');
|
|
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,
|
|
isStopConfirmed,
|
|
waitForGenerationPersistence,
|
|
deleteAllSharedLinksWithCleanup,
|
|
eraseUserData,
|
|
deleteUserMCPServers,
|
|
revokeUserCodeEnvironmentWorkers,
|
|
finalizeMCPAuthorizationMutation,
|
|
clearSessionHintCookie,
|
|
} = 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: bcrypt.compare }),
|
|
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),
|
|
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');
|
|
}
|
|
const activeAgentRuns = await GenerationJobManager.getAccountCleanupJobIdsForUser(
|
|
user.id,
|
|
tenantId,
|
|
);
|
|
const activeAgentJobs = await Promise.all(
|
|
activeAgentRuns.map(async (streamId) => ({
|
|
streamId,
|
|
job: await GenerationJobManager.getCleanupJob(streamId),
|
|
})),
|
|
);
|
|
const ownedAgentJobs = activeAgentJobs.filter(
|
|
({ job }) =>
|
|
job?.metadata?.userId === user.id &&
|
|
(!job.metadata.tenantId || job.metadata.tenantId === tenantId),
|
|
);
|
|
const stopResults = await Promise.all(
|
|
ownedAgentJobs.map(({ streamId, job }) =>
|
|
GenerationJobManager.abortJob(streamId, {
|
|
expectedCreatedAt: job.createdAt,
|
|
awaitProviderDrain: true,
|
|
}),
|
|
),
|
|
);
|
|
if (stopResults.some((result) => !isStopConfirmed(result))) {
|
|
throw new Error('Agent generations could not be confirmed stopped');
|
|
}
|
|
await Promise.all(
|
|
ownedAgentJobs.map(({ streamId, job }) =>
|
|
waitForGenerationPersistence(streamId, job.createdAt, (id) =>
|
|
GenerationJobManager.getCleanupJob(id),
|
|
),
|
|
),
|
|
);
|
|
|
|
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,
|
|
};
|