Files
chat/api/server/controllers/UserController.js
zooqueen e937ba8c71 a new account is met by a card naming what is here
Four lines on the empty chat: the model picker, the modes and their sources,
/agent, and the sidebar. It dims nothing and gates nothing -- the arrival modal
was removed by owner call, and this sits beside the product rather than in
front of it.

`toured` carries no schema default. `false` shows the card, `true` has seen it,
and ABSENT is an account older than the card, which never gets one; a default
reaches every record that lacks the field, which is every account that already
exists. `reconcileUser` writes `false` at creation, so exactly the accounts born
from here on qualify. The client tests `=== false` for the same reason, and the
spec fails when that is weakened to a falsy check.

It rides out on `GET /v1/chat/user`, which already sends the projected document,
so there is no second GET to keep in step -- one POST records the dismissal, and
the cached user is written on mutate so the card goes on the click rather than a
round-trip later.

In the flow, under the composer, not floating over it. The composer block is
min-h-[280px] at the bottom of that column and its input is capped and centred,
leaving ~142px of gutter at 1440 against a card that needs 304 -- measured in
Chromium against the built stylesheet: 288x252 at 16px from the column edge,
clear of the composer at 1440 and at 390, no sideways scroll, 17.25:1 on the
title and 12.82:1 on the lines.
2026-08-17 16:38:35 -07:00

531 lines
20 KiB
JavaScript

const { logger, webSearchKeys } = require('@hanzochat/data-schemas');
const { Tools, CacheKeys, Constants, FileSources } = require('@hanzochat/data-provider');
const {
MCPOAuthHandler,
MCPTokenStorage,
normalizeHttpError,
extractWebSearchEnvVars,
resolveTenantBearer,
} = require('@hanzochat/api');
const {
deleteAllSharedLinks,
updateUserPlugins,
deleteUserById,
deleteMessages,
deletePresets,
deleteUserKey,
deleteConvos,
deleteFiles,
deleteAllAgentApiKeys,
updateUser,
findToken,
getFiles,
} = require('~/models');
const {
ConversationTag,
Transaction,
MemoryEntry,
Assistant,
AclEntry,
Balance,
Action,
Group,
Token,
User,
} = require('~/db/models');
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { getAppConfig } = require('~/server/services/Config');
const { buildGuestUser } = require('~/server/services/guestConfig');
const { publicUser } = require('~/server/services/publicUser');
const { pictureFromUserinfo } = require('~/server/services/iamUser');
const { deleteToolCalls } = require('~/models/ToolCall');
const { deleteUserPrompts } = require('~/models/Prompt');
const { deleteUserAgents } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
/**
* Adopt the visitor's hanzo.id photo, for a session that already existed.
*
* The avatar is adopted when the account is first seen (services/iamUser), which
* is the right place for it and the only place that needs to run for anyone
* arriving from here on. It leaves out exactly one group: everybody who already
* had an account when that shipped, who would have had to sign out and back in
* to see their own face. Telling a signed-in person to re-authenticate to
* collect a photo the platform already holds is a workaround, not a fix.
*
* So this reads the SAME userinfo endpoint, through the SAME
* `pictureFromUserinfo` — never a second copy of the discovery-and-fetch dance,
* because two of those drift and the wrong one is invisible until a photo
* silently stops arriving.
*
* Three things keep it cheap and quiet:
*
* - It runs ONLY when the record has no avatar. A photo already here — uploaded
* by hand, or adopted on a previous pass — is the visitor's own and is never
* overwritten from upstream.
* - It runs only from HERE, the one endpoint that renders a person's identity,
* rather than from the per-request identification every API call goes through.
* That is the difference between a couple of round-trips a session and two in
* front of every call an account without a photo ever makes.
* - It cannot fail the read. A guest carries no bearer and
* `pictureFromUserinfo` resolves to '' on every error, so the worst case is
* the response this function was never called for.
*
* It runs AFTER the S3 branch above, and that order is load-bearing: that branch
* is gated on an avatar EXISTING and treats it as an S3 key to re-sign. Adopting
* first would hand it a hanzo.id data URI to refresh as one.
*/
async function adoptIamPhoto(req, userData) {
if (userData.avatar) {
return;
}
try {
const bearer = resolveTenantBearer(req);
if (!bearer) {
return;
}
const picture = await pictureFromUserinfo(bearer);
if (!picture) {
return;
}
userData.avatar = picture;
await updateUser(userData.id, { avatar: picture });
} catch (error) {
logger.debug('[getUserController] could not adopt the hanzo.id photo', error);
}
}
const getUserController = async (req, res) => {
if (req.user?.guest === true) {
return res.status(200).send(buildGuestUser(req.user));
}
const appConfig = await getAppConfig({ role: req.user?.role });
/** @type {IUser} */
const raw = req.user.toObject != null ? req.user.toObject() : { ...req.user };
/**
* ONE projection, at the boundary. This was three `delete`s against a
* deny-list the jwt strategy also spells (`-password -__v -totpSecret
* -backupCodes`), which between them named four fields and shipped the other
* twenty-six — `refreshToken` among them. See services/publicUser.
*/
const userData = publicUser(raw);
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 updateUser(userData.id, { avatar: userData.avatar });
} catch (error) {
userData.avatar = originalAvatar;
logger.error('Error getting new S3 URL for avatar:', error);
}
}
await adoptIamPhoto(req, userData);
res.status(200).send(userData);
};
const getTermsStatusController = async (req, res) => {
try {
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({ termsAccepted: !!user.termsAccepted });
} 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 User.findByIdAndUpdate(req.user.id, { termsAccepted: true }, { new: true });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({ message: 'Terms accepted successfully' });
} catch (error) {
logger.error('Error accepting terms:', error);
res.status(500).json({ message: 'Error accepting terms' });
}
};
/**
* Records that this account has seen the welcome card, so it is shown once and
* not once per device. There is no matching GET: `toured` rides the projected
* user document out of `GET /v1/chat/user`, which the client already holds.
*/
const tourController = async (req, res) => {
try {
const user = await User.findByIdAndUpdate(req.user.id, { toured: true }, { new: true });
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.status(200).json({ toured: true });
} catch (error) {
logger.error('Error recording tour:', error);
res.status(500).json({ message: 'Error recording tour' });
}
};
const deleteUserFiles = async (req) => {
try {
const userFiles = await getFiles({ user: req.user.id });
await processDeleteRequest({
req,
files: userFiles,
});
} catch (error) {
logger.error('[deleteUserFiles]', error);
}
};
const updateUserPluginsController = async (req, res) => {
const appConfig = await getAppConfig({ role: req.user?.role });
const { user } = req;
const { pluginKey, action, auth, isEntityTool } = req.body;
try {
if (!isEntityTool) {
await updateUserPlugins(user._id, user.plugins, pluginKey, action);
}
if (auth == null) {
return res.status(200).send();
}
let keys = Object.keys(auth);
const values = Object.values(auth); // Used in 'install' block
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
// Early exit condition:
// If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
// AND it's not web_search (which has special key handling to populate `keys` for uninstall)
// 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 (
keys.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;
keys = extractWebSearchEnvVars({
keys: action === 'install' ? keys : webSearchKeys,
config: webSearchConfig,
});
}
if (action === 'install') {
for (let i = 0; i < keys.length; i++) {
authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
if (authService instanceof Error) {
logger.error('[authService]', authService);
({ status, message } = normalizeHttpError(authService));
}
}
} else if (action === 'uninstall') {
// const isMCPTool was defined earlier
if (isMCPTool && keys.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 (keys will be populated with all webSearchKeys if auth was {}).
// 2. Other tools uninstall (if keys were provided).
// 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
// If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
for (let i = 0; i < keys.length; i++) {
authService = await deleteUserPluginAuth(user.id, keys[i]); // 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();
if (mcpManager) {
// Extract server name from pluginKey (format: "mcp_<serverName>")
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
logger.info(
`[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
);
await mcpManager.disconnectUserConnection(user.id, serverName);
await invalidateCachedTools({ userId: user.id, serverName });
}
} catch (disconnectError) {
logger.error(
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
disconnectError,
);
// Do not fail the request for this, but log it.
}
}
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;
try {
await deleteMessages({ user: user.id }); // delete user messages
await Transaction.deleteMany({ user: user.id }); // delete user transactions
await deleteUserKey({ userId: user.id, all: true }); // delete user keys
await Balance.deleteMany({ user: user._id }); // delete user balances
await deletePresets(user.id); // delete user presets
try {
await deleteConvos(user.id); // delete user convos
} catch (error) {
logger.error('[deleteUserController] Error deleting user convos, likely no convos', error);
}
await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth
await deleteUserById(user.id); // delete user
await deleteAllSharedLinks(user.id); // delete user shared links
await deleteUserFiles(req); // delete user files
await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps
await deleteToolCalls(user.id); // delete user tool calls
await deleteUserAgents(user.id); // delete user agents
/**
* `deleteAllAgentApiKeys`, not `AgentApiKey.deleteMany({ user })` — the
* schema's owner field is `userId` and there is no `user` field at all, so
* that filter matched nothing while every other line in this chain quietly
* did its job around it. Deleting an account left its API key rows, hashed
* key material included, in the store forever.
*
* Bounded, because the key middleware loads the owning user and 401s when
* it is gone: an orphaned key cannot authenticate. It is retention, not a
* live credential — and the account had asked to be erased.
*
* Going through the model layer is the actual repair. A filter written at
* the call site is a second place that has to know the field name; there is
* nowhere to be wrong when the query lives with the schema.
*/
await deleteAllAgentApiKeys(user._id); // delete user agent API keys
await Assistant.deleteMany({ user: user.id }); // delete user assistants
await ConversationTag.deleteMany({ user: user.id }); // delete user conversation tags
await MemoryEntry.deleteMany({ userId: user.id }); // delete user memory entries
await deleteUserPrompts(req, user.id); // delete user prompts
await Action.deleteMany({ user: user.id }); // delete user actions
await Token.deleteMany({ userId: user.id }); // delete user OAuth tokens
await Group.updateMany(
// remove user from all groups
{ memberIds: user.id },
{ $pull: { memberIds: user.id } },
);
await AclEntry.deleteMany({ principalId: user._id }); // delete user ACL entries
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
res.status(200).send({ message: 'User deleted' });
} catch (err) {
logger.error('[deleteUserController]', err);
return res.status(500).json({ message: 'Something went wrong.' });
}
};
/**
* Forget everything this deployment stores about one user's OAuth session with
* one MCP server: the tokens and the flow state. Never throws — it is the last
* step of an uninstall the user already asked for, and a failure to tidy up is
* not a reason to fail it.
*/
const clearStoredMCPOAuthState = async (userId, serverName) => {
try {
await MCPTokenStorage.deleteUserTokens({
userId,
serverName,
deleteToken: async (filter) => {
await Token.deleteOne(filter);
},
});
} catch (error) {
logger.warn(
`[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for ${serverName}:`,
error,
);
}
try {
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
const flowId = MCPOAuthHandler.generateFlowId(userId, serverName);
await flowManager.deleteFlow(flowId, 'mcp_get_tokens');
await flowManager.deleteFlow(flowId, 'mcp_oauth');
} catch (error) {
logger.warn(
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
error,
);
}
};
/**
* Uninstalling an MCP plugin: revoke its OAuth tokens at the provider, then
* forget them here.
*
* Revocation is best-effort and clearing is not. Every path that could not
* revoke — the server no longer declares OAuth, the client metadata is gone,
* the tokens will not decrypt — used to RETURN, leaving the stored tokens
* behind for a plugin the user had just removed. Reconfiguring a server without
* OAuth was enough to strand them permanently. So the local state goes in all
* cases, and a failure upstream only costs the revocation.
*/
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)) {
return clearStoredMCPOAuthState(userId, serverName);
}
// 1. get client info used for revocation (client id, secret)
let clientTokenData = null;
try {
clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
userId,
serverName,
findToken,
});
} catch (error) {
logger.warn(
`[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
error,
);
}
if (clientTokenData == null) {
return clearStoredMCPOAuthState(userId, serverName);
}
const { clientInfo, clientMetadata } = clientTokenData;
// 2. get decrypted tokens before deletion
let tokens = null;
try {
tokens = await MCPTokenStorage.getTokens({
userId,
serverName,
findToken,
});
} catch (error) {
logger.warn(
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
error,
);
}
// 3. revoke OAuth tokens at the provider
const revocationEndpoint =
serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
const revocationEndpointAuthMethodsSupported =
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
clientMetadata.revocation_endpoint_auth_methods_supported;
const oauthHeaders = serverConfig.oauth_headers ?? {};
if (tokens?.access_token) {
try {
await MCPOAuthHandler.revokeOAuthToken(
serverName,
tokens.access_token,
'access',
{
serverUrl: serverConfig.url,
clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint,
revocationEndpointAuthMethodsSupported,
},
oauthHeaders,
);
} catch (error) {
logger.error(`Error revoking OAuth access token for ${serverName}:`, error);
}
}
if (tokens?.refresh_token) {
try {
await MCPOAuthHandler.revokeOAuthToken(
serverName,
tokens.refresh_token,
'refresh',
{
serverUrl: serverConfig.url,
clientId: clientInfo.client_id,
clientSecret: clientInfo.client_secret ?? '',
revocationEndpoint,
revocationEndpointAuthMethodsSupported,
},
oauthHeaders,
);
} catch (error) {
logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error);
}
}
// 4. forget the tokens and the flow state, whether or not revocation ran
await clearStoredMCPOAuthState(userId, serverName);
};
module.exports = {
getUserController,
getTermsStatusController,
acceptTermsController,
tourController,
deleteUserController,
/** Not a route — uninstalling an MCP plugin revokes its OAuth tokens at the
* provider, and `maybeUninstallOAuthMCP.spec.js` tests that directly. */
maybeUninstallOAuthMCP,
updateUserPluginsController,
};