From 34fd2a802fcd63b4bdf0717734126ad2c91c116a Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 22 Jan 2026 12:38:51 +0000 Subject: [PATCH 01/94] Duplicate OpenID strategy to enable Solid login --- api/server/socialLogins.js | 4 +- api/strategies/MyopenidStrategy.js | 589 +++++++++++++++++++++++++++++ 2 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 api/strategies/MyopenidStrategy.js diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index a84c33bd52de..14c9e6f15446 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -7,7 +7,7 @@ const { openIdJwtLogin, facebookLogin, discordLogin, - setupOpenId, + MysetupOpenId, googleLogin, githubLogin, appleLogin, @@ -36,7 +36,7 @@ async function configureOpenId(app) { app.use(session(sessionOptions)); app.use(passport.session()); - const config = await setupOpenId(); + const config = await MysetupOpenId(); if (!config) { logger.error('OpenID Connect configuration failed - strategy not registered.'); return; diff --git a/api/strategies/MyopenidStrategy.js b/api/strategies/MyopenidStrategy.js new file mode 100644 index 000000000000..2ebd1e780a8c --- /dev/null +++ b/api/strategies/MyopenidStrategy.js @@ -0,0 +1,589 @@ +const undici = require('undici'); +const { get } = require('lodash'); +const fetch = require('node-fetch'); +const passport = require('passport'); +const client = require('openid-client'); +const jwtDecode = require('jsonwebtoken/decode'); +const { HttpsProxyAgent } = require('https-proxy-agent'); +const { hashToken, logger } = require('@librechat/data-schemas'); +const { CacheKeys, ErrorTypes } = require('librechat-data-provider'); +const { Strategy: OpenIDStrategy } = require('openid-client/passport'); +const { + isEnabled, + logHeaders, + safeStringify, + findOpenIDUser, + getBalanceConfig, + isEmailDomainAllowed, +} = require('@librechat/api'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { findUser, createUser, updateUser } = require('~/models'); +const { getAppConfig } = require('~/server/services/Config'); +const getLogStores = require('~/cache/getLogStores'); + +/** + * @typedef {import('openid-client').ClientMetadata} ClientMetadata + * @typedef {import('openid-client').Configuration} Configuration + **/ + +/** + * @param {string} url + * @param {client.CustomFetchOptions} options + */ +async function customFetch(url, options) { + const urlStr = url.toString(); + logger.debug(`[MyopenidStrategy] Request to: ${urlStr}`); + const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS); + if (debugOpenId) { + logger.debug(`[MyopenidStrategy] Request method: ${options.method || 'GET'}`); + logger.debug(`[MyopenidStrategy] Request headers: ${logHeaders(options.headers)}`); + if (options.body) { + let bodyForLogging = ''; + if (options.body instanceof URLSearchParams) { + bodyForLogging = options.body.toString(); + } else if (typeof options.body === 'string') { + bodyForLogging = options.body; + } else { + bodyForLogging = safeStringify(options.body); + } + logger.debug(`[MyopenidStrategy] Request body: ${bodyForLogging}`); + } + } + + try { + /** @type {undici.RequestInit} */ + let fetchOptions = options; + if (process.env.PROXY) { + logger.info(`[MyopenidStrategy] proxy agent configured: ${process.env.PROXY}`); + fetchOptions = { + ...options, + dispatcher: new undici.ProxyAgent(process.env.PROXY), + }; + } + + const response = await undici.fetch(url, fetchOptions); + + if (debugOpenId) { + logger.debug(`[MyopenidStrategy] Response status: ${response.status} ${response.statusText}`); + logger.debug(`[MyopenidStrategy] Response headers: ${logHeaders(response.headers)}`); + } + + if (response.status === 200 && response.headers.has('www-authenticate')) { + const wwwAuth = response.headers.get('www-authenticate'); + logger.warn(`[MyopenidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}. +This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`); + + /** Cloned response without the WWW-Authenticate header */ + const responseBody = await response.arrayBuffer(); + const newHeaders = new Headers(); + for (const [key, value] of response.headers.entries()) { + if (key.toLowerCase() !== 'www-authenticate') { + newHeaders.append(key, value); + } + } + + return new Response(responseBody, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + } + + return response; + } catch (error) { + logger.error(`[MyopenidStrategy] Fetch error: ${error.message}`); + throw error; + } +} + +/** @typedef {Configuration | null} */ +let openidConfig = null; + +/** + * Custom OpenID Strategy + * + * Note: Originally overrode currentUrl() to work around Express 4's req.host not including port. + * With Express 5, req.host now includes the port by default, but we continue to use DOMAIN_SERVER + * for consistency and explicit configuration control. + * More info: https://github.com/panva/openid-client/pull/713 + */ +class CustomOpenIDStrategy extends OpenIDStrategy { + currentUrl(req) { + const hostAndProtocol = process.env.DOMAIN_SERVER; + return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`); + } + + authorizationRequestParams(req, options) { + const params = super.authorizationRequestParams(req, options); + if (options?.state && !params.has('state')) { + params.set('state', options.state); + } + + if (process.env.OPENID_AUDIENCE) { + params.set('audience', process.env.OPENID_AUDIENCE); + logger.debug( + `[MyopenidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`, + ); + } + + /** Generate nonce for federated providers that require it */ + const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); + if (shouldGenerateNonce && !params.has('nonce') && this._sessionKey) { + const crypto = require('crypto'); + const nonce = crypto.randomBytes(16).toString('hex'); + params.set('nonce', nonce); + logger.debug('[MyopenidStrategy] Generated nonce for federated provider:', nonce); + } + + return params; + } +} + +/** + * Exchange the access token for a new access token using the on-behalf-of flow if required. + * @param {Configuration} config + * @param {string} accessToken access token to be exchanged if necessary + * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token + * @param {boolean} fromCache - Indicates whether to use cached tokens. + * @returns {Promise} The new access token if exchanged, otherwise the original access token. + */ +const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => { + const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS); + const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED); + if (onBehalfFlowRequired) { + if (fromCache) { + const cachedToken = await tokensCache.get(sub); + if (cachedToken) { + return cachedToken.access_token; + } + } + const grantResponse = await client.genericGrantRequest( + config, + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + { + scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read', + assertion: accessToken, + requested_token_use: 'on_behalf_of', + }, + ); + await tokensCache.set( + sub, + { + access_token: grantResponse.access_token, + }, + grantResponse.expires_in * 1000, + ); + return grantResponse.access_token; + } + return accessToken; +}; + +/** + * get user info from openid provider + * @param {Configuration} config + * @param {string} accessToken access token + * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token + * @returns {Promise} + */ +const getUserInfo = async (config, accessToken, sub) => { + try { + const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub); + return await client.fetchUserInfo(config, exchangedAccessToken, sub); + } catch (error) { + logger.error('[MyopenidStrategy] getUserInfo: Error fetching user info:', error); + return null; + } +}; + +/** + * Downloads an image from a URL using an access token. + * @param {string} url + * @param {Configuration} config + * @param {string} accessToken access token + * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token + * @returns {Promise} The image buffer or an empty string if the download fails. + */ +const downloadImage = async (url, config, accessToken, sub) => { + const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true); + if (!url) { + return ''; + } + + try { + const options = { + method: 'GET', + headers: { + Authorization: `Bearer ${exchangedAccessToken}`, + }, + }; + + if (process.env.PROXY) { + options.agent = new HttpsProxyAgent(process.env.PROXY); + } + + const response = await fetch(url, options); + + if (response.ok) { + const buffer = await response.buffer(); + return buffer; + } else { + throw new Error(`${response.statusText} (HTTP ${response.status})`); + } + } catch (error) { + logger.error( + `[MyopenidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`, + ); + return ''; + } +}; + +/** + * Determines the full name of a user based on OpenID userinfo and environment configuration. + * + * @param {Object} userinfo - The user information object from OpenID Connect + * @param {string} [userinfo.given_name] - The user's first name + * @param {string} [userinfo.family_name] - The user's last name + * @param {string} [userinfo.username] - The user's username + * @param {string} [userinfo.email] - The user's email address + * @returns {string} The determined full name of the user + */ +function getFullName(userinfo) { + if (process.env.OPENID_NAME_CLAIM) { + return userinfo[process.env.OPENID_NAME_CLAIM]; + } + + if (userinfo.given_name && userinfo.family_name) { + return `${userinfo.given_name} ${userinfo.family_name}`; + } + + if (userinfo.given_name) { + return userinfo.given_name; + } + + if (userinfo.family_name) { + return userinfo.family_name; + } + + return userinfo.username || userinfo.email; +} + +/** + * Converts an input into a string suitable for a username. + * If the input is a string, it will be returned as is. + * If the input is an array, elements will be joined with underscores. + * In case of undefined or other falsy values, a default value will be returned. + * + * @param {string | string[] | undefined} input - The input value to be converted into a username. + * @param {string} [defaultValue=''] - The default value to return if the input is falsy. + * @returns {string} The processed input as a string suitable for a username. + */ +function convertToUsername(input, defaultValue = '') { + if (typeof input === 'string') { + return input; + } else if (Array.isArray(input)) { + return input.join('_'); + } + + return defaultValue; +} + +/** + * Sets up the OpenID strategy for authentication. + * This function configures the OpenID client, handles proxy settings, + * and defines the OpenID strategy for Passport.js. + * + * @async + * @function setupOpenId + * @returns {Promise} A promise that resolves when the OpenID strategy is set up and returns the openid client config object. + * @throws {Error} If an error occurs during the setup process. + */ +async function MysetupOpenId() { + try { + const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); + + /** @type {ClientMetadata} */ + const clientMetadata = { + client_id: process.env.OPENID_CLIENT_ID, + client_secret: process.env.OPENID_CLIENT_SECRET, + }; + + if (shouldGenerateNonce) { + clientMetadata.response_types = ['code']; + clientMetadata.grant_types = ['authorization_code']; + clientMetadata.token_endpoint_auth_method = 'client_secret_post'; + } + + /** @type {Configuration} */ + openidConfig = await client.discovery( + new URL(process.env.OPENID_ISSUER), + process.env.OPENID_CLIENT_ID, + clientMetadata, + undefined, + { + [client.customFetch]: customFetch, + }, + ); + + const requiredRole = process.env.OPENID_REQUIRED_ROLE; + const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; + const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND; + const usePKCE = isEnabled(process.env.OPENID_USE_PKCE); + logger.info(`[MyopenidStrategy] OpenID authentication configuration`, { + generateNonce: shouldGenerateNonce, + reason: shouldGenerateNonce + ? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers' + : 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata', + }); + + // Set of env variables that specify how to set if a user is an admin + // If not set, all users will be treated as regular users + const adminRole = process.env.OPENID_ADMIN_ROLE; + const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + + const openidLogin = new CustomOpenIDStrategy( + { + config: openidConfig, + scope: process.env.OPENID_SCOPE, + callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL, + clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, + usePKCE, + }, + /** + * @param {import('openid-client').TokenEndpointResponseHelpers} tokenset + * @param {import('passport-jwt').VerifyCallback} done + */ + async (tokenset, done) => { + try { + const claims = tokenset.claims(); + const userinfo = { + ...claims, + ...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)), + }; + + const appConfig = await getAppConfig(); + /** Azure AD sometimes doesn't return email, use preferred_username as fallback */ + const email = userinfo.email || userinfo.preferred_username || userinfo.upn || `${userinfo.webid}@FAKEDOMAIN.TLD`; + if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + logger.error( + `[My OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`, + ); + return done(null, false, { message: 'Email domain not allowed' }); + } + + const result = await findOpenIDUser({ + findUser, + email: email, + openidId: claims.sub, + idOnTheSource: claims.oid, + strategyName: 'MyopenidStrategy', + }); + let user = result.user; + const error = result.error; + + if (error) { + return done(null, false, { + message: ErrorTypes.AUTH_FAILED, + }); + } + + const fullName = getFullName(userinfo); + + if (requiredRole) { + const requiredRoles = requiredRole + .split(',') + .map((role) => role.trim()) + .filter(Boolean); + let decodedToken = ''; + if (requiredRoleTokenKind === 'access') { + decodedToken = jwtDecode(tokenset.access_token); + } else if (requiredRoleTokenKind === 'id') { + decodedToken = jwtDecode(tokenset.id_token); + } + + let roles = get(decodedToken, requiredRoleParameterPath); + if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { + logger.error( + `[MyopenidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`, + ); + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + return done(null, false, { + message: `You must have ${rolesList} role to log in.`, + }); + } + + if (!requiredRoles.some((role) => roles.includes(role))) { + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + return done(null, false, { + message: `You must have ${rolesList} role to log in.`, + }); + } + } + + let username = ''; + if (process.env.OPENID_USERNAME_CLAIM) { + username = userinfo[process.env.OPENID_USERNAME_CLAIM]; + } else { + username = convertToUsername( + userinfo.preferred_username || userinfo.username || userinfo.email, + ); + } + + if (!user) { + user = { + provider: 'openid', + openidId: userinfo.sub, + username, + email: email || '', + emailVerified: userinfo.email_verified || false, + name: fullName, + idOnTheSource: userinfo.oid, + }; + + const balanceConfig = getBalanceConfig(appConfig); + user = await createUser(user, balanceConfig, true, true); + } else { + user.provider = 'openid'; + user.openidId = userinfo.sub; + user.username = username; + user.name = fullName; + user.idOnTheSource = userinfo.oid; + if (email && email !== user.email) { + user.email = email; + user.emailVerified = userinfo.email_verified || false; + } + } + + if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { + let adminRoleObject; + switch (adminRoleTokenKind) { + case 'access': + adminRoleObject = jwtDecode(tokenset.access_token); + break; + case 'id': + adminRoleObject = jwtDecode(tokenset.id_token); + break; + case 'userinfo': + adminRoleObject = userinfo; + break; + default: + logger.error( + `[MyopenidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, + ); + return done(new Error('Invalid admin role token kind')); + } + + const adminRoles = get(adminRoleObject, adminRoleParameterPath); + + // Accept 3 types of values for the object extracted from adminRoleParameterPath: + // 1. A boolean value indicating if the user is an admin + // 2. A string with a single role name + // 3. An array of role names + + if ( + adminRoles && + (adminRoles === true || + adminRoles === adminRole || + (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) + ) { + user.role = 'ADMIN'; + logger.info( + `[MyopenidStrategy] User ${username} is an admin based on role: ${adminRole}`, + ); + } else if (user.role === 'ADMIN') { + user.role = 'USER'; + logger.info( + `[MyopenidStrategy] User ${username} demoted from admin - role no longer present in token`, + ); + } + } + + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { + /** @type {string | undefined} */ + const imageUrl = userinfo.picture; + + let fileName; + if (crypto) { + fileName = (await hashToken(userinfo.sub)) + '.png'; + } else { + fileName = userinfo.sub + '.png'; + } + + const imageBuffer = await downloadImage( + imageUrl, + openidConfig, + tokenset.access_token, + userinfo.sub, + ); + if (imageBuffer) { + const { saveBuffer } = getStrategyFunctions( + appConfig?.fileStrategy ?? process.env.CDN_PROVIDER, + ); + const imagePath = await saveBuffer({ + fileName, + userId: user._id.toString(), + buffer: imageBuffer, + }); + user.avatar = imagePath ?? ''; + } + } + + user = await updateUser(user._id, user); + + logger.info( + `[MyopenidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, + { + user: { + openidId: user.openidId, + username: user.username, + email: user.email, + name: user.name, + }, + }, + ); + + done(null, { + ...user, + tokenset, + federatedTokens: { + access_token: tokenset.access_token, + refresh_token: tokenset.refresh_token, + expires_at: tokenset.expires_at, + }, + }); + } catch (err) { + logger.error('[MyopenidStrategy] login failed', err); + done(err); + } + }, + ); + passport.use('openid', openidLogin); + return openidConfig; + } catch (err) { + logger.error('[MyopenidStrategy]', err); + return null; + } +} +/** + * @function getOpenIdConfig + * @description Returns the OpenID client instance. + * @throws {Error} If the OpenID client is not initialized. + * @returns {Configuration} + */ +function MygetOpenIdConfig() { + if (!openidConfig) { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + } + return openidConfig; +} + +module.exports = { + MysetupOpenId, + MygetOpenIdConfig, +}; From d61dfb0f40f641780703d489982584f5e9609b98 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 22 Jan 2026 12:44:00 +0000 Subject: [PATCH 02/94] Proper naming for Solid-OIDC strategy --- api/server/socialLogins.js | 4 +- ...enidStrategy.js => SolidOpenidStrategy.js} | 54 +++++++++---------- api/strategies/index.js | 5 ++ 3 files changed, 34 insertions(+), 29 deletions(-) rename api/strategies/{MyopenidStrategy.js => SolidOpenidStrategy.js} (89%) diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 14c9e6f15446..57978197531e 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -7,7 +7,7 @@ const { openIdJwtLogin, facebookLogin, discordLogin, - MysetupOpenId, + setupSolidOpenId, googleLogin, githubLogin, appleLogin, @@ -36,7 +36,7 @@ async function configureOpenId(app) { app.use(session(sessionOptions)); app.use(passport.session()); - const config = await MysetupOpenId(); + const config = await setupSolidOpenId(); if (!config) { logger.error('OpenID Connect configuration failed - strategy not registered.'); return; diff --git a/api/strategies/MyopenidStrategy.js b/api/strategies/SolidOpenidStrategy.js similarity index 89% rename from api/strategies/MyopenidStrategy.js rename to api/strategies/SolidOpenidStrategy.js index 2ebd1e780a8c..3c4221b0717f 100644 --- a/api/strategies/MyopenidStrategy.js +++ b/api/strategies/SolidOpenidStrategy.js @@ -32,11 +32,11 @@ const getLogStores = require('~/cache/getLogStores'); */ async function customFetch(url, options) { const urlStr = url.toString(); - logger.debug(`[MyopenidStrategy] Request to: ${urlStr}`); + logger.debug(`[SolidOpenidStrategy] Request to: ${urlStr}`); const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS); if (debugOpenId) { - logger.debug(`[MyopenidStrategy] Request method: ${options.method || 'GET'}`); - logger.debug(`[MyopenidStrategy] Request headers: ${logHeaders(options.headers)}`); + logger.debug(`[SolidOpenidStrategy] Request method: ${options.method || 'GET'}`); + logger.debug(`[SolidOpenidStrategy] Request headers: ${logHeaders(options.headers)}`); if (options.body) { let bodyForLogging = ''; if (options.body instanceof URLSearchParams) { @@ -46,7 +46,7 @@ async function customFetch(url, options) { } else { bodyForLogging = safeStringify(options.body); } - logger.debug(`[MyopenidStrategy] Request body: ${bodyForLogging}`); + logger.debug(`[SolidOpenidStrategy] Request body: ${bodyForLogging}`); } } @@ -54,7 +54,7 @@ async function customFetch(url, options) { /** @type {undici.RequestInit} */ let fetchOptions = options; if (process.env.PROXY) { - logger.info(`[MyopenidStrategy] proxy agent configured: ${process.env.PROXY}`); + logger.info(`[SolidOpenidStrategy] proxy agent configured: ${process.env.PROXY}`); fetchOptions = { ...options, dispatcher: new undici.ProxyAgent(process.env.PROXY), @@ -64,13 +64,13 @@ async function customFetch(url, options) { const response = await undici.fetch(url, fetchOptions); if (debugOpenId) { - logger.debug(`[MyopenidStrategy] Response status: ${response.status} ${response.statusText}`); - logger.debug(`[MyopenidStrategy] Response headers: ${logHeaders(response.headers)}`); + logger.debug(`[SolidOpenidStrategy] Response status: ${response.status} ${response.statusText}`); + logger.debug(`[SolidOpenidStrategy] Response headers: ${logHeaders(response.headers)}`); } if (response.status === 200 && response.headers.has('www-authenticate')) { const wwwAuth = response.headers.get('www-authenticate'); - logger.warn(`[MyopenidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}. + logger.warn(`[SolidOpenidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}. This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`); /** Cloned response without the WWW-Authenticate header */ @@ -91,7 +91,7 @@ This violates RFC 7235 and may cause issues with strict OAuth clients. Removing return response; } catch (error) { - logger.error(`[MyopenidStrategy] Fetch error: ${error.message}`); + logger.error(`[SolidOpenidStrategy] Fetch error: ${error.message}`); throw error; } } @@ -122,7 +122,7 @@ class CustomOpenIDStrategy extends OpenIDStrategy { if (process.env.OPENID_AUDIENCE) { params.set('audience', process.env.OPENID_AUDIENCE); logger.debug( - `[MyopenidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`, + `[SolidOpenidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`, ); } @@ -132,7 +132,7 @@ class CustomOpenIDStrategy extends OpenIDStrategy { const crypto = require('crypto'); const nonce = crypto.randomBytes(16).toString('hex'); params.set('nonce', nonce); - logger.debug('[MyopenidStrategy] Generated nonce for federated provider:', nonce); + logger.debug('[SolidOpenidStrategy] Generated nonce for federated provider:', nonce); } return params; @@ -190,7 +190,7 @@ const getUserInfo = async (config, accessToken, sub) => { const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub); return await client.fetchUserInfo(config, exchangedAccessToken, sub); } catch (error) { - logger.error('[MyopenidStrategy] getUserInfo: Error fetching user info:', error); + logger.error('[SolidOpenidStrategy] getUserInfo: Error fetching user info:', error); return null; } }; @@ -231,7 +231,7 @@ const downloadImage = async (url, config, accessToken, sub) => { } } catch (error) { logger.error( - `[MyopenidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`, + `[SolidOpenidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`, ); return ''; } @@ -297,7 +297,7 @@ function convertToUsername(input, defaultValue = '') { * @returns {Promise} A promise that resolves when the OpenID strategy is set up and returns the openid client config object. * @throws {Error} If an error occurs during the setup process. */ -async function MysetupOpenId() { +async function setupSolidOpenId() { try { const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); @@ -328,7 +328,7 @@ async function MysetupOpenId() { const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND; const usePKCE = isEnabled(process.env.OPENID_USE_PKCE); - logger.info(`[MyopenidStrategy] OpenID authentication configuration`, { + logger.info(`[SolidOpenidStrategy] OpenID authentication configuration`, { generateNonce: shouldGenerateNonce, reason: shouldGenerateNonce ? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers' @@ -366,7 +366,7 @@ async function MysetupOpenId() { const email = userinfo.email || userinfo.preferred_username || userinfo.upn || `${userinfo.webid}@FAKEDOMAIN.TLD`; if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { logger.error( - `[My OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`, + `[SolidOpenidStrategy] Authentication blocked - email domain not allowed [Email: ${email}]`, ); return done(null, false, { message: 'Email domain not allowed' }); } @@ -376,7 +376,7 @@ async function MysetupOpenId() { email: email, openidId: claims.sub, idOnTheSource: claims.oid, - strategyName: 'MyopenidStrategy', + strategyName: 'SolidOpenidStrategy', }); let user = result.user; const error = result.error; @@ -404,7 +404,7 @@ async function MysetupOpenId() { let roles = get(decodedToken, requiredRoleParameterPath); if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) { logger.error( - `[MyopenidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`, + `[SolidOpenidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`, ); const rolesList = requiredRoles.length === 1 @@ -474,7 +474,7 @@ async function MysetupOpenId() { break; default: logger.error( - `[MyopenidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, + `[SolidOpenidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, ); return done(new Error('Invalid admin role token kind')); } @@ -494,12 +494,12 @@ async function MysetupOpenId() { ) { user.role = 'ADMIN'; logger.info( - `[MyopenidStrategy] User ${username} is an admin based on role: ${adminRole}`, + `[SolidOpenidStrategy] User ${username} is an admin based on role: ${adminRole}`, ); } else if (user.role === 'ADMIN') { user.role = 'USER'; logger.info( - `[MyopenidStrategy] User ${username} demoted from admin - role no longer present in token`, + `[SolidOpenidStrategy] User ${username} demoted from admin - role no longer present in token`, ); } } @@ -537,7 +537,7 @@ async function MysetupOpenId() { user = await updateUser(user._id, user); logger.info( - `[MyopenidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, + `[SolidOpenidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `, { user: { openidId: user.openidId, @@ -558,7 +558,7 @@ async function MysetupOpenId() { }, }); } catch (err) { - logger.error('[MyopenidStrategy] login failed', err); + logger.error('[SolidOpenidStrategy] login failed', err); done(err); } }, @@ -566,7 +566,7 @@ async function MysetupOpenId() { passport.use('openid', openidLogin); return openidConfig; } catch (err) { - logger.error('[MyopenidStrategy]', err); + logger.error('[SolidOpenidStrategy]', err); return null; } } @@ -576,7 +576,7 @@ async function MysetupOpenId() { * @throws {Error} If the OpenID client is not initialized. * @returns {Configuration} */ -function MygetOpenIdConfig() { +function getSolidOpenIdConfig() { if (!openidConfig) { throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); } @@ -584,6 +584,6 @@ function MygetOpenIdConfig() { } module.exports = { - MysetupOpenId, - MygetOpenIdConfig, + setupSolidOpenId, + getSolidOpenIdConfig, }; diff --git a/api/strategies/index.js b/api/strategies/index.js index 9a1c58ad38c6..67c105ca1d78 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -5,6 +5,11 @@ const discordLogin = require('./discordStrategy'); const passportLogin = require('./localStrategy'); const googleLogin = require('./googleStrategy'); const githubLogin = require('./githubStrategy'); +const discordLogin = require('./discordStrategy'); +const facebookLogin = require('./facebookStrategy'); +const { setupSolidOpenId, getSolidOpenIdConfig } = require('./SolidOpenidStrategy'); +const jwtLogin = require('./jwtStrategy'); +const ldapLogin = require('./ldapStrategy'); const { setupSaml } = require('./samlStrategy'); const appleLogin = require('./appleStrategy'); const ldapLogin = require('./ldapStrategy'); From 67e3081aad2c258119e0911bcbed96f6ea05e182 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 22 Jan 2026 13:35:23 +0000 Subject: [PATCH 03/94] Add task reminders about email in Solid login Co-authored-by: Jesse Wright <63333554+jeswr@users.noreply.github.com> --- api/strategies/SolidOpenidStrategy.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/strategies/SolidOpenidStrategy.js b/api/strategies/SolidOpenidStrategy.js index 3c4221b0717f..0b91bdb98682 100644 --- a/api/strategies/SolidOpenidStrategy.js +++ b/api/strategies/SolidOpenidStrategy.js @@ -363,6 +363,9 @@ async function setupSolidOpenId() { const appConfig = await getAppConfig(); /** Azure AD sometimes doesn't return email, use preferred_username as fallback */ + // TODO: jeswr - Potentially fetch an email from the user's WebID if they have a `foaf:mbox` + // TODO: jeswr - Can codebase support email or WebID rather than requiring email? + // TODO: jeswr - Suggest opening issues for these once we PR the changes upstream const email = userinfo.email || userinfo.preferred_username || userinfo.upn || `${userinfo.webid}@FAKEDOMAIN.TLD`; if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { logger.error( From 13c1038b6313ac2ae21da49fc1d0fcf4889eef56 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 22 Jan 2026 14:09:01 +0000 Subject: [PATCH 04/94] Add Solid-OIDC Client ID Document route --- api/server/index.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/api/server/index.js b/api/server/index.js index f034f102367f..b26a1ea1d114 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -164,7 +164,23 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); - /** 404 for unmatched API routes */ + // Solid-OIDC Client ID Document route, see spec https://solidproject.org/TR/oidc#clientids-document + // TODO: Expose this route only if adequate according to config, i.e. when Solid-OIDC is in use + // TODO: Follow 'app.use' pattern instead of inlining route here + // TODO: Make path configurable instead of hardcoding here + app.get('/solid-client-id', (_, res) => { + // TODO: Use constants/enums for header name and value if available from framework + res.set('Content-Type', 'application/ld+json'); + + res.send({ + '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], + + // TODO: Take host & port from config or API instead of hardcoding + // TODO: Take callback path from config or API instead of hardcoding + redirect_uris: ['http://localhost:3080/oauth/openid/callback'], + }); + }); + app.use('/api', apiNotFound); /** SPA fallback - serve index.html for all unmatched routes */ From ddbb221cd5ff005f114df220267066a6fd6ce650 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 22 Jan 2026 14:20:54 +0000 Subject: [PATCH 05/94] Take Solid-OIDC redirect URI from config --- api/server/index.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api/server/index.js b/api/server/index.js index b26a1ea1d114..6fb9035468c2 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -174,10 +174,7 @@ const startServer = async () => { res.send({ '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], - - // TODO: Take host & port from config or API instead of hardcoding - // TODO: Take callback path from config or API instead of hardcoding - redirect_uris: ['http://localhost:3080/oauth/openid/callback'], + redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL], }); }); From 8b514482e1bfe2a9d08dd64a3d6891e6e8cba481 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 22 Jan 2026 16:37:01 +0000 Subject: [PATCH 06/94] Temporarily allow insecure OIDC AS for local development --- api/strategies/SolidOpenidStrategy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/api/strategies/SolidOpenidStrategy.js b/api/strategies/SolidOpenidStrategy.js index 0b91bdb98682..d5ea0a0e4705 100644 --- a/api/strategies/SolidOpenidStrategy.js +++ b/api/strategies/SolidOpenidStrategy.js @@ -321,6 +321,7 @@ async function setupSolidOpenId() { undefined, { [client.customFetch]: customFetch, + execute: [client.allowInsecureRequests], // TODO: Insecure! Remove deprecated hack used for local HTTP only. }, ); From a9dfe59678994559ebeefef3bad52ab62cb712dc Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Thu, 22 Jan 2026 17:29:13 +0000 Subject: [PATCH 07/94] Add `client_id` to Solid-OIDC Client ID Document --- api/server/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/server/index.js b/api/server/index.js index 6fb9035468c2..5ca3ad3db0dd 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -175,6 +175,9 @@ const startServer = async () => { res.send({ '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL], + + // TODO: Don't hardcode path + client_id: process.env.DOMAIN_SERVER + '/solid-client-id', }); }); From c53cf67d7f37514fe87905630e1725a1507306f9 Mon Sep 17 00:00:00 2001 From: Samu Lang Date: Fri, 23 Jan 2026 09:44:50 +0000 Subject: [PATCH 08/94] Don't require client secret for Solid-OIDC --- api/server/routes/config.js | 1 - api/server/socialLogins.js | 1 - 2 files changed, 2 deletions(-) diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a2dc5b79d27f..ccbbaf8a0114 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -46,7 +46,6 @@ router.get('/', async function (req, res) { const isOpenIdEnabled = !!process.env.OPENID_CLIENT_ID && - !!process.env.OPENID_CLIENT_SECRET && !!process.env.OPENID_ISSUER && !!process.env.OPENID_SESSION_SECRET; diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 57978197531e..a65e996be15a 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -73,7 +73,6 @@ const configureSocialLogins = async (app) => { } if ( process.env.OPENID_CLIENT_ID && - process.env.OPENID_CLIENT_SECRET && process.env.OPENID_ISSUER && process.env.OPENID_SCOPE && process.env.OPENID_SESSION_SECRET From f954750eae698530cca6c72d64faf4be6d6dcf35 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 29 Jan 2026 10:33:10 +0000 Subject: [PATCH 09/94] Added Middleware to log authorization code from received from Solid/OpenID provider. --- api/server/routes/oauth.js | 34 ++ docker-compose.yml | 2 + package-lock.json | 628 +++++++++++++++++++++++++++++++++++-- 3 files changed, 630 insertions(+), 34 deletions(-) diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index f4bb5b6026e6..ecc4749255f4 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -97,8 +97,42 @@ router.get('/openid', (req, res, next) => { })(req, res, next); }); +/** + * Middleware to log authorization code from Solid/OpenID provider + */ +const logAuthorizationCode = (req, res, next) => { + const { code, state, error } = req.query; + + if (code) { + logger.info('[OpenID Callback] Authorization code received from Solid provider', { + authorizationCode: code, + state: state || 'not provided', + hasError: !!error, + error: error || null, + queryParams: { + code: code ? 'present' : 'missing', + state: state || 'missing', + error: error || 'none', + }, + }); + logger.info(`[OpenID Callback] Full authorization code: ${code}`); + } else if (error) { + logger.warn('[OpenID Callback] OAuth error received (no authorization code)', { + error, + state: state || 'not provided', + }); + } else { + logger.warn('[OpenID Callback] No authorization code or error in callback', { + queryParams: req.query, + }); + } + + next(); +}; + router.get( '/openid/callback', + logAuthorizationCode, passport.authenticate('openid', { failureRedirect: `${domains.client}/oauth/error`, failureMessage: true, diff --git a/docker-compose.yml b/docker-compose.yml index 079cdb74b6de..61f214f34773 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,8 @@ services: - ./uploads:/app/uploads - ./logs:/app/logs mongodb: + ports: + - "27017:27017" container_name: chat-mongodb image: mongo:8.0.17 restart: always diff --git a/package-lock.json b/package-lock.json index 1126527dd051..10864619ef0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2369,6 +2369,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -2755,6 +2756,7 @@ "resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.14.3.tgz", "integrity": "sha512-GJZTkkvN66gM3Epqm9laKEjC3orQqzmQt8JAgTN9+zlb+I+1/oEd3Z7rj2tkEKCTeOUVScdhcXPudN8GdpuGqA==", "license": "MIT", + "peer": true, "dependencies": { "@anthropic-ai/sdk": ">=0.50.3 <1", "google-auth-library": "^9.4.2" @@ -2789,6 +2791,7 @@ "resolved": "https://registry.npmjs.org/@ariakit/react/-/react-0.4.17.tgz", "integrity": "sha512-HQaIboE2axtlncJz1hRTaiQfJ1GGjhdtNcAnPwdjvl2RybfmlHowIB+HTVBp36LzroKPs/M4hPCxk7XTaqRZGg==", "license": "MIT", + "peer": true, "dependencies": { "@ariakit/react-core": "0.4.17" }, @@ -2806,6 +2809,7 @@ "resolved": "https://registry.npmjs.org/@ariakit/react-core/-/react-core-0.4.17.tgz", "integrity": "sha512-kFF6n+gC/5CRQIyaMTFoBPio2xUe0k9rZhMNdUobWRmc/twfeLVkODx+8UVYaNyKilTge8G0JFqwvFKku/jKEw==", "license": "MIT", + "peer": true, "dependencies": { "@ariakit/core": "0.4.15", "@floating-ui/dom": "^1.0.0", @@ -2918,6 +2922,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2941,6 +2946,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5129,6 +5135,7 @@ "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.8.0.tgz", "integrity": "sha512-l9ALUGHtFB/JfsqmA+9iYAp2a+cCwdNO/cyIr2y7nJLJsz1aae6qVP8XxT7Kbudg0IQRSIMXj0+iivFdbD1xPA==", "license": "MIT", + "peer": true, "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", @@ -5210,6 +5217,7 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/@azure/search-documents/-/search-documents-12.0.0.tgz", "integrity": "sha512-d9d53f2WWBpLHifk+LVn+AG52zuXvjgxJAdaH6kuT2qwrO1natcigtTgBM8qrI3iDYaDXsQhJSIMEgg9WKSoWA==", + "peer": true, "dependencies": { "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.3.0", @@ -5230,6 +5238,7 @@ "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.30.0.tgz", "integrity": "sha512-peDCR8blSqhsAKDbpSP/o55S4sheNwSrblvCaHUZ5xUI73XA7ieUGGwrONgD/Fng0EoDe1VOa3fAQ7+WGB3Ocg==", "license": "MIT", + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", @@ -5298,6 +5307,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -7593,6 +7603,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "license": "MIT", "engines": { "node": ">=20.19.0" @@ -7616,6 +7627,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "license": "MIT", "engines": { "node": ">=20.19.0" @@ -9027,6 +9039,7 @@ "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.4.tgz", "integrity": "sha512-I1wCUp0yu5qSIeMQHmDYXQIXKkKjcja/SYBxppPkYFXpR2alxb0k9/swFDdMbkY6a1c9AT1kI1y+Pg6ywQ2rTA==", "license": "MIT", + "peer": true, "dependencies": { "@dicebear/adventurer": "9.2.4", "@dicebear/adventurer-neutral": "9.2.4", @@ -9071,6 +9084,7 @@ "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.4.tgz", "integrity": "sha512-hz6zArEcUwkZzGOSJkWICrvqnEZY7BKeiq9rqKzVJIc1tRVv0MkR0FGvIxSvXiK9TTIgKwu656xCWAGAl6oh+w==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.11" }, @@ -9605,6 +9619,7 @@ "version": "0.10.16", "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.16.tgz", "integrity": "sha512-SUati2qH48gvVGnSsqMkZr1Iq7No52a3tJQ4itboSTM89Erezmw3v1RsfVymrDze9+KiOLmBpvLNKSvheITFjg==", + "peer": true, "dependencies": { "@firebase/component": "0.6.11", "@firebase/logger": "0.4.4", @@ -9666,6 +9681,7 @@ "version": "0.2.46", "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.46.tgz", "integrity": "sha512-9hSHWE5LMqtKIm13CnH5OZeMPbkVV3y5vgNZ5EMFHcG2ceRrncyNjG9No5XfWQw8JponZdGs4HlE4aMD/jxcFA==", + "peer": true, "dependencies": { "@firebase/app": "0.10.16", "@firebase/component": "0.6.11", @@ -9680,7 +9696,8 @@ "node_modules/@firebase/app-types": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", - "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==" + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "peer": true }, "node_modules/@firebase/auth": { "version": "1.8.1", @@ -10097,6 +10114,7 @@ "version": "1.10.2", "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.2.tgz", "integrity": "sha512-qnSHIoE9FK+HYnNhTI8q14evyqbc/vHRivfB4TgCIUOl4tosmKSQlp7ltymOlMP4xVIJTg5wrkfcZ60X4nUf7Q==", + "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -10331,6 +10349,7 @@ "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.4.tgz", "integrity": "sha512-lz+OGcAH1dK93rgSMzXmm1qKOJkBUqZf1L4M8TWLNplftQD3IkoEDdUFNfAn4ylsN6WOTVtWaLmvmaHOUk1dTA==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.20.2", @@ -11675,6 +11694,7 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.3.80.tgz", "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", "license": "MIT", + "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -12188,6 +12208,7 @@ "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.55.tgz", "integrity": "sha512-impxeKpCDlPkAVQFWnA6u6xkxDSBR/+H8uYq7rZomBeu0rUh/OhJLiI1fAwPhKXP33udNtHA8GyDi0QJj78R9w==", "license": "MIT", + "peer": true, "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.980.0", @@ -12316,6 +12337,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", @@ -12697,6 +12719,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -12706,7 +12729,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api": "^1.3.0" }, @@ -12732,6 +12754,7 @@ "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", "peer": true, + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -13883,6 +13906,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.207.0.tgz", "integrity": "sha512-ruUQB4FkWtxHjNmSXjrhmJZFvyMm+tBzHyMm7YPQshApy4wvZUTcrpPyP/A/rCl/8M4BwoVIZdiwijMdbZaq4w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.207.0", @@ -14127,7 +14151,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.211.0.tgz", "integrity": "sha512-bp1+63V8WPV+bRI9EQG6E9YID1LIHYSZVbp7f+44g9tRzCq+rtw/o4fpL5PC31adcUsFiz/oN0MdLISSrZDdrg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/otlp-transformer": "0.211.0" @@ -14416,7 +14439,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.211.0.tgz", "integrity": "sha512-O5nPwzgg2JHzo59kpQTPUOTzFi0Nv5LxryG27QoXBciX3zWM3z83g+SNOHhiQVYRWFSxoWn1JM2TGD5iNjOwdA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.211.0", "@opentelemetry/core": "2.5.0", @@ -14619,6 +14641,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -15207,6 +15230,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz", "integrity": "sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==", + "peer": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.1", @@ -15963,6 +15987,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.0.7.tgz", "integrity": "sha512-OcUN2FU0YpmajD/qkph3XzMcK/NmSk9hGWnjV68p6QiZMgILugusgQwnLSDs3oFSJYGKf3Y49zgFedhGh04k9A==", + "peer": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.1", @@ -15994,6 +16019,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz", "integrity": "sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==", + "peer": true, "peerDependencies": { "react": "^16.x || ^17.x || ^18.x" } @@ -17948,6 +17974,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "peer": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.1" @@ -18139,6 +18166,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", + "peer": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.1", @@ -18169,6 +18197,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.1.5.tgz", "integrity": "sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.1", @@ -18560,7 +18589,6 @@ "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-10.0.1.tgz", "integrity": "sha512-FgQk02OqFrYyJBTTnBTWAU0WPzkHkKXauc6aeexcvATvLapUxwnfGuLlsLYF8BYjEVfkivPT04ziAue6zyRBtQ==", "license": "MIT", - "peer": true, "dependencies": { "@react-spring/animated": "~10.0.1", "@react-spring/core": "~10.0.1", @@ -18577,7 +18605,6 @@ "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-10.0.1.tgz", "integrity": "sha512-BGL3hA66Y8Qm3KmRZUlfG/mFbDPYajgil2/jOP0VXf2+o2WPVmcDps/eEgdDqgf5Pv9eBbyj7LschLMuSjlW3Q==", "license": "MIT", - "peer": true, "dependencies": { "@react-spring/shared": "~10.0.1", "@react-spring/types": "~10.0.1" @@ -18591,7 +18618,6 @@ "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-10.0.1.tgz", "integrity": "sha512-KaMMsN1qHuVTsFpg/5ajAVye7OEqhYbCq0g4aKM9bnSZlDBBYpO7Uf+9eixyXN8YEbF+YXaYj9eoWDs+npZ+sA==", "license": "MIT", - "peer": true, "dependencies": { "@react-spring/animated": "~10.0.1", "@react-spring/shared": "~10.0.1", @@ -18609,15 +18635,13 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-10.0.1.tgz", "integrity": "sha512-UrzG/d6Is+9i0aCAjsjWRqIlFFiC4lFqFHrH63zK935z2YDU95TOFio4VKGISJ5SG0xq4ULy7c1V3KU+XvL+Yg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-spring/web/node_modules/@react-spring/shared": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-10.0.1.tgz", "integrity": "sha512-KR2tmjDShPruI/GGPfAZOOLvDgkhFseabjvxzZFFggJMPkyICLjO0J6mCIoGtdJSuHywZyc4Mmlgi+C88lS00g==", "license": "MIT", - "peer": true, "dependencies": { "@react-spring/rafz": "~10.0.1", "@react-spring/types": "~10.0.1" @@ -18630,8 +18654,7 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-10.0.1.tgz", "integrity": "sha512-Fk1wYVAKL+ZTYK+4YFDpHf3Slsy59pfFFvnnTfRjQQFGlyIo4VejPtDs3CbDiuBjM135YztRyZjIH2VbycB+ZQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@react-stately/flags": { "version": "3.1.2", @@ -18675,6 +18698,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -18863,6 +18887,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19929,6 +19954,7 @@ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz", "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@smithy/abort-controller": "^4.2.8", "@smithy/protocol-http": "^5.3.8", @@ -20478,6 +20504,7 @@ "version": "4.36.1", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.36.1.tgz", "integrity": "sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw==", + "peer": true, "dependencies": { "@tanstack/query-core": "4.36.1", "use-sync-external-store": "^1.2.0" @@ -21285,6 +21312,7 @@ "version": "20.11.16", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -21338,6 +21366,7 @@ "version": "18.2.53", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.53.tgz", "integrity": "sha512-52IHsMDT8qATp9B9zoOyobW8W3/0QhaJQTw1HwRj0UY2yBpCAQ7+S/CqHYQ8niAm3p4ji+rWUQ9UCib0GxQ60w==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -21349,6 +21378,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz", "integrity": "sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==", "devOptional": true, + "peer": true, "dependencies": { "@types/react": "*" } @@ -21502,6 +21532,7 @@ "integrity": "sha512-aFcXEJJCI4gUdXgoo/j9udUYIHgF23MFkg09LFz2dzEmU0+1Plk4rQWv/IYKvPHAtlkkGoB3m5e6oUp+JPsNaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.24.0", @@ -21532,6 +21563,7 @@ "integrity": "sha512-MFDaO9CYiard9j9VepMNa9MTcqVvSny2N4hkY6roquzj8pdCBRENhErrteaQuu7Yjn1ppk0v1/ZF9CG3KIlrTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/types": "8.24.0", @@ -22026,6 +22058,152 @@ "win32" ] }, + "node_modules/@webassemblyjs/ast": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.12.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@xtuc/long": "4.2.2" + } + }, "node_modules/@xmldom/is-dom-node": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", @@ -22043,6 +22221,18 @@ "node": ">=10.0.0" } }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -22104,6 +22294,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -22165,6 +22356,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -22193,6 +22385,15 @@ } } }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/anser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz", @@ -22693,6 +22894,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -23265,6 +23467,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -23705,6 +23908,15 @@ "node": ">= 6" } }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -23746,6 +23958,7 @@ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "clsx": "^2.1.1" }, @@ -23910,6 +24123,7 @@ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -24143,6 +24357,7 @@ "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-8.1.0.tgz", "integrity": "sha512-Km0EYLDlmExF52UCss5gLGTtrukGC57G6WCC2aqEMft5Vr4xNWuM4tL+T97kWrw+vp40SXFteb6Xk/7MxgpwdA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -24772,6 +24987,7 @@ "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -25196,6 +25412,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -25823,6 +26040,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -26190,6 +26408,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true + }, "node_modules/es-object-atoms": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", @@ -26344,6 +26568,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -26404,6 +26629,7 @@ "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "build/bin/cli.js" }, @@ -26515,6 +26741,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -26764,6 +26991,28 @@ "eslint": ">=5.0.0" } }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-scope/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -26981,6 +27230,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.2.tgz", "integrity": "sha512-YolzkJNxsTL3tCJMWFxpxtG2sCjbZ4LQUBUrkdaJK0ub0p6lmJt+2+1SwhKjLc652lpH9L/79Ptez972H9tphw==", + "peer": true, "dependencies": { "eventsource-parser": "^3.0.0" }, @@ -27067,6 +27317,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -27114,13 +27365,10 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", - "license": "MIT", - "dependencies": { - "ip-address": "10.0.1" - }, + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "peer": true, "engines": { "node": ">= 16" }, @@ -27136,6 +27384,7 @@ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.7", @@ -27654,6 +27903,7 @@ "version": "11.0.2", "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.0.2.tgz", "integrity": "sha512-w4T8BSJpzdZA25QRch5ahLsgB6uRvg1LEic4BaC5rTD1YygroI1AXp+W+rbMnr8d8EjfAv6t4k8doULIjc1P8Q==", + "peer": true, "dependencies": { "@firebase/analytics": "0.10.10", "@firebase/analytics-compat": "0.2.16", @@ -27792,6 +28042,7 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", + "peer": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -27867,7 +28118,6 @@ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.9.tgz", "integrity": "sha512-TqEHXj8LWfQSKqfdr5Y4mYltYLw96deu6/K9kGDd+ysqRJPNwF9nb5mZcrLmybHbU7gcJ+HQar41U3UTGanbbQ==", "license": "MIT", - "peer": true, "dependencies": { "motion-dom": "^12.23.9", "motion-utils": "^12.23.6", @@ -27895,7 +28145,6 @@ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.9.tgz", "integrity": "sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==", "license": "MIT", - "peer": true, "dependencies": { "motion-utils": "^12.23.6" } @@ -27904,8 +28153,7 @@ "version": "12.23.6", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fresh": { "version": "0.5.2", @@ -28183,6 +28431,12 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, "node_modules/glob/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -28766,8 +29020,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.12.1.tgz", "integrity": "sha512-xnKGl+iMIlhrZmGHB729MqlmPoWBznctSQTYCpFKqNsCgimJQmithcW0xSQMMFzYnV2iKUh25alswn6epgxS0Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/htm": { "version": "3.1.1", @@ -28894,6 +29147,7 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -28956,6 +29210,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" }, @@ -28973,6 +29228,7 @@ "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" } @@ -29198,6 +29454,7 @@ "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" @@ -29237,6 +29494,7 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", "license": "MIT", + "peer": true, "dependencies": { "@ioredis/commands": "^1.1.1", "cluster-key-slot": "^1.1.0", @@ -31312,6 +31570,7 @@ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -31677,6 +31936,7 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.2.tgz", "integrity": "sha512-TXcFHbmm/z7MGd1u9ASiCSfTS+ei6Z8B3a5JHzx3oPa/o7QzWVtPRpc4KGER5RR469IC+/nfg4U5YLIuDUua2g==", "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -32208,6 +32468,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, "node_modules/loader-utils": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.3.1.tgz", @@ -34594,6 +34863,7 @@ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", + "peer": true, "dependencies": { "whatwg-url": "^5.0.0" }, @@ -35824,6 +36094,7 @@ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -35889,6 +36160,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -37434,6 +37706,7 @@ "version": "6.0.15", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -37503,6 +37776,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -37879,6 +38153,7 @@ "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.0.tgz", "integrity": "sha512-wV450NQyKC24NmPosJb2131RoczLdfIJdKCReNwtVpm5998U8SgKrAZrIHaN/NfQgqOHaan8Uq++B4sa5REwjA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 16" }, @@ -37934,6 +38209,7 @@ "version": "7.4.2", "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-7.4.2.tgz", "integrity": "sha512-yGturTw7WGP+M1GbJ+UTAO7L4buxeW6oilhL9Sq3DezsRS8/9qec4UiXUbeoiX9bzvRXH11JvgskBtxSp4YSNg==", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/mini-decimal": "^1.0.1", @@ -37963,6 +38239,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -38035,6 +38312,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -38332,6 +38610,7 @@ "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz", "integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" @@ -38403,6 +38682,7 @@ "version": "8.5.3", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.3.tgz", "integrity": "sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", @@ -39614,6 +39894,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -39986,6 +40267,24 @@ "loose-envify": "^1.1.0" } }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/seedrandom": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", @@ -41243,6 +41542,7 @@ "version": "1.14.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -41252,6 +41552,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -41380,6 +41681,69 @@ "node": ">=10" } }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -41470,7 +41834,8 @@ "node_modules/tiktoken": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/tiktoken/-/tiktoken-1.0.15.tgz", - "integrity": "sha512-sCsrq/vMWUSEW29CJLNmPvWxlVp7yh2tlkAjpJltIKqp5CKf98ZNpdeHRmAlPVFlGEbswDc6SmI8vz64W/qErw==" + "integrity": "sha512-sCsrq/vMWUSEW29CJLNmPvWxlVp7yh2tlkAjpJltIKqp5CKf98ZNpdeHRmAlPVFlGEbswDc6SmI8vz64W/qErw==", + "peer": true }, "node_modules/timers-browserify": { "version": "2.0.12", @@ -41541,6 +41906,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -41761,6 +42127,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -42102,6 +42469,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -42225,6 +42593,7 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=20.18.1" } @@ -42883,6 +43252,82 @@ "node": ">=4" } }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vite-plugin-compression2": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/vite-plugin-compression2/-/vite-plugin-compression2-2.2.1.tgz", @@ -42894,6 +43339,55 @@ "tar-mini": "^0.2.0" } }, + "node_modules/vite-plugin-node-polyfills": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.23.0.tgz", + "integrity": "sha512-4n+Ys+2bKHQohPBKigFlndwWQ5fFKwaGY6muNDMTb0fSQLyBzS+jjUNRZG9sKF0S/Go4ApG6LFnUGopjkILg3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-inject": "^5.0.5", + "node-stdlib-browser": "^1.2.0" + }, + "funding": { + "url": "https://github.com/sponsors/davidmyersdev" + }, + "peerDependencies": { + "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vm-browserify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz", @@ -42985,6 +43479,19 @@ "makeerror": "1.0.12" } }, + "node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -43011,6 +43518,62 @@ "node": ">=12" } }, + "node_modules/webpack": { + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "dev": true, + "peer": true, + "dependencies": { + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.7.1", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -43189,6 +43752,7 @@ "version": "3.11.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.11.0.tgz", "integrity": "sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", @@ -43542,6 +44106,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, + "peer": true, "license": "MIT", "bin": { "rollup": "dist/bin/rollup" @@ -44125,6 +44690,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -44134,6 +44700,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25 || ^4" } @@ -46317,7 +46884,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -46349,8 +46915,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "packages/client/node_modules/regenerate-unicode-properties": { "version": "10.2.2", @@ -46652,7 +47217,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "peer": true, "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", @@ -46669,7 +47233,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "peer": true, "engines": { "node": ">= 6" } @@ -46698,7 +47261,6 @@ "version": "3.17.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", @@ -46720,7 +47282,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", - "peer": true, "dependencies": { "file-stream-rotator": "^0.6.1", "object-hash": "^3.0.0", @@ -46738,7 +47299,6 @@ "version": "4.9.0", "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "peer": true, "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", From f08074a66c95da1e707a3b7d5e3a43e7c04933ce Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Mon, 2 Feb 2026 10:01:51 +0000 Subject: [PATCH 10/94] server stores conversations in pod --- SOLID_INTEGRATION_PROGRESS.md | 130 ++ api/app/clients/BaseClient.js | 4 + api/models/Conversation.js | 95 +- api/models/Message.js | 171 +- api/package.json | 1 + api/server/controllers/AuthController.js | 88 +- api/server/controllers/agents/request.js | 65 +- api/server/index.js | 10 +- api/server/middleware/requireJwtAuth.js | 43 +- api/server/middleware/validateMessageReq.js | 21 +- api/server/routes/agents/index.js | 7 + api/server/routes/convos.js | 53 +- api/server/routes/messages.js | 140 +- api/server/routes/oauth.js | 45 +- api/server/services/AuthService.js | 36 +- api/server/services/SolidStorage.js | 1930 +++++++++++++++++++ api/strategies/jwtStrategy.js | 28 +- package-lock.json | 310 +++ 18 files changed, 3064 insertions(+), 113 deletions(-) create mode 100644 SOLID_INTEGRATION_PROGRESS.md create mode 100644 api/server/services/SolidStorage.js diff --git a/SOLID_INTEGRATION_PROGRESS.md b/SOLID_INTEGRATION_PROGRESS.md new file mode 100644 index 000000000000..7547ca5ee7c2 --- /dev/null +++ b/SOLID_INTEGRATION_PROGRESS.md @@ -0,0 +1,130 @@ +# Solid Pod Integration - Progress Report + +## Overview +Successfully implemented Solid Pod storage integration for LibreChat, enabling users to store conversations and messages in their personal Solid Pods instead of (or alongside) MongoDB. + +## Completed Milestones + +### 1. Authentication & Token Management +- **Status**: Complete +- **Details**: + - Integrated Solid-OIDC authentication flow + - Captured and logged authorization codes from Solid provider + - Implemented token storage in session and cookies + - Handled cases where Solid provider doesn't issue refresh tokens + - Ensured JWT tokens are created for frontend authentication + +### 2. Solid Pod Access +- **Status**: Complete +- **Details**: + - Implemented authenticated fetch using OpenID access tokens + - Created Pod URL discovery with fallback mechanism + - Derived Pod URLs from WebID when not found in profile + - Verified Pod accessibility before operations + +### 3. Container Management +- **Status**: Complete +- **Details**: + - Implemented automatic container creation for base storage structure + - Created `/librechat/`, `/librechat/conversations/`, and `/librechat/messages/` containers + - Used HTTP HEAD requests to check container existence + - Properly parsed Turtle (RDF) format responses from Solid Pods + +### 4. Message Storage +- **Status**: Complete +- **Details**: + - Implemented `saveMessageToSolid` - saves individual messages as JSON files + - Messages stored in: `/librechat/messages/{conversationId}/{messageId}.json` + - Successfully tested: User messages are being saved to Pod + - Proper error handling and logging + +### 5. Conversation Storage +- **Status**: Complete +- **Details**: + - Implemented `saveConvoToSolid` - saves conversations as JSON files + - Conversations stored in: `/librechat/conversations/{conversationId}.json` + - Successfully tested: Conversations are being created in Pod + - Includes message references and metadata + +### 6. Data Retrieval +- **Status**: Complete +- **Details**: + - Implemented `getMessagesFromSolid` - retrieves all messages for a conversation + - Implemented `getConvosByCursorFromSolid` - retrieves conversations with pagination + - Properly parses Turtle format to extract `ldp:contains` items + - Handles empty containers gracefully (returns empty arrays, not errors) + +### 7. Feature Flag Implementation +- **Status**: Complete +- **Details**: + - Added `USE_SOLID_STORAGE` environment variable + - Integrated Solid storage functions into `Message.js` and `Conversation.js` models + - Maintains backward compatibility with MongoDB + - Original MongoDB code preserved (commented) for rollback capability + +## Current Status + +### Working Features +1. **User Login**: Solid-OIDC authentication working correctly +2. **Conversation Creation**: New conversations are saved to Solid Pod +3. **Message Saving**: User messages are saved to Solid Pod +4. **Data Retrieval**: Conversations and messages can be read from Pod +5. **Container Structure**: Proper directory structure created automatically + +### Known Issues 🔧 +1. **Message Updates**: LLM response messages fail to update in Pod + - **Error**: `conversationId is required for updating messages` + - **Root Cause**: `conversationId` not always present in message object during updates + - **Status**: In Progress + +2. **Title Generation**: Endpoint `/api/convos/gen_title/{conversationId}` returns 404 + - **Status**: To be investigated + +## Technical Implementation + +### Storage Format +- **Format**: JSON files +- **Structure**: + ``` + /librechat/ + /conversations/ + {conversationId}.json + /messages/ + {conversationId}/ + {messageId}.json + ``` + +### Parsing Logic +- Uses HTTP GET requests to retrieve container contents +- Parses Turtle (RDF) format responses +- Extracts `ldp:contains` predicates to list files +- Handles relative and absolute URLs + +### Authentication +- Uses OpenID access tokens stored in session +- Falls back to cookies if session unavailable +- Tokens retrieved from multiple sources for robustness + +## Next Steps +1. Fix message update functionality to handle missing `conversationId` +2. Investigate and fix title generation endpoint +3. Test full conversation flow (create → send message → receive LLM response → update) +4. Performance testing with multiple conversations +5. Error recovery and retry mechanisms + +## Files Modified +- `api/server/services/SolidStorage.js` (NEW) - Core Solid Pod operations +- `api/models/Message.js` - Integrated Solid storage +- `api/models/Conversation.js` - Integrated Solid storage +- `api/server/routes/oauth.js` - Token logging and storage +- `api/server/services/AuthService.js` - Token management +- `api/server/index.js` - Session middleware ordering +- `api/server/controllers/AuthController.js` - Refresh token handling + +## Dependencies Added +- `@inrupt/solid-client@^1.30.2` - Solid Pod client library + +--- + +**Report Date**: January 30, 2026 +**Status**: 85% Complete - Core functionality working, minor fixes needed diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 8f931f8a5e5a..efaeffeef7b4 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -1031,6 +1031,10 @@ class BaseClient { * @param {Partial} message */ async updateMessageInDatabase(message) { + // Ensure conversationId is included if available + if (!message.conversationId && this.conversationId) { + message.conversationId = this.conversationId; + } await updateMessage(this.options.req, message); } diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 32eac1a76419..84d5b5d8c72d 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -1,8 +1,11 @@ const { logger } = require('@librechat/data-schemas'); -const { createTempChatExpirationDate } = require('@librechat/api'); +const { createTempChatExpirationDate, isEnabled } = require('@librechat/api'); const { getMessages, deleteMessages } = require('./Message'); const { Conversation } = require('~/db/models'); +// Feature flag to toggle between MongoDB and Solid storage +const USE_SOLID_STORAGE = isEnabled(process.env.USE_SOLID_STORAGE); + /** * Searches for a conversation by conversationId and returns a lean document with only conversationId and user. * @param {string} conversationId - The conversation's ID. @@ -21,9 +24,34 @@ const searchConversation = async (conversationId) => { * Retrieves a single conversation for a given user and conversation ID. * @param {string} user - The user's ID. * @param {string} conversationId - The conversation's ID. + * @param {Object} [req] - Optional request object for Solid storage support. * @returns {Promise} The conversation object. */ -const getConvo = async (user, conversationId) => { +const getConvo = async (user, conversationId, req = null) => { + // Use Solid storage if enabled and req is provided with openidId + if (USE_SOLID_STORAGE && req && req.user?.openidId) { + try { + const { getConvoFromSolid } = require('~/server/services/SolidStorage'); + const convo = await getConvoFromSolid(req, conversationId); + if (convo) { + return convo; + } + // If Solid storage returns null, fall through to MongoDB + logger.warn('[getConvo] Conversation not found in Solid Pod, falling back to MongoDB', { + conversationId, + userId: user, + }); + } catch (error) { + logger.error('[getConvo] Error getting conversation from Solid Pod, falling back to MongoDB', { + error: error.message, + conversationId, + userId: user, + }); + // Fall through to MongoDB storage + } + } + + // MongoDB storage (original code) try { return await Conversation.findOne({ user, conversationId }).lean(); } catch (error) { @@ -87,6 +115,46 @@ module.exports = { * @returns {Promise} The conversation object. */ saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => { + // Use Solid storage if enabled + if (USE_SOLID_STORAGE) { + try { + if (metadata?.context) { + logger.debug(`[saveConvo] ${metadata.context}`); + } + + const { saveConvoToSolid } = require('~/server/services/SolidStorage'); + + const convoData = { + conversationId, + newConversationId, + ...convo, + }; + + if (req?.body?.isTemporary) { + try { + const appConfig = req.config; + convoData.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig); + } catch (err) { + logger.error('Error creating temporary chat expiration date:', err); + logger.info(`---\`saveConvo\` context: ${metadata?.context}`); + convoData.expiredAt = null; + } + } else { + convoData.expiredAt = null; + } + + const savedConvo = await saveConvoToSolid(req, convoData, metadata); + return savedConvo; + } catch (error) { + logger.error('[saveConvo] Error saving conversation to Solid Pod', error); + if (metadata && metadata?.context) { + logger.info(`[saveConvo] ${metadata.context}`); + } + return { message: 'Error saving conversation' }; + } + } + + // MongoDB storage (original code) try { if (metadata?.context) { logger.debug(`[saveConvo] ${metadata.context}`); @@ -170,8 +238,31 @@ module.exports = { search, sortBy = 'updatedAt', sortDirection = 'desc', + req, // Optional req object for Solid storage } = {}, ) => { + // Use Solid storage if enabled and req is provided + if (USE_SOLID_STORAGE && req && req.user?.openidId) { + try { + const { getConvosByCursorFromSolid } = require('~/server/services/SolidStorage'); + return await getConvosByCursorFromSolid(req, { + cursor, + limit, + isArchived, + tags, + search, + sortBy, + sortDirection, + }); + } catch (error) { + logger.warn('[getConvosByCursor] Error getting conversations from Solid Pod, falling back to MongoDB', { + error: error.message, + user: req.user?.id, + hasOpenidId: !!req.user?.openidId, + }); + // Fall through to MongoDB storage + } + } const filters = [{ user }]; if (isArchived) { filters.push({ isArchived: true }); diff --git a/api/models/Message.js b/api/models/Message.js index 8fe04f6f5409..cdf363ae64eb 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -1,10 +1,13 @@ const { z } = require('zod'); const { logger } = require('@librechat/data-schemas'); -const { createTempChatExpirationDate } = require('@librechat/api'); +const { createTempChatExpirationDate, isEnabled } = require('@librechat/api'); const { Message } = require('~/db/models'); const idSchema = z.string().uuid(); +// Feature flag to toggle between MongoDB and Solid storage +const USE_SOLID_STORAGE = isEnabled(process.env.USE_SOLID_STORAGE); + /** * Saves a message in the database. * @@ -47,6 +50,127 @@ async function saveMessage(req, params, metadata) { return; } + // Use Solid storage if enabled + if (USE_SOLID_STORAGE) { + try { + const { saveMessageToSolid } = require('~/server/services/SolidStorage'); + + const messageData = { + ...params, + messageId: params.newMessageId || params.messageId, + }; + + if (req?.body?.isTemporary) { + try { + const appConfig = req.config; + messageData.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig); + } catch (err) { + logger.error('Error creating temporary chat expiration date:', err); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); + messageData.expiredAt = null; + } + } else { + messageData.expiredAt = null; + } + + if (messageData.tokenCount != null && isNaN(messageData.tokenCount)) { + logger.warn( + `Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${messageData.tokenCount}`, + ); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); + messageData.tokenCount = 0; + } + + const savedMessage = await saveMessageToSolid(req, messageData, metadata); + return savedMessage; + } catch (err) { + logger.error('Error saving message to Solid Pod:', err); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); + throw err; + } + } + + // MongoDB storage (original code - commented for reference) + /* + try { + const update = { + ...params, + user: req.user.id, + messageId: params.newMessageId || params.messageId, + }; + + if (req?.body?.isTemporary) { + try { + const appConfig = req.config; + update.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig); + } catch (err) { + logger.error('Error creating temporary chat expiration date:', err); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); + update.expiredAt = null; + } + } else { + update.expiredAt = null; + } + + if (update.tokenCount != null && isNaN(update.tokenCount)) { + logger.warn( + `Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${update.tokenCount}`, + ); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); + update.tokenCount = 0; + } + const message = await Message.findOneAndUpdate( + { messageId: params.messageId, user: req.user.id }, + update, + { upsert: true, new: true }, + ); + + return message.toObject(); + } catch (err) { + logger.error('Error saving message:', err); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); + + // Check if this is a duplicate key error (MongoDB error code 11000) + if (err.code === 11000 && err.message.includes('duplicate key error')) { + // Log the duplicate key error but don't crash the application + logger.warn(`Duplicate messageId detected: ${params.messageId}. Continuing execution.`); + + try { + // Try to find the existing message with this ID + const existingMessage = await Message.findOne({ + messageId: params.messageId, + user: req.user.id, + }); + + // If we found it, return it + if (existingMessage) { + return existingMessage.toObject(); + } + + // If we can't find it (unlikely but possible in race conditions) + return { + ...params, + messageId: params.messageId, + user: req.user.id, + }; + } catch (findError) { + // If the findOne also fails, log it but don't crash + logger.warn( + `Could not retrieve existing message with ID ${params.messageId}: ${findError.message}`, + ); + return { + ...params, + messageId: params.messageId, + user: req.user.id, + }; + } + } + + throw err; // Re-throw other errors + } + */ + + // Fallback to MongoDB if Solid is not enabled try { const update = { ...params, @@ -237,6 +361,32 @@ async function updateMessageText(req, { messageId, text }) { * @throws {Error} If there is an error in updating the message or if the message is not found. */ async function updateMessage(req, message, metadata) { + // Use Solid storage if enabled + if (USE_SOLID_STORAGE) { + try { + const { updateMessageInSolid } = require('~/server/services/SolidStorage'); + const updatedMessage = await updateMessageInSolid(req, message, metadata); + + return { + messageId: updatedMessage.messageId, + conversationId: updatedMessage.conversationId, + parentMessageId: updatedMessage.parentMessageId, + sender: updatedMessage.sender, + text: updatedMessage.text, + isCreatedByUser: updatedMessage.isCreatedByUser, + tokenCount: updatedMessage.tokenCount, + feedback: updatedMessage.feedback, + }; + } catch (err) { + logger.error('Error updating message in Solid Pod:', err); + if (metadata && metadata?.context) { + logger.info(`---\`updateMessage\` context: ${metadata.context}`); + } + throw err; + } + } + + // MongoDB storage (original code) try { const { messageId, ...update } = message; const updatedMessage = await Message.findOneAndUpdate( @@ -283,6 +433,22 @@ async function updateMessage(req, message, metadata) { * @throws {Error} If there is an error in deleting messages. */ async function deleteMessagesSince(req, { messageId, conversationId }) { + // Use Solid storage if enabled + if (USE_SOLID_STORAGE) { + try { + const { deleteMessagesFromSolid } = require('~/server/services/SolidStorage'); + const deletedCount = await deleteMessagesFromSolid(req, { + messageId, + conversationId, + }); + return { deletedCount }; + } catch (err) { + logger.error('Error deleting messages from Solid Pod:', err); + throw err; + } + } + + // MongoDB storage (original code) try { const message = await Message.findOne({ messageId, user: req.user.id }).lean(); @@ -309,6 +475,9 @@ async function deleteMessagesSince(req, { messageId, conversationId }) { * @throws {Error} If there is an error in retrieving messages. */ async function getMessages(filter, select) { + // Note: getMessages doesn't have access to req, so it can't use Solid storage + // For Solid storage, use getMessagesFromSolid directly from routes/controllers that have req + // MongoDB storage (original code) try { if (select) { return await Message.find(filter).select(select).sort({ createdAt: 1 }).lean(); diff --git a/api/package.json b/api/package.json index 90b0509cc0a6..3d3055447364 100644 --- a/api/package.json +++ b/api/package.json @@ -42,6 +42,7 @@ "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.30.0", "@google/genai": "^1.19.0", + "@inrupt/solid-client": "^1.30.2", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", "@librechat/agents": "^3.1.55", diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 13d024cd036f..8810aa9df4e9 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -68,17 +68,20 @@ const refreshController = async (req, res) => { const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {}; const token_provider = parsedCookies.token_provider; + // Handle OpenID users with OPENID_REUSE_TOKENS enabled if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { - /** For OpenID users, read refresh token from session to avoid large cookie issues */ + /** For OpenID users with token reuse, read refresh token from session */ const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken; if (!refreshToken) { - return res.status(200).send('Refresh token not provided'); - } - - try { - const openIdConfig = getOpenIdConfig(); - const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {}; + // Solid provider may not provide refresh tokens - fall back to standard JWT refresh + logger.warn('[refreshController] No OpenID refresh token available, falling back to standard refresh'); + // Fall through to standard refresh logic below + } else { + // We have a refresh token, use OpenID refresh flow + try { + const openIdConfig = getOpenIdConfig(); + const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {}; const tokenset = await openIdClient.refreshTokenGrant( openIdConfig, refreshToken, @@ -93,46 +96,49 @@ const refreshController = async (req, res) => { strategyName: 'refreshController', }); - logger.debug( - `[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`, - ); - - if (error || !user) { - logger.warn( - `[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`, + logger.debug( + `[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`, ); - return res.status(401).redirect('/login'); - } - // Handle migration: update user with openidId if found by email without openidId - // Also handle case where user has mismatched openidId (e.g., after database switch) - if (migration || user.openidId !== claims.sub) { - const reason = migration ? 'migration' : 'openidId mismatch'; - await updateUser(user._id.toString(), { - provider: 'openid', - openidId: claims.sub, - }); - logger.info( - `[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`, - ); + if (error || !user) { + logger.warn( + `[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`, + ); + return res.status(401).redirect('/login'); + } + + // Handle migration: update user with openidId if found by email without openidId + // Also handle case where user has mismatched openidId (e.g., after database switch) + if (migration || user.openidId !== claims.sub) { + const reason = migration ? 'migration' : 'openidId mismatch'; + await updateUser(user._id.toString(), { + provider: 'openid', + openidId: claims.sub, + }); + logger.info( + `[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`, + ); + } + + const token = setOpenIDAuthTokens(tokenset, req, res, user._id.toString(), refreshToken); + + user.federatedTokens = { + access_token: tokenset.access_token, + id_token: tokenset.id_token, + refresh_token: refreshToken, + expires_at: claims.exp, + }; + + return res.status(200).send({ token, user }); + } catch (error) { + logger.error('[refreshController] OpenID token refresh error', error); + // Fall through to standard refresh logic as fallback } - - const token = setOpenIDAuthTokens(tokenset, req, res, user._id.toString(), refreshToken); - - user.federatedTokens = { - access_token: tokenset.access_token, - id_token: tokenset.id_token, - refresh_token: refreshToken, - expires_at: claims.exp, - }; - - return res.status(200).send({ token, user }); - } catch (error) { - logger.error('[refreshController] OpenID token refresh error', error); - return res.status(403).send('Invalid OpenID refresh token'); } } + /** For non-OpenID users or OpenID users without OPENID_REUSE_TOKENS, use standard JWT refresh */ + /** For non-OpenID users, read refresh token from cookies */ const refreshToken = parsedCookies.refreshToken; if (!refreshToken) { diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index dea5400036da..b39426b4736d 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -305,6 +305,24 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } if (!wasAbortedBeforeComplete) { + // Always save/update the final response message with complete text FIRST + // This ensures the message is persisted before we emit FINAL and complete the job + logger.debug('[ResumableAgentController] Saving final response message', { + messageId: response.messageId, + hasText: !!response.text, + textLength: response.text?.length || 0, + hasContent: !!response.content, + contentLength: Array.isArray(response.content) ? response.content.length : 0, + contentTypes: Array.isArray(response.content) + ? response.content.map(c => c?.type).filter(Boolean) + : [], + }); + await saveMessage( + req, + { ...response, user: userId }, + { context: 'api/server/controllers/agents/request.js - resumable response end' }, + ); + const finalEvent = { final: true, conversation, @@ -313,35 +331,25 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit responseMessage: { ...response }, }; - logger.debug(`[ResumableAgentController] Emitting FINAL event`, { - streamId, - wasAbortedBeforeComplete, - userMessageId: userMessage?.messageId, - responseMessageId: response?.messageId, - conversationId: conversation?.conversationId, - }); - - await GenerationJobManager.emitDone(streamId, finalEvent); - GenerationJobManager.completeJob(streamId); + // Emit the final event to all subscribers + GenerationJobManager.emitDone(streamId, finalEvent); await decrementPendingRequest(userId); + + // Complete the job AFTER emitting FINAL and saving messages + // This ensures the frontend receives the FINAL event while the job is still active + GenerationJobManager.completeJob(streamId); } else { const finalEvent = { final: true, conversation, title: conversation.title, requestMessage: sanitizeMessageForTransmit(userMessage), - responseMessage: { ...response, unfinished: true }, + responseMessage: { ...response, error: true }, + error: { message: 'Request was aborted' }, }; - - logger.debug(`[ResumableAgentController] Emitting ABORTED FINAL event`, { - streamId, - wasAbortedBeforeComplete, - userMessageId: userMessage?.messageId, - responseMessageId: response?.messageId, - conversationId: conversation?.conversationId, - }); - - await GenerationJobManager.emitDone(streamId, finalEvent); + // Emit the final event to all subscribers first + GenerationJobManager.emitDone(streamId, finalEvent); + // Then complete the job to mark it as inactive GenerationJobManager.completeJob(streamId, 'Request aborted'); await decrementPendingRequest(userId); } @@ -658,14 +666,13 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle }); res.end(); - // Save the message if needed - if (client.savedMessageIds && !client.savedMessageIds.has(messageId)) { - await saveMessage( - req, - { ...finalResponse, user: userId }, - { context: 'api/server/controllers/agents/request.js - response end' }, - ); - } + // Always save/update the final response message with complete text + // Even if it was saved initially, we need to update it with the final text content + await saveMessage( + req, + { ...finalResponse, user: userId }, + { context: 'api/server/controllers/agents/request.js - response end' }, + ); } // Edge case: sendMessage completed but abort happened during sendCompletion // We need to ensure a final event is sent diff --git a/api/server/index.js b/api/server/index.js index 5ca3ad3db0dd..ab42170ef35a 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -105,6 +105,12 @@ const startServer = async () => { app.use(cors()); app.use(cookieParser()); + // Configure social logins (including session middleware) BEFORE passport initialization + // This ensures sessions are available for all requests, including JWT-authenticated ones + if (isEnabled(ALLOW_SOCIAL_LOGIN)) { + await configureSocialLogins(app); + } + if (!isEnabled(DISABLE_COMPRESSION)) { app.use(compression()); } else { @@ -129,10 +135,6 @@ const startServer = async () => { passport.use(ldapLogin); } - if (isEnabled(ALLOW_SOCIAL_LOGIN)) { - await configureSocialLogins(app); - } - app.use('/oauth', routes.oauth); /* API Endpoints */ app.use('/api/auth', routes.auth); diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index 16b107aefc5e..12cc0fd51f90 100644 --- a/api/server/middleware/requireJwtAuth.js +++ b/api/server/middleware/requireJwtAuth.js @@ -6,15 +6,56 @@ const { isEnabled } = require('@librechat/api'); * Custom Middleware to handle JWT authentication, with support for OpenID token reuse * Switches between JWT and OpenID authentication based on cookies and environment settings */ +const { logger } = require('@librechat/data-schemas'); + const requireJwtAuth = (req, res, next) => { const cookieHeader = req.headers.cookie; const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null; + const hasAuthHeader = !!req.headers.authorization; + + logger.info('[requireJwtAuth] Authentication check', { + path: req.path, + method: req.method, + hasCookie: !!cookieHeader, + tokenProvider, + hasAuthHeader, + authHeaderPrefix: req.headers.authorization?.substring(0, 30), + openidReuseTokens: isEnabled(process.env.OPENID_REUSE_TOKENS), + }); + // Use OpenID authentication if token provider is OpenID and OPENID_REUSE_TOKENS is enabled if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { + logger.debug('[requireJwtAuth] Using OpenID JWT authentication'); return passport.authenticate('openidJwt', { session: false })(req, res, next); } - return passport.authenticate('jwt', { session: false })(req, res, next); + // Default to standard JWT authentication + logger.debug('[requireJwtAuth] Using standard JWT authentication'); + + // Add error handler to log authentication failures + return passport.authenticate('jwt', { session: false }, (err, user, info) => { + if (err) { + logger.error('[requireJwtAuth] Authentication error', { + error: err.message, + stack: err.stack, + }); + return res.status(401).json({ error: 'Authentication failed', message: err.message }); + } + if (!user) { + logger.warn('[requireJwtAuth] Authentication failed - no user', { + info: info?.message || 'No user returned from JWT strategy', + hasAuthHeader, + tokenProvider, + }); + return res.status(401).json({ + error: 'Authentication required', + message: 'No valid JWT token found. Make sure you are logged in and the Authorization header is sent.', + hint: 'Access this endpoint via the frontend app, or include Authorization: Bearer header', + }); + } + req.user = user; + next(); + })(req, res, next); }; module.exports = requireJwtAuth; diff --git a/api/server/middleware/validateMessageReq.js b/api/server/middleware/validateMessageReq.js index 430444a17278..7e4d21c53589 100644 --- a/api/server/middleware/validateMessageReq.js +++ b/api/server/middleware/validateMessageReq.js @@ -1,4 +1,8 @@ const { getConvo } = require('~/models'); +const { isEnabled } = require('@librechat/api'); +const { getConvoFromSolid } = require('~/server/services/SolidStorage'); + +const USE_SOLID_STORAGE = isEnabled(process.env.USE_SOLID_STORAGE); // Middleware to validate conversationId and user relationship const validateMessageReq = async (req, res, next) => { @@ -12,7 +16,22 @@ const validateMessageReq = async (req, res, next) => { conversationId = req.body.message.conversationId; } - const conversation = await getConvo(req.user.id, conversationId); + let conversation = null; + + // Use Solid storage if enabled + if (USE_SOLID_STORAGE && req.user?.openidId) { + try { + conversation = await getConvoFromSolid(req, conversationId); + } catch (error) { + // If Solid storage fails, fall back to MongoDB + // Don't log error here as it might be expected (conversation doesn't exist in Solid yet) + } + } + + // Fallback to MongoDB if Solid storage is disabled or didn't find the conversation + if (!conversation) { + conversation = await getConvo(req.user.id, conversationId); + } if (!conversation) { return res.status(404).json({ error: 'Conversation not found' }); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index f8d39cb4d82b..05afdb9b1f90 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -169,6 +169,13 @@ router.get('/chat/status/:conversationId', async (req, res) => { const resumeState = await GenerationJobManager.getResumeState(conversationId); const isActive = job.status === 'running'; + logger.debug('[GET /api/agents/chat/status/:conversationId] Job status check', { + conversationId, + jobStatus: job.status, + isActive, + userId: req.user.id, + }); + res.json({ active: isActive, streamId: conversationId, diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index bb9c4ebea95a..3081a7904a17 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -44,6 +44,7 @@ router.get('/', async (req, res) => { const result = await getConvosByCursor(req.user.id, { cursor, limit, + req, // Pass req for Solid storage support isArchived, tags, search, @@ -52,19 +53,55 @@ router.get('/', async (req, res) => { }); res.status(200).json(result); } catch (error) { - logger.error('Error fetching conversations', error); - res.status(500).json({ error: 'Error fetching conversations' }); + logger.error('Error fetching conversations', { + error: error.message, + stack: error.stack, + userId: req.user?.id, + hasOpenidId: !!req.user?.openidId, + useSolidStorage: process.env.USE_SOLID_STORAGE, + }); + res.status(500).json({ + error: 'Error fetching conversations', + message: error.message, // Include actual error message for debugging + }); } }); router.get('/:conversationId', async (req, res) => { const { conversationId } = req.params; - const convo = await getConvo(req.user.id, conversationId); - - if (convo) { - res.status(200).json(convo); - } else { - res.status(404).end(); + + logger.info('[GET /api/convos/:conversationId] Fetching conversation', { + conversationId, + userId: req.user?.id, + hasOpenidId: !!req.user?.openidId, + useSolidStorage: isEnabled(process.env.USE_SOLID_STORAGE), + }); + + try { + const convo = await getConvo(req.user.id, conversationId, req); + + if (convo) { + logger.info('[GET /api/convos/:conversationId] Conversation found', { + conversationId, + title: convo.title, + messageCount: convo.messages?.length || 0, + }); + res.status(200).json(convo); + } else { + logger.warn('[GET /api/convos/:conversationId] Conversation not found', { + conversationId, + userId: req.user?.id, + }); + res.status(404).end(); + } + } catch (error) { + logger.error('[GET /api/convos/:conversationId] Error fetching conversation', { + conversationId, + error: error.message, + stack: error.stack, + userId: req.user?.id, + }); + res.status(500).json({ error: 'Error fetching conversation', message: error.message }); } }); diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index c208e9c40673..4bb4280854cc 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -2,7 +2,7 @@ const express = require('express'); const { v4: uuidv4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); const { ContentTypes } = require('librechat-data-provider'); -const { unescapeLaTeX, countTokens } = require('@librechat/api'); +const { unescapeLaTeX, countTokens, isEnabled } = require('@librechat/api'); const { saveConvo, getMessage, @@ -15,6 +15,9 @@ const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/ const { requireJwtAuth, validateMessageReq } = require('~/server/middleware'); const { getConvosQueried } = require('~/models/Conversation'); const { Message } = require('~/db/models'); +const { getMessagesFromSolid } = require('~/server/services/SolidStorage'); + +const USE_SOLID_STORAGE = isEnabled(process.env.USE_SOLID_STORAGE); const router = express.Router(); router.use(requireJwtAuth); @@ -40,28 +43,99 @@ router.get('/', async (req, res) => { const sortOrder = sortDirection === 'asc' ? 1 : -1; if (conversationId && messageId) { - const message = await Message.findOne({ - conversationId, - messageId, - user: user, - }).lean(); - response = { messages: message ? [message] : [], nextCursor: null }; - } else if (conversationId) { - const filter = { conversationId, user: user }; - if (cursor) { - filter[sortField] = sortOrder === 1 ? { $gt: cursor } : { $lt: cursor }; + // Use Solid storage if enabled + if (USE_SOLID_STORAGE && req.user?.openidId) { + try { + const allMessages = await getMessagesFromSolid(req, conversationId); + const message = allMessages.find(m => m.messageId === messageId); + response = { messages: message ? [message] : [], nextCursor: null }; + } catch (error) { + logger.error('Error getting message from Solid Pod, falling back to MongoDB', error); + // Fallback to MongoDB + const message = await Message.findOne({ + conversationId, + messageId, + user: user, + }).lean(); + response = { messages: message ? [message] : [], nextCursor: null }; + } + } else { + // MongoDB storage + const message = await Message.findOne({ + conversationId, + messageId, + user: user, + }).lean(); + response = { messages: message ? [message] : [], nextCursor: null }; } - const messages = await Message.find(filter) - .sort({ [sortField]: sortOrder }) - .limit(pageSize + 1) - .lean(); - let nextCursor = null; - if (messages.length > pageSize) { - messages.pop(); // Remove extra item used to detect next page - // Create cursor from the last RETURNED item (not the popped one) - nextCursor = messages[messages.length - 1][sortField]; + } else if (conversationId) { + // Use Solid storage if enabled + if (USE_SOLID_STORAGE && req.user?.openidId) { + try { + const allMessages = await getMessagesFromSolid(req, conversationId); + + // Apply sorting + allMessages.sort((a, b) => { + const aVal = a[sortField] || 0; + const bVal = b[sortField] || 0; + return sortOrder === 1 ? aVal - bVal : bVal - aVal; + }); + + // Apply cursor filtering if provided + let filteredMessages = allMessages; + if (cursor) { + filteredMessages = allMessages.filter(msg => { + const msgVal = msg[sortField] || 0; + return sortOrder === 1 ? msgVal > cursor : msgVal < cursor; + }); + } + + // Apply pagination + const messages = filteredMessages.slice(0, pageSize + 1); + let nextCursor = null; + if (messages.length > pageSize) { + messages.pop(); // Remove extra item used to detect next page + // Create cursor from the last RETURNED item (not the popped one) + nextCursor = messages[messages.length - 1][sortField]; + } + response = { messages, nextCursor }; + } catch (error) { + logger.error('Error getting messages from Solid Pod, falling back to MongoDB', error); + // Fallback to MongoDB + const filter = { conversationId, user: user }; + if (cursor) { + filter[sortField] = sortOrder === 1 ? { $gt: cursor } : { $lt: cursor }; + } + const messages = await Message.find(filter) + .sort({ [sortField]: sortOrder }) + .limit(pageSize + 1) + .lean(); + let nextCursor = null; + if (messages.length > pageSize) { + messages.pop(); // Remove extra item used to detect next page + // Create cursor from the last RETURNED item (not the popped one) + nextCursor = messages[messages.length - 1][sortField]; + } + response = { messages, nextCursor }; + } + } else { + // MongoDB storage + const filter = { conversationId, user: user }; + if (cursor) { + filter[sortField] = sortOrder === 1 ? { $gt: cursor } : { $lt: cursor }; + } + const messages = await Message.find(filter) + .sort({ [sortField]: sortOrder }) + .limit(pageSize + 1) + .lean(); + let nextCursor = null; + if (messages.length > pageSize) { + messages.pop(); // Remove extra item used to detect next page + // Create cursor from the last RETURNED item (not the popped one) + nextCursor = messages[messages.length - 1][sortField]; + } + response = { messages, nextCursor }; } - response = { messages, nextCursor }; } else if (search) { const searchResults = await Message.meiliSearch(search, { filter: `user = "${user}"` }, true); @@ -283,8 +357,28 @@ router.post('/artifact/:messageId', async (req, res) => { router.get('/:conversationId', validateMessageReq, async (req, res) => { try { const { conversationId } = req.params; - const messages = await getMessages({ conversationId }, '-_id -__v -user'); - res.status(200).json(messages); + + // Use Solid storage if enabled + if (USE_SOLID_STORAGE && req.user?.openidId) { + try { + const messages = await getMessagesFromSolid(req, conversationId); + // Remove internal fields for response + const cleanedMessages = messages.map(msg => { + const { _id, __v, user, ...rest } = msg; + return rest; + }); + res.status(200).json(cleanedMessages); + } catch (error) { + logger.error('Error getting messages from Solid Pod, falling back to MongoDB', error); + // Fallback to MongoDB + const messages = await getMessages({ conversationId }, '-_id -__v -user'); + res.status(200).json(messages); + } + } else { + // MongoDB storage + const messages = await getMessages({ conversationId }, '-_id -__v -user'); + res.status(200).json(messages); + } } catch (error) { logger.error('Error fetching messages:', error); res.status(500).json({ error: 'Internal server error' }); diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index ecc4749255f4..8a365e6bce1a 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -25,7 +25,50 @@ const domains = { router.use(logHeaders); router.use(loginLimiter); -const oauthHandler = createOAuthHandler(); +const oauthHandler = async (req, res, next) => { + try { + if (res.headersSent) { + return; + } + + await checkBan(req, res); + if (req.banned) { + return; + } + if ( + req.user && + req.user.provider == 'openid' && + // isEnabled(process.env.OPENID_REUSE_TOKENS) === true + req.user.tokenset && + req.user.tokenset.access_token + ) { + // Always store OpenID tokens in session (needed for Solid Pod access) + setOpenIDAuthTokens(req.user.tokenset, req, res, req.user._id.toString()); + logger.info('[oauthHandler] OpenID tokens stored for Solid Pod access', { + userId: req.user._id.toString(), + hasAccessToken: !!req.user.tokenset.access_token, + hasRefreshToken: !!req.user.tokenset.refresh_token, + }); + + // Also create JWT tokens for frontend authentication + // OPENID_REUSE_TOKENS determines if we use OpenID JWT or standard JWT + if (isEnabled(process.env.OPENID_REUSE_TOKENS) === true) { + await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token); + // When OPENID_REUSE_TOKENS is enabled, setOpenIDAuthTokens handles JWT creation + // via the openidJwt strategy, so we don't need to call setAuthTokens + } else { + // When OPENID_REUSE_TOKENS is disabled, create standard JWT tokens + await setAuthTokens(req.user._id, res); + } + } else { + await setAuthTokens(req.user._id, res); + } + res.redirect(domains.client); + } catch (err) { + logger.error('Error in setting authentication tokens:', err); + next(err); + } +}; router.get('/error', (req, res) => { /** A single error message is pushed by passport when authentication fails. */ diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index ef50a365b9ae..43468190d408 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -448,6 +448,8 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = const refreshToken = tokenset.refresh_token || existingRefreshToken; + // Log warning if no refresh token, but continue with access token only + // Some providers (like Solid) may not provide refresh tokens if (!refreshToken) { logger.error('[setOpenIDAuthTokens] No refresh token available'); return; @@ -483,17 +485,49 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = req.session.openidTokens = { accessToken: tokenset.access_token, idToken: tokenset.id_token, - refreshToken: refreshToken, + refreshToken: refreshToken || null, // Store null if no refresh token expiresAt: expirationDate.getTime(), }; + + // Explicitly save the session to ensure it persists + req.session.save((err) => { + if (err) { + logger.error('[setOpenIDAuthTokens] Error saving session', { + error: err.message, + stack: err.stack, + }); + } else { + logger.debug('[setOpenIDAuthTokens] Session saved successfully'); + } + }); + + logger.info('[setOpenIDAuthTokens] Tokens stored in session', { + hasAccessToken: !!tokenset.access_token, + hasRefreshToken: !!refreshToken, + sessionId: req.sessionID, + accessTokenLength: tokenset.access_token?.length, + expiresAt: expirationDate.toISOString(), + }); } else { logger.warn('[setOpenIDAuthTokens] No session available, falling back to cookies'); + if (refreshToken) { + res.cookie('refreshToken', refreshToken, { + expires: expirationDate, + httpOnly: true, + secure: isProduction, + sameSite: 'strict', + }); + } res.cookie('openid_access_token', tokenset.access_token, { expires: expirationDate, httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); + logger.info('[setOpenIDAuthTokens] Tokens stored in cookies', { + hasAccessToken: !!tokenset.access_token, + hasRefreshToken: !!refreshToken, + }); if (tokenset.id_token) { res.cookie('openid_id_token', tokenset.id_token, { expires: expirationDate, diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js new file mode 100644 index 000000000000..b665ac14a133 --- /dev/null +++ b/api/server/services/SolidStorage.js @@ -0,0 +1,1930 @@ +const { logger } = require('@librechat/data-schemas'); +const fetch = require('node-fetch'); +const { + getFile, + saveFileInContainer, + overwriteFile, + deleteFile, + getPodUrlAll, + createContainerAt, +} = require('@inrupt/solid-client'); + +/** + * Solid Storage Utility Module + * + * This module provides functions to interact with Solid Pods for storing + * and retrieving messages and conversations. + * + * Storage Structure: + * {podUrl}/librechat/ + * ├── conversations/ + * │ └── {conversationId}.json + * └── messages/ + * └── {conversationId}/ + * └── {messageId}.json + */ + +/** + * Get authenticated fetch function from Solid session + * + * @param {Object} req - Express request object + * @returns {Promise} Authenticated fetch function + */ +async function getSolidFetch(req) { + try { + logger.debug('[SolidStorage] Getting authenticated fetch from session', { + hasSession: !!req.session, + sessionId: req.sessionID, + hasUser: !!req.user, + userId: req.user?.id, + openidId: req.user?.openidId, + }); + + // Check if user is authenticated with Solid/OpenID + if (!req.user || !req.user.openidId) { + logger.error('[SolidStorage] User not authenticated with Solid/OpenID', { + hasUser: !!req.user, + hasOpenidId: !!req.user?.openidId, + }); + throw new Error('User not authenticated with Solid/OpenID'); + } + + let accessToken = null; + let tokenSource = 'unknown'; + + // Try to get access token from multiple sources (in order of preference) + + // Source 1: Session (tokens stored during OAuth callback) + const openidTokens = req.session?.openidTokens; + if (openidTokens && openidTokens.accessToken) { + accessToken = openidTokens.accessToken; + tokenSource = 'session'; + logger.debug('[SolidStorage] Access token found in session', { + tokenLength: accessToken?.length, + expiresAt: openidTokens.expiresAt, + isExpired: openidTokens.expiresAt ? Date.now() > openidTokens.expiresAt : 'unknown', + }); + } + + // Source 2: Cookies (fallback if session not available) + if (!accessToken && req.cookies?.openid_access_token) { + accessToken = req.cookies.openid_access_token; + tokenSource = 'cookies'; + logger.debug('[SolidStorage] Access token found in cookies', { + tokenLength: accessToken?.length, + }); + } + + // Source 3: User object tokenset (from OAuth callback - if stored in DB) + if (!accessToken && req.user.tokenset && req.user.tokenset.access_token) { + accessToken = req.user.tokenset.access_token; + tokenSource = 'user.tokenset'; + logger.debug('[SolidStorage] Access token found in user.tokenset', { + tokenLength: accessToken?.length, + }); + } + + // Source 4: User object federatedTokens (from OAuth callback - if stored in DB) + if (!accessToken && req.user.federatedTokens && req.user.federatedTokens.access_token) { + accessToken = req.user.federatedTokens.access_token; + tokenSource = 'user.federatedTokens'; + logger.debug('[SolidStorage] Access token found in user.federatedTokens', { + tokenLength: accessToken?.length, + }); + } + + if (!accessToken) { + // Check if session exists but wasn't loaded + const sessionCookie = req.cookies?.['connect.sid'] || req.cookies?.connect_sid; + logger.error('[SolidStorage] No OpenID access token found in any source', { + hasSession: !!req.session, + sessionId: req.sessionID, + hasSessionCookie: !!sessionCookie, + sessionCookieName: sessionCookie ? 'present' : 'missing', + hasOpenidTokens: !!openidTokens, + hasCookies: !!req.cookies, + hasOpenidCookie: !!req.cookies?.openid_access_token, + hasTokenset: !!req.user.tokenset, + hasFederatedTokens: !!req.user.federatedTokens, + sessionHasAccessToken: !!openidTokens?.accessToken, + tokensetHasAccessToken: !!req.user.tokenset?.access_token, + federatedTokensHasAccessToken: !!req.user.federatedTokens?.access_token, + cookieKeys: req.cookies ? Object.keys(req.cookies) : [], + allCookies: req.cookies ? Object.keys(req.cookies).join(', ') : 'none', + }); + throw new Error('No OpenID access token found. Make sure you logged in via Solid-OIDC and the session is maintained.'); + } + logger.info('[SolidStorage] Access token retrieved', { + tokenSource, + tokenLength: accessToken?.length, + tokenPrefix: accessToken?.substring(0, 20) + '...', + }); + + // Create authenticated fetch function + // The access token will be used in Authorization header + const authenticatedFetch = async (url, options = {}) => { + const headers = { + ...options.headers, + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': options.headers?.['Content-Type'] || 'application/json', + }; + + logger.debug('[SolidStorage] Making authenticated request', { + url: url.toString(), + method: options.method || 'GET', + hasAuthHeader: !!headers.Authorization, + }); + + try { + const response = await fetch(url, { + ...options, + headers, + }); + + logger.debug('[SolidStorage] Request response', { + url: url.toString(), + status: response.status, + statusText: response.statusText, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unable to read error response'); + logger.error('[SolidStorage] Request failed', { + url: url.toString(), + status: response.status, + statusText: response.statusText, + error: errorText, + }); + } + + return response; + } catch (error) { + logger.error('[SolidStorage] Fetch error', { + url: url.toString(), + error: error.message, + stack: error.stack, + }); + throw error; + } + }; + + logger.info('[SolidStorage] Authenticated fetch function created successfully'); + return authenticatedFetch; + } catch (error) { + logger.error('[SolidStorage] Error getting authenticated fetch', { + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +/** + * Get user's Pod URL from their WebID + * + * @param {string} webId - User's WebID + * @param {Function} fetch - Authenticated fetch function + * @returns {Promise} Primary Pod URL + */ +async function getPodUrl(webId, fetch) { + try { + logger.debug('[SolidStorage] Getting Pod URL from WebID', { webId }); + + if (!webId) { + logger.error('[SolidStorage] WebID is required'); + throw new Error('WebID is required'); + } + + if (!fetch) { + logger.error('[SolidStorage] Fetch function is required'); + throw new Error('Fetch function is required'); + } + + // Try to get Pod URLs from profile + let podUrls = []; + try { + podUrls = await getPodUrlAll(webId, { fetch }); + logger.debug('[SolidStorage] Pod URLs retrieved from profile', { + webId, + podCount: podUrls?.length || 0, + podUrls: podUrls || [], + }); + } catch (error) { + logger.warn('[SolidStorage] Failed to get Pod URLs from profile, will try fallback', { + webId, + error: error.message, + }); + } + + // If no Pod URLs found, derive from WebID as fallback + if (!podUrls || podUrls.length === 0) { + logger.info('[SolidStorage] No Pod URLs found in profile, deriving from WebID', { webId }); + + // Extract base URL from WebID + // WebID format: http://localhost:3000/bisi/profile/card#me + // Pod URL format: http://localhost:3000/bisi/ + try { + const webIdUrl = new URL(webId); + // Remove the fragment (#me) and path segments after the pod identifier + // For most Solid servers, the Pod is at the root or one level deep + // Pattern: http://host:port/podId/ -> Pod URL + const pathParts = webIdUrl.pathname.split('/').filter(p => p); + + // If path contains 'profile', 'card', or similar, remove them + // The Pod is usually at the base or one level up + let podPath = '/'; + if (pathParts.length > 0) { + // For pattern like /bisi/profile/card, Pod is at /bisi/ + // For pattern like /profile/card, Pod is at / + const podIdentifier = pathParts[0]; + if (podIdentifier && podIdentifier !== 'profile' && podIdentifier !== 'card') { + podPath = `/${podIdentifier}/`; + } + } + + const derivedPodUrl = `${webIdUrl.protocol}//${webIdUrl.host}${podPath}`; + logger.info('[SolidStorage] Derived Pod URL from WebID', { + webId, + derivedPodUrl, + pathParts, + }); + + // Verify the Pod URL is accessible by trying to fetch the root + try { + const response = await fetch(derivedPodUrl, { + method: 'HEAD', + }); + if (response.ok || response.status === 401 || response.status === 403) { + // 401/403 means the Pod exists but we need auth (which is expected) + logger.info('[SolidStorage] Derived Pod URL is accessible', { + derivedPodUrl, + status: response.status, + }); + return derivedPodUrl; + } + } catch (verifyError) { + logger.warn('[SolidStorage] Could not verify derived Pod URL, using it anyway', { + derivedPodUrl, + error: verifyError.message, + }); + } + + return derivedPodUrl; + } catch (urlError) { + logger.error('[SolidStorage] Failed to derive Pod URL from WebID', { + webId, + error: urlError.message, + }); + throw new Error(`Could not determine Pod URL from WebID: ${webId}`); + } + } + + const primaryPodUrl = podUrls[0]; + logger.info('[SolidStorage] Primary Pod URL determined', { + webId, + primaryPodUrl, + totalPods: podUrls.length, + }); + + return primaryPodUrl; + } catch (error) { + logger.error('[SolidStorage] Error getting Pod URL', { + webId, + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +/** + * Get base storage path for LibreChat data in Pod + * + * @param {string} podUrl - Pod URL + * @returns {string} Base storage path + */ +function getBaseStoragePath(podUrl) { + const basePath = `${podUrl}librechat/`; + logger.debug('[SolidStorage] Base storage path', { podUrl, basePath }); + return basePath; +} + +/** + * Get conversation file path + * + * @param {string} podUrl - Pod URL + * @param {string} conversationId - Conversation ID + * @returns {string} Conversation file path + */ +function getConversationPath(podUrl, conversationId) { + const path = `${getBaseStoragePath(podUrl)}conversations/${conversationId}.json`; + logger.debug('[SolidStorage] Conversation path', { conversationId, path }); + return path; +} + +/** + * Get messages container path for a conversation + * + * @param {string} podUrl - Pod URL + * @param {string} conversationId - Conversation ID + * @returns {string} Messages container path + */ +function getMessagesContainerPath(podUrl, conversationId) { + const path = `${getBaseStoragePath(podUrl)}messages/${conversationId}/`; + logger.debug('[SolidStorage] Messages container path', { conversationId, path }); + return path; +} + +/** + * Get message file path + * + * @param {string} podUrl - Pod URL + * @param {string} conversationId - Conversation ID + * @param {string} messageId - Message ID + * @returns {string} Message file path + */ +function getMessagePath(podUrl, conversationId, messageId) { + const path = `${getMessagesContainerPath(podUrl, conversationId)}${messageId}.json`; + logger.debug('[SolidStorage] Message path', { conversationId, messageId, path }); + return path; +} + +/** + * Ensure container exists, create if it doesn't + * + * @param {string} containerUrl - Container URL + * @param {Function} fetch - Authenticated fetch function + * @returns {Promise} + */ +async function ensureContainerExists(containerUrl, fetch) { + try { + logger.debug('[SolidStorage] Ensuring container exists', { containerUrl }); + + try { + // Try to check if container exists using HEAD request + const response = await fetch(containerUrl, { + method: 'HEAD', + }); + + if (response.ok || response.status === 200 || response.status === 405) { + // Container exists (405 Method Not Allowed is also OK - means container exists but doesn't support HEAD) + logger.debug('[SolidStorage] Container already exists', { + containerUrl, + status: response.status, + }); + return; + } + + // If we get here, container might not exist + if (response.status === 404) { + throw new Error('Container not found'); + } + } catch (error) { + // Container doesn't exist, create it + if (error.status === 404 || error.message?.includes('404') || error.message?.includes('not found') || error.message?.includes('Container not found')) { + logger.info('[SolidStorage] Container does not exist, creating', { containerUrl }); + try { + await createContainerAt(containerUrl, { fetch }); + logger.info('[SolidStorage] Container created successfully', { containerUrl }); + } catch (createError) { + // If creation fails with 409, container already exists (race condition) + if (createError.status === 409 || createError.message?.includes('409') || createError.message?.includes('already exists')) { + logger.debug('[SolidStorage] Container already exists (race condition)', { containerUrl }); + return; + } + throw createError; + } + } else { + logger.error('[SolidStorage] Error checking container existence', { + containerUrl, + error: error.message, + errorStatus: error.status, + }); + throw error; + } + } + } catch (error) { + logger.error('[SolidStorage] Error ensuring container exists', { + containerUrl, + error: error.message, + errorStatus: error.status, + stack: error.stack, + }); + throw error; + } +} + +/** + * Ensure base storage structure exists + * + * @param {string} podUrl - Pod URL + * @param {Function} fetch - Authenticated fetch function + * @returns {Promise} + */ +async function ensureBaseStructure(podUrl, fetch) { + try { + logger.debug('[SolidStorage] Ensuring base storage structure exists', { podUrl }); + + const basePath = getBaseStoragePath(podUrl); + const conversationsPath = `${basePath}conversations/`; + const messagesPath = `${basePath}messages/`; + + // Ensure base librechat container + await ensureContainerExists(basePath, fetch); + + // Ensure conversations container + await ensureContainerExists(conversationsPath, fetch); + + // Ensure messages container + await ensureContainerExists(messagesPath, fetch); + + logger.info('[SolidStorage] Base storage structure ensured', { + podUrl, + basePath, + conversationsPath, + messagesPath, + }); + } catch (error) { + logger.error('[SolidStorage] Error ensuring base structure', { + podUrl, + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +/** + * Save a message to Solid Pod + * + * @param {Object} req - Express request object + * @param {Object} messageData - Message data to save + * @param {string} messageData.messageId - Message ID + * @param {string} messageData.conversationId - Conversation ID + * @param {string} messageData.text - Message text + * @param {string} messageData.sender - Sender identifier + * @param {boolean} messageData.isCreatedByUser - Whether message was created by user + * @param {string} messageData.endpoint - Endpoint where message originated + * @param {string} [messageData.parentMessageId] - Parent message ID + * @param {string} [messageData.error] - Error message + * @param {boolean} [messageData.unfinished] - Whether message is unfinished + * @param {Array} [messageData.files] - Files associated with message + * @param {string} [messageData.finish_reason] - Finish reason + * @param {number} [messageData.tokenCount] - Token count + * @param {string} [messageData.plugin] - Plugin name + * @param {Array} [messageData.plugins] - Plugin array + * @param {string} [messageData.model] - Model used + * @param {Date} [messageData.expiredAt] - Expiration date + * @param {Object} [metadata] - Additional metadata + * @returns {Promise} Saved message data + */ +async function saveMessageToSolid(req, messageData, metadata) { + try { + logger.info('[SolidStorage] Saving message to Solid Pod', { + messageId: messageData.messageId, + conversationId: messageData.conversationId, + context: metadata?.context, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!messageData.messageId) { + throw new Error('messageId is required'); + } + + if (!messageData.conversationId) { + throw new Error('conversationId is required'); + } + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // Ensure base structure exists + await ensureBaseStructure(podUrl, authenticatedFetch); + + // Ensure messages container for this conversation exists + const messagesContainerPath = getMessagesContainerPath(podUrl, messageData.conversationId); + await ensureContainerExists(messagesContainerPath, authenticatedFetch); + + // Prepare message object with all fields + const messageToSave = { + messageId: messageData.newMessageId || messageData.messageId, + conversationId: messageData.conversationId, + user: req.user.id, + text: messageData.text || '', + content: messageData.content || undefined, // For agent endpoints, content is an array + sender: messageData.sender, + isCreatedByUser: messageData.isCreatedByUser, + endpoint: messageData.endpoint, + parentMessageId: messageData.parentMessageId || null, + error: messageData.error || null, + unfinished: messageData.unfinished || false, + files: messageData.files || [], + finish_reason: messageData.finish_reason || null, + tokenCount: messageData.tokenCount || 0, + plugin: messageData.plugin || null, + plugins: messageData.plugins || [], + model: messageData.model || null, + expiredAt: messageData.expiredAt || null, + createdAt: messageData.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + // Get message file path + const messagePath = getMessagePath( + podUrl, + messageData.conversationId, + messageToSave.messageId + ); + + // Convert message to JSON string + const messageJson = JSON.stringify(messageToSave, null, 2); + + // Use Buffer for Node.js compatibility (Blob is available in Node 18+ but Buffer is more universal) + const messageBuffer = Buffer.from(messageJson, 'utf-8'); + + logger.debug('[SolidStorage] Saving message file', { + messagePath, + messageId: messageToSave.messageId, + conversationId: messageToSave.conversationId, + textLength: messageToSave.text?.length || 0, + hasContent: !!messageToSave.content, + contentLength: Array.isArray(messageToSave.content) ? messageToSave.content.length : 0, + contentTypes: Array.isArray(messageToSave.content) + ? messageToSave.content.map(c => c?.type).filter(Boolean) + : [], + bufferSize: messageBuffer.length, + }); + + // Check if message already exists (for update vs create) + let messageExists = false; + try { + await getFile(messagePath, { fetch: authenticatedFetch }); + messageExists = true; + logger.debug('[SolidStorage] Message file already exists, will overwrite', { + messagePath, + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + messageExists = false; + logger.debug('[SolidStorage] Message file does not exist, will create', { + messagePath, + }); + } else { + // Some other error occurred, log it but continue + logger.warn('[SolidStorage] Error checking if message exists, will try to save anyway', { + messagePath, + error: error.message, + }); + } + } + + // Save or overwrite the message file + if (messageExists) { + await overwriteFile(messagePath, messageBuffer, { + contentType: 'application/json', + fetch: authenticatedFetch, + }); + logger.info('[SolidStorage] Message file overwritten successfully', { + messagePath, + messageId: messageToSave.messageId, + }); + } else { + await saveFileInContainer(messagesContainerPath, messageBuffer, { + slug: `${messageToSave.messageId}.json`, + contentType: 'application/json', + fetch: authenticatedFetch, + }); + logger.info('[SolidStorage] Message file saved successfully', { + messagePath, + messageId: messageToSave.messageId, + }); + } + + if (metadata?.context) { + logger.info(`[SolidStorage] ---saveMessageToSolid context: ${metadata.context}`); + } + + return messageToSave; + } catch (error) { + logger.error('[SolidStorage] Error saving message to Solid Pod', { + messageId: messageData?.messageId, + conversationId: messageData?.conversationId, + error: error.message, + stack: error.stack, + context: metadata?.context, + }); + throw error; + } +} + +/** + * Get all messages for a conversation from Solid Pod + * + * @param {Object} req - Express request object + * @param {string} conversationId - Conversation ID + * @returns {Promise} Array of message objects, sorted by createdAt + */ +async function getMessagesFromSolid(req, conversationId) { + try { + logger.info('[SolidStorage] Getting messages from Solid Pod', { + conversationId, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!conversationId) { + throw new Error('conversationId is required'); + } + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // Get messages container path + const messagesContainerPath = getMessagesContainerPath(podUrl, conversationId); + + logger.debug('[SolidStorage] Reading messages container', { + messagesContainerPath, + conversationId, + }); + + // Get all files in the messages container + // Solid Pods return Turtle (RDF) format with ldp:contains predicates + let messageFiles = []; + try { + const response = await authenticatedFetch(messagesContainerPath, { + method: 'GET', + headers: { + 'Accept': 'text/turtle, application/ld+json, */*', + }, + }); + + if (response.status === 404) { + // Container doesn't exist, return empty array + logger.info('[SolidStorage] Messages container does not exist, returning empty array', { + messagesContainerPath, + }); + return []; + } + + if (!response.ok) { + throw new Error(`Failed to get container contents: ${response.status} ${response.statusText}`); + } + + // Parse the response (Solid containers return Turtle format) + // Format: ldp:contains , , . + const text = await response.text(); + + // Parse Turtle format to extract all items from ldp:contains + // Handle both single and comma-separated items + const ldpContainsPattern = /ldp:contains\s+((?:<[^>]+>(?:\s*,\s*<[^>]+>)*))/g; + const allItems = []; + let match; + + while ((match = ldpContainsPattern.exec(text)) !== null) { + // Extract all URLs from the matched group (handles comma-separated items) + const itemsString = match[1]; + const itemPattern = /<([^>]+)>/g; + let itemMatch; + while ((itemMatch = itemPattern.exec(itemsString)) !== null) { + const itemUrl = itemMatch[1]; + // Convert relative URLs to absolute URLs + const absoluteUrl = itemUrl.startsWith('http') + ? itemUrl + : new URL(itemUrl, messagesContainerPath).href; + allItems.push({ url: absoluteUrl }); + } + } + + // Filter for JSON files only + messageFiles = allItems.filter((item) => { + const url = item.url || ''; + return url.endsWith('.json') && !url.endsWith('.meta.json'); + }); + + logger.debug('[SolidStorage] Found message files', { + conversationId, + fileCount: messageFiles.length, + files: messageFiles.map(f => f.url), + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + // Container doesn't exist, return empty array + logger.info('[SolidStorage] Messages container does not exist, returning empty array', { + messagesContainerPath, + }); + return []; + } + throw error; + } + + logger.debug('[SolidStorage] Found message files', { + conversationId, + fileCount: messageFiles.length, + }); + + // Read all message files + const messages = []; + for (const fileInfo of messageFiles) { + try { + const fileUrl = fileInfo.url; + logger.debug('[SolidStorage] Reading message file', { + fileUrl, + conversationId, + }); + + const file = await getFile(fileUrl, { fetch: authenticatedFetch }); + const fileText = await file.text(); + const messageData = JSON.parse(fileText); + + // Validate that this message belongs to the current user + if (messageData.user !== req.user.id) { + logger.warn('[SolidStorage] Message belongs to different user, skipping', { + messageId: messageData.messageId, + messageUserId: messageData.user, + currentUserId: req.user.id, + }); + continue; + } + + // Validate that this message belongs to the requested conversation + if (messageData.conversationId !== conversationId) { + logger.warn('[SolidStorage] Message belongs to different conversation, skipping', { + messageId: messageData.messageId, + messageConversationId: messageData.conversationId, + requestedConversationId: conversationId, + }); + continue; + } + + messages.push(messageData); + } catch (error) { + logger.error('[SolidStorage] Error reading message file', { + fileUrl: fileInfo.url, + conversationId, + error: error.message, + }); + // Continue with other files even if one fails + } + } + + // Sort messages by createdAt (ascending) + messages.sort((a, b) => { + const dateA = new Date(a.createdAt || 0); + const dateB = new Date(b.createdAt || 0); + return dateA - dateB; + }); + + logger.info('[SolidStorage] Messages retrieved successfully', { + conversationId, + messageCount: messages.length, + }); + + return messages; + } catch (error) { + logger.error('[SolidStorage] Error getting messages from Solid Pod', { + conversationId, + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +/** + * Update an existing message in Solid Pod + * + * @param {Object} req - Express request object + * @param {Object} messageData - Message data to update + * @param {string} messageData.messageId - Message ID (required) + * @param {string} [messageData.text] - Updated message text + * @param {Array} [messageData.files] - Updated files array + * @param {boolean} [messageData.isCreatedByUser] - Updated isCreatedByUser flag + * @param {string} [messageData.sender] - Updated sender + * @param {number} [messageData.tokenCount] - Updated token count + * @param {string} [messageData.finish_reason] - Updated finish reason + * @param {boolean} [messageData.unfinished] - Updated unfinished flag + * @param {string} [messageData.error] - Updated error message + * @param {Object} [metadata] - Additional metadata + * @returns {Promise} Updated message data + */ +async function updateMessageInSolid(req, messageData, metadata) { + try { + logger.info('[SolidStorage] Updating message in Solid Pod', { + messageId: messageData.messageId, + context: metadata?.context, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!messageData.messageId) { + throw new Error('messageId is required'); + } + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // First, try to get the existing message to merge updates + let existingMessage = null; + let conversationId = messageData.conversationId; + + // If conversationId is not provided, we need to find it from the existing message + if (!conversationId) { + logger.warn('[SolidStorage] conversationId not provided in update, searching for message in Pod', { + messageId: messageData.messageId, + updateFields: Object.keys(messageData), + }); + + // Search for the message across all conversation message containers + // We'll check the messages container for all conversation subdirectories + const basePath = getBaseStoragePath(podUrl); + const messagesContainerPath = `${basePath}messages/`; + + try { + // Get list of all conversation directories in messages container + const response = await authenticatedFetch(messagesContainerPath, { + method: 'GET', + headers: { + 'Accept': 'text/turtle, application/ld+json, */*', + }, + }); + + if (response.ok) { + const text = await response.text(); + // Parse Turtle format to extract conversation directories + const ldpContainsPattern = /ldp:contains\s+((?:<[^>]+>(?:\s*,\s*<[^>]+>)*))/g; + const allItems = []; + let match; + + while ((match = ldpContainsPattern.exec(text)) !== null) { + const itemsString = match[1]; + const itemPattern = /<([^>]+)>/g; + let itemMatch; + while ((itemMatch = itemPattern.exec(itemsString)) !== null) { + const itemUrl = itemMatch[1]; + // Only include directories (ending with /) + if (itemUrl.endsWith('/')) { + const absoluteUrl = itemUrl.startsWith('http') + ? itemUrl + : new URL(itemUrl, messagesContainerPath).href; + allItems.push(absoluteUrl); + } + } + } + + logger.debug('[SolidStorage] Searching for message across conversation directories', { + messageId: messageData.messageId, + directoryCount: allItems.length, + }); + + // Search each conversation directory for the message + for (const conversationDir of allItems) { + try { + const messageFileUrl = `${conversationDir}${messageData.messageId}.json`; + const fileResponse = await authenticatedFetch(messageFileUrl, { method: 'HEAD' }); + + if (fileResponse.ok) { + // Found the message! Extract conversationId from the directory path + // Format: .../messages/{conversationId}/ + const pathParts = conversationDir.split('/'); + const conversationIdIndex = pathParts.findIndex(part => part === 'messages') + 1; + if (conversationIdIndex > 0 && pathParts[conversationIdIndex]) { + conversationId = pathParts[conversationIdIndex]; + logger.info('[SolidStorage] Found conversationId from message location', { + messageId: messageData.messageId, + conversationId, + searchPath: conversationDir, + }); + break; + } + } + } catch (searchError) { + // Continue searching other directories + logger.debug('[SolidStorage] Message not found in conversation directory', { + conversationDir, + messageId: messageData.messageId, + error: searchError.message, + }); + } + } + } else { + logger.warn('[SolidStorage] Failed to list messages container', { + messagesContainerPath, + status: response.status, + statusText: response.statusText, + }); + } + } catch (searchError) { + logger.error('[SolidStorage] Error searching for message in Pod', { + messageId: messageData.messageId, + error: searchError.message, + stack: searchError.stack, + }); + } + + // If we still don't have conversationId, throw error + if (!conversationId) { + const error = new Error('conversationId is required for updating messages. Could not find message in Pod to determine conversationId.'); + logger.error('[SolidStorage] Failed to find conversationId for message update', { + messageId: messageData.messageId, + updateFields: Object.keys(messageData), + }); + throw error; + } + } + + // Now we have conversationId, get the message path + const messagePath = getMessagePath(podUrl, conversationId, messageData.messageId); + + try { + const file = await getFile(messagePath, { fetch: authenticatedFetch }); + const fileText = await file.text(); + existingMessage = JSON.parse(fileText); + + // Validate that this message belongs to the current user + if (existingMessage.user !== req.user.id) { + throw new Error('Message does not belong to current user'); + } + + logger.debug('[SolidStorage] Existing message found', { + messageId: messageData.messageId, + conversationId, + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + throw new Error(`Message with ID ${messageData.messageId} not found`); + } + throw error; + } + + // Merge existing message with updates + // Only update content if it's explicitly provided (not undefined) + const updatedMessage = { + ...existingMessage, + ...messageData, + // Preserve content if not provided in update (for agent endpoints) + content: messageData.content !== undefined ? messageData.content : existingMessage.content, + messageId: existingMessage.messageId, // Don't allow changing messageId + conversationId: existingMessage.conversationId, // Don't allow changing conversationId + user: existingMessage.user, // Don't allow changing user + updatedAt: new Date().toISOString(), + // Preserve createdAt + createdAt: existingMessage.createdAt, + }; + + // Convert to JSON and create buffer + const messageJson = JSON.stringify(updatedMessage, null, 2); + const messageBuffer = Buffer.from(messageJson, 'utf-8'); + + logger.debug('[SolidStorage] Updating message file', { + messagePath, + messageId: updatedMessage.messageId, + conversationId: updatedMessage.conversationId, + }); + + // Overwrite the message file + await overwriteFile(messagePath, messageBuffer, { + contentType: 'application/json', + fetch: authenticatedFetch, + }); + + logger.info('[SolidStorage] Message updated successfully', { + messagePath, + messageId: updatedMessage.messageId, + }); + + if (metadata?.context) { + logger.info(`[SolidStorage] ---updateMessageInSolid context: ${metadata.context}`); + } + + return updatedMessage; + } catch (error) { + logger.error('[SolidStorage] Error updating message in Solid Pod', { + messageId: messageData?.messageId, + conversationId: messageData?.conversationId, + error: error.message, + stack: error.stack, + context: metadata?.context, + }); + throw error; + } +} + +/** + * Delete messages from Solid Pod + * + * @param {Object} req - Express request object + * @param {Object} params - Delete parameters + * @param {string} params.conversationId - Conversation ID (required) + * @param {string} [params.messageId] - If provided, delete all messages after this message + * @param {Array} [params.messageIds] - Specific message IDs to delete + * @returns {Promise} Number of messages deleted + */ +async function deleteMessagesFromSolid(req, params) { + try { + logger.info('[SolidStorage] Deleting messages from Solid Pod', { + conversationId: params.conversationId, + messageId: params.messageId, + messageIds: params.messageIds, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!params.conversationId) { + throw new Error('conversationId is required'); + } + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // Get messages container path + const messagesContainerPath = getMessagesContainerPath(podUrl, params.conversationId); + + // Get all messages for the conversation + const allMessages = await getMessagesFromSolid(req, params.conversationId); + + if (allMessages.length === 0) { + logger.info('[SolidStorage] No messages found to delete', { + conversationId: params.conversationId, + }); + return 0; + } + + let messagesToDelete = []; + + // Case 1: Delete messages after a specific messageId + if (params.messageId) { + const referenceMessage = allMessages.find((msg) => msg.messageId === params.messageId); + if (!referenceMessage) { + logger.warn('[SolidStorage] Reference message not found', { + messageId: params.messageId, + conversationId: params.conversationId, + }); + return 0; + } + + const referenceDate = new Date(referenceMessage.createdAt || 0); + messagesToDelete = allMessages.filter((msg) => { + const msgDate = new Date(msg.createdAt || 0); + return msgDate > referenceDate; + }); + + logger.debug('[SolidStorage] Filtering messages after reference message', { + referenceMessageId: params.messageId, + referenceDate: referenceDate.toISOString(), + totalMessages: allMessages.length, + messagesToDelete: messagesToDelete.length, + }); + } + // Case 2: Delete specific message IDs + else if (params.messageIds && Array.isArray(params.messageIds) && params.messageIds.length > 0) { + messagesToDelete = allMessages.filter((msg) => params.messageIds.includes(msg.messageId)); + logger.debug('[SolidStorage] Filtering specific message IDs', { + requestedIds: params.messageIds.length, + foundMessages: messagesToDelete.length, + }); + } + // Case 3: Delete all messages (if neither messageId nor messageIds provided) + else { + messagesToDelete = allMessages; + logger.debug('[SolidStorage] Deleting all messages', { + totalMessages: allMessages.length, + }); + } + + if (messagesToDelete.length === 0) { + logger.info('[SolidStorage] No messages to delete after filtering', { + conversationId: params.conversationId, + }); + return 0; + } + + // Delete each message file + let deletedCount = 0; + for (const message of messagesToDelete) { + try { + const messagePath = getMessagePath(podUrl, params.conversationId, message.messageId); + + logger.debug('[SolidStorage] Deleting message file', { + messagePath, + messageId: message.messageId, + }); + + await deleteFile(messagePath, { fetch: authenticatedFetch }); + deletedCount++; + + logger.debug('[SolidStorage] Message file deleted successfully', { + messageId: message.messageId, + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + // File already doesn't exist, count it as deleted + logger.debug('[SolidStorage] Message file already deleted', { + messageId: message.messageId, + }); + deletedCount++; + } else { + logger.error('[SolidStorage] Error deleting message file', { + messageId: message.messageId, + error: error.message, + }); + // Continue with other messages even if one fails + } + } + } + + logger.info('[SolidStorage] Messages deleted successfully', { + conversationId: params.conversationId, + deletedCount, + totalRequested: messagesToDelete.length, + }); + + return deletedCount; + } catch (error) { + logger.error('[SolidStorage] Error deleting messages from Solid Pod', { + conversationId: params?.conversationId, + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +/** + * Save a conversation to Solid Pod + * + * @param {Object} req - Express request object + * @param {Object} convoData - Conversation data to save + * @param {string} convoData.conversationId - Conversation ID (required) + * @param {string} [convoData.newConversationId] - New conversation ID (for renaming) + * @param {string} [convoData.title] - Conversation title + * @param {string} [convoData.endpoint] - Endpoint where conversation originated + * @param {string} [convoData.model] - Model used + * @param {string} [convoData.agent_id] - Agent ID + * @param {string} [convoData.assistant_id] - Assistant ID + * @param {string} [convoData.spec] - Spec + * @param {string} [convoData.iconURL] - Icon URL + * @param {Array} [convoData.messages] - Array of message references + * @param {Array} [convoData.files] - Array of file IDs + * @param {string} [convoData.promptPrefix] - Prompt prefix + * @param {number} [convoData.temperature] - Temperature setting + * @param {number} [convoData.topP] - Top P setting + * @param {number} [convoData.presence_penalty] - Presence penalty + * @param {number} [convoData.frequency_penalty] - Frequency penalty + * @param {Date} [convoData.expiredAt] - Expiration date + * @param {Object} [metadata] - Additional metadata + * @returns {Promise} Saved conversation data + */ +async function saveConvoToSolid(req, convoData, metadata) { + try { + logger.info('[SolidStorage] Saving conversation to Solid Pod', { + conversationId: convoData.conversationId, + newConversationId: convoData.newConversationId, + context: metadata?.context, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!convoData.conversationId && !convoData.newConversationId) { + throw new Error('conversationId or newConversationId is required'); + } + + // Use newConversationId if provided, otherwise use conversationId + const finalConversationId = convoData.newConversationId || convoData.conversationId; + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // Ensure base structure exists + await ensureBaseStructure(podUrl, authenticatedFetch); + + // Get conversation file path + const conversationPath = getConversationPath(podUrl, finalConversationId); + + // Get messages for this conversation (just IDs for reference) + // Note: We call getMessagesFromSolid directly since it's in the same module + const messages = await getMessagesFromSolid(req, finalConversationId); + const messageRefs = messages.map((msg) => ({ + messageId: msg.messageId, + createdAt: msg.createdAt, + })); + + // Prepare conversation object + const conversationToSave = { + conversationId: finalConversationId, + user: req.user.id, + title: convoData.title || null, + endpoint: convoData.endpoint || null, + model: convoData.model || null, + agent_id: convoData.agent_id || null, + assistant_id: convoData.assistant_id || null, + spec: convoData.spec || null, + iconURL: convoData.iconURL || null, + messages: messageRefs, + files: convoData.files || [], + promptPrefix: convoData.promptPrefix || null, + temperature: convoData.temperature || null, + topP: convoData.topP || null, + presence_penalty: convoData.presence_penalty || null, + frequency_penalty: convoData.frequency_penalty || null, + expiredAt: convoData.expiredAt || null, + createdAt: convoData.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + // Convert to JSON and create buffer + const conversationJson = JSON.stringify(conversationToSave, null, 2); + const conversationBuffer = Buffer.from(conversationJson, 'utf-8'); + + logger.debug('[SolidStorage] Saving conversation file', { + conversationPath, + conversationId: finalConversationId, + messageCount: messageRefs.length, + }); + + // Check if conversation already exists + let conversationExists = false; + try { + await getFile(conversationPath, { fetch: authenticatedFetch }); + conversationExists = true; + logger.debug('[SolidStorage] Conversation file already exists, will overwrite', { + conversationPath, + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + conversationExists = false; + logger.debug('[SolidStorage] Conversation file does not exist, will create', { + conversationPath, + }); + } else { + logger.warn('[SolidStorage] Error checking if conversation exists, will try to save anyway', { + conversationPath, + error: error.message, + }); + } + } + + // If conversationId changed, delete old file + if (convoData.newConversationId && convoData.conversationId !== convoData.newConversationId) { + const oldConversationPath = getConversationPath(podUrl, convoData.conversationId); + try { + await deleteFile(oldConversationPath, { fetch: authenticatedFetch }); + logger.info('[SolidStorage] Old conversation file deleted after rename', { + oldConversationPath, + newConversationId: convoData.newConversationId, + }); + } catch (error) { + if (error.status !== 404) { + logger.warn('[SolidStorage] Error deleting old conversation file', { + oldConversationPath, + error: error.message, + }); + } + } + } + + // Save or overwrite the conversation file + const conversationsContainerPath = `${getBaseStoragePath(podUrl)}conversations/`; + if (conversationExists) { + await overwriteFile(conversationPath, conversationBuffer, { + contentType: 'application/json', + fetch: authenticatedFetch, + }); + logger.info('[SolidStorage] Conversation file overwritten successfully', { + conversationPath, + conversationId: finalConversationId, + }); + } else { + await saveFileInContainer(conversationsContainerPath, conversationBuffer, { + slug: `${finalConversationId}.json`, + contentType: 'application/json', + fetch: authenticatedFetch, + }); + logger.info('[SolidStorage] Conversation file saved successfully', { + conversationPath, + conversationId: finalConversationId, + }); + } + + if (metadata?.context) { + logger.info(`[SolidStorage] ---saveConvoToSolid context: ${metadata.context}`); + } + + return conversationToSave; + } catch (error) { + logger.error('[SolidStorage] Error saving conversation to Solid Pod', { + conversationId: convoData?.conversationId, + newConversationId: convoData?.newConversationId, + error: error.message, + stack: error.stack, + context: metadata?.context, + }); + throw error; + } +} + +/** + * Get a single conversation from Solid Pod + * + * @param {Object} req - Express request object + * @param {string} conversationId - Conversation ID + * @returns {Promise} Conversation object or null if not found + */ +async function getConvoFromSolid(req, conversationId) { + try { + logger.info('[SolidStorage] Getting conversation from Solid Pod', { + conversationId, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!conversationId) { + throw new Error('conversationId is required'); + } + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // Get conversation file path + const conversationPath = getConversationPath(podUrl, conversationId); + + logger.debug('[SolidStorage] Reading conversation file', { + conversationPath, + conversationId, + }); + + try { + const file = await getFile(conversationPath, { fetch: authenticatedFetch }); + const fileText = await file.text(); + const conversationData = JSON.parse(fileText); + + // Validate that this conversation belongs to the current user + if (conversationData.user !== req.user.id) { + logger.warn('[SolidStorage] Conversation belongs to different user', { + conversationId, + conversationUserId: conversationData.user, + currentUserId: req.user.id, + }); + return null; + } + + logger.info('[SolidStorage] Conversation retrieved successfully', { + conversationId, + title: conversationData.title, + hasEndpoint: !!conversationData.endpoint, + hasModel: !!conversationData.model, + messageCount: conversationData.messages?.length || 0, + userId: conversationData.user, + expectedUserId: req.user.id, + }); + + return conversationData; + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + logger.info('[SolidStorage] Conversation not found', { + conversationId, + }); + return null; + } + throw error; + } + } catch (error) { + logger.error('[SolidStorage] Error getting conversation from Solid Pod', { + conversationId, + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +/** + * Get conversations with cursor-based pagination from Solid Pod + * + * @param {Object} req - Express request object + * @param {Object} options - Query options + * @param {string} [options.cursor] - Base64-encoded cursor for pagination + * @param {number} [options.limit=25] - Maximum number of conversations to return + * @param {boolean} [options.isArchived=false] - Filter by archived status + * @param {Array} [options.tags] - Filter by tags + * @param {string} [options.search] - Search query (in-memory text matching) + * @param {string} [options.sortBy='updatedAt'] - Sort field: 'title', 'createdAt', or 'updatedAt' + * @param {string} [options.sortDirection='desc'] - Sort direction: 'asc' or 'desc' + * @returns {Promise<{conversations: Array, nextCursor: string|null}>} Conversations and next cursor + */ +async function getConvosByCursorFromSolid(req, options = {}) { + try { + const { + cursor, + limit = 25, + isArchived = false, + tags, + search, + sortBy = 'updatedAt', + sortDirection = 'desc', + } = options; + + logger.info('[SolidStorage] Getting conversations with cursor from Solid Pod', { + cursor: cursor ? 'present' : 'none', + limit, + isArchived, + tags: tags?.length || 0, + search: search || 'none', + sortBy, + sortDirection, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + // Validate sortBy field + const validSortFields = ['title', 'createdAt', 'updatedAt']; + if (!validSortFields.includes(sortBy)) { + throw new Error( + `Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`, + ); + } + + const finalSortBy = sortBy; + const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc'; + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // Get conversations container path + const conversationsContainerPath = `${getBaseStoragePath(podUrl)}conversations/`; + + logger.debug('[SolidStorage] Reading conversations container', { + conversationsContainerPath, + }); + + // Get all conversation files + // Solid Pods return Turtle (RDF) format with ldp:contains predicates + let containerContents = []; + try { + // Try to get container contents using direct HTTP request + const response = await authenticatedFetch(conversationsContainerPath, { + method: 'GET', + headers: { + 'Accept': 'text/turtle, application/ld+json, */*', + }, + }); + + if (response.status === 404) { + // Container doesn't exist, return empty result + logger.info('[SolidStorage] Conversations container does not exist (404), returning empty array', { + conversationsContainerPath, + }); + return { conversations: [], nextCursor: null }; + } + + if (!response.ok) { + throw new Error(`Failed to get container contents: ${response.status} ${response.statusText}`); + } + + // Parse the response as text (Solid containers return Turtle format) + // Format: ldp:contains , , . + const text = await response.text(); + + // Parse Turtle format to extract all items from ldp:contains + // Handle both single and comma-separated items: ldp:contains , . + const ldpContainsPattern = /ldp:contains\s+((?:<[^>]+>(?:\s*,\s*<[^>]+>)*))/g; + const allItems = []; + let match; + + while ((match = ldpContainsPattern.exec(text)) !== null) { + // Extract all URLs from the matched group (handles comma-separated items) + const itemsString = match[1]; + const itemPattern = /<([^>]+)>/g; + let itemMatch; + while ((itemMatch = itemPattern.exec(itemsString)) !== null) { + const itemUrl = itemMatch[1]; + // Convert relative URLs to absolute URLs + const absoluteUrl = itemUrl.startsWith('http') + ? itemUrl + : new URL(itemUrl, conversationsContainerPath).href; + allItems.push({ url: absoluteUrl }); + } + } + + containerContents = allItems; + + logger.debug('[SolidStorage] Container contents retrieved', { + conversationsContainerPath, + itemCount: containerContents.length, + items: containerContents.map(c => c.url), + }); + } catch (error) { + // Log full error details for debugging + const errorMessage = error?.message || String(error) || 'Unknown error'; + const errorStatus = error?.status || error?.statusCode || error?.response?.status || 'no status'; + const errorName = error?.name || 'Unknown'; + + logger.warn('[SolidStorage] Error getting container contents', { + conversationsContainerPath, + errorMessage, + errorName, + errorStatus, + errorType: typeof error, + errorString: String(error), + errorKeys: error ? Object.keys(error) : [], + hasResponse: !!error?.response, + responseStatus: error?.response?.status, + responseStatusText: error?.response?.statusText, + }); + + // Check if error is a 404 (container doesn't exist) + const isNotFound = + errorStatus === 404 || + errorStatus === '404' || + errorMessage?.includes('404') || + errorMessage?.includes('Not Found') || + errorMessage?.toLowerCase().includes('not found') || + errorMessage?.toLowerCase().includes('404'); + + if (isNotFound) { + // Container doesn't exist, return empty result (this is expected for new users) + logger.info('[SolidStorage] Conversations container does not exist (404), returning empty array', { + conversationsContainerPath, + errorStatus, + errorMessage, + }); + return { conversations: [], nextCursor: null }; + } + + // Log unexpected errors but don't throw - return empty array instead + // This prevents fallback to MongoDB when Solid storage is enabled + logger.warn('[SolidStorage] Unexpected error getting container contents, returning empty array', { + conversationsContainerPath, + errorMessage, + errorName, + errorStatus, + errorStack: error?.stack, + }); + + // Return empty array instead of throwing to avoid MongoDB fallback + return { conversations: [], nextCursor: null }; + } + + // Filter for JSON files only + const conversationFiles = Array.from(containerContents).filter((item) => { + const url = item.url || ''; + return url.endsWith('.json') && !url.endsWith('.meta.json'); + }); + + logger.debug('[SolidStorage] Found conversation files', { + fileCount: conversationFiles.length, + }); + + // Read all conversation files + const allConversations = []; + for (const fileInfo of conversationFiles) { + try { + const fileUrl = fileInfo.url; + const file = await getFile(fileUrl, { fetch: authenticatedFetch }); + const fileText = await file.text(); + const conversationData = JSON.parse(fileText); + + // Validate that this conversation belongs to the current user + if (conversationData.user !== req.user.id) { + continue; + } + + allConversations.push(conversationData); + } catch (error) { + logger.error('[SolidStorage] Error reading conversation file', { + fileUrl: fileInfo.url, + error: error.message, + }); + // Continue with other files even if one fails + } + } + + logger.debug('[SolidStorage] All conversations loaded', { + totalCount: allConversations.length, + }); + + // Apply filters + let filtered = allConversations; + + // Filter by archived status + if (isArchived) { + filtered = filtered.filter((convo) => convo.isArchived === true); + } else { + filtered = filtered.filter( + (convo) => !convo.isArchived || convo.isArchived === false, + ); + } + + // Filter by tags + if (Array.isArray(tags) && tags.length > 0) { + filtered = filtered.filter((convo) => { + const convoTags = convo.tags || []; + return tags.some((tag) => convoTags.includes(tag)); + }); + } + + // Filter out expired conversations + filtered = filtered.filter((convo) => { + if (!convo.expiredAt) { + return true; + } + const expiredDate = new Date(convo.expiredAt); + return expiredDate > new Date(); + }); + + // Apply search (in-memory text matching) + if (search) { + const searchLower = search.toLowerCase(); + filtered = filtered.filter((convo) => { + const title = (convo.title || '').toLowerCase(); + return title.includes(searchLower); + }); + } + + // Apply cursor filter + if (cursor) { + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString()); + const { primary, secondary } = decoded; + const primaryValue = finalSortBy === 'title' ? primary : new Date(primary); + const secondaryValue = new Date(secondary); + const op = finalSortDirection === 'asc' ? 'gt' : 'lt'; + + filtered = filtered.filter((convo) => { + const convoPrimary = finalSortBy === 'title' ? convo[finalSortBy] : new Date(convo[finalSortBy]); + const convoSecondary = new Date(convo.updatedAt); + + if (op === 'gt') { + return ( + convoPrimary > primaryValue || + (convoPrimary.getTime && convoPrimary.getTime() === primaryValue.getTime() && + convoSecondary > secondaryValue) + ); + } else { + return ( + convoPrimary < primaryValue || + (convoPrimary.getTime && convoPrimary.getTime() === primaryValue.getTime() && + convoSecondary < secondaryValue) + ); + } + }); + } catch (err) { + logger.warn('[SolidStorage] Invalid cursor format, starting from beginning', { + error: err.message, + }); + } + } + + // Sort conversations + const sortOrder = finalSortDirection === 'asc' ? 1 : -1; + filtered.sort((a, b) => { + let aPrimary = a[finalSortBy]; + let bPrimary = b[finalSortBy]; + + if (finalSortBy !== 'title') { + aPrimary = new Date(aPrimary || 0); + bPrimary = new Date(bPrimary || 0); + } else { + aPrimary = (aPrimary || '').toLowerCase(); + bPrimary = (bPrimary || '').toLowerCase(); + } + + if (aPrimary < bPrimary) { + return -1 * sortOrder; + } + if (aPrimary > bPrimary) { + return 1 * sortOrder; + } + + // If primary values are equal, sort by updatedAt + const aSecondary = new Date(a.updatedAt || 0); + const bSecondary = new Date(b.updatedAt || 0); + if (aSecondary < bSecondary) { + return -1 * sortOrder; + } + if (aSecondary > bSecondary) { + return 1 * sortOrder; + } + return 0; + }); + + // Apply limit + 1 to detect if there are more results + const limited = filtered.slice(0, limit + 1); + + // Extract next cursor if there are more results + let nextCursor = null; + if (limited.length > limit) { + limited.pop(); // Remove extra item used to detect next page + const lastReturned = limited[limited.length - 1]; + const primaryValue = lastReturned[finalSortBy]; + const primaryStr = finalSortBy === 'title' ? primaryValue : new Date(primaryValue).toISOString(); + const secondaryStr = new Date(lastReturned.updatedAt).toISOString(); + const composite = { primary: primaryStr, secondary: secondaryStr }; + nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64'); + } + + // Select only required fields (matching MongoDB behavior) + const conversations = limited.map((convo) => ({ + conversationId: convo.conversationId, + endpoint: convo.endpoint, + title: convo.title, + createdAt: convo.createdAt, + updatedAt: convo.updatedAt, + user: convo.user, + model: convo.model, + agent_id: convo.agent_id, + assistant_id: convo.assistant_id, + spec: convo.spec, + iconURL: convo.iconURL, + })); + + logger.info('[SolidStorage] Conversations retrieved successfully', { + returnedCount: conversations.length, + nextCursor: nextCursor ? 'present' : 'none', + }); + + return { conversations, nextCursor }; + } catch (error) { + logger.error('[SolidStorage] Error getting conversations from Solid Pod', { + errorMessage: error.message, + errorName: error.name, + errorStatus: error.status, + errorCode: error.code, + stack: error.stack, + userId: req.user?.id, + openidId: req.user?.openidId, + }); + throw error; + } +} + +/** + * Delete conversations from Solid Pod + * + * @param {Object} req - Express request object + * @param {Array} conversationIds - Array of conversation IDs to delete + * @returns {Promise} Number of conversations deleted + */ +async function deleteConvosFromSolid(req, conversationIds) { + try { + logger.info('[SolidStorage] Deleting conversations from Solid Pod', { + conversationIds, + count: conversationIds?.length || 0, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!Array.isArray(conversationIds) || conversationIds.length === 0) { + logger.warn('[SolidStorage] No conversation IDs provided for deletion'); + return 0; + } + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + let deletedCount = 0; + + // Delete each conversation and its associated messages + for (const conversationId of conversationIds) { + try { + // Get conversation file path + const conversationPath = getConversationPath(podUrl, conversationId); + + // Verify conversation belongs to user before deleting + try { + const conversation = await getConvoFromSolid(req, conversationId); + if (!conversation) { + logger.warn('[SolidStorage] Conversation not found or does not belong to user', { + conversationId, + }); + continue; + } + } catch (error) { + logger.warn('[SolidStorage] Error verifying conversation ownership, skipping', { + conversationId, + error: error.message, + }); + continue; + } + + // Delete all messages for this conversation + try { + const messagesDeleted = await deleteMessagesFromSolid(req, { + conversationId, + }); + logger.debug('[SolidStorage] Messages deleted for conversation', { + conversationId, + messagesDeleted, + }); + } catch (error) { + logger.warn('[SolidStorage] Error deleting messages for conversation, continuing', { + conversationId, + error: error.message, + }); + // Continue with conversation deletion even if messages fail + } + + // Delete the conversation file + try { + await deleteFile(conversationPath, { fetch: authenticatedFetch }); + deletedCount++; + logger.info('[SolidStorage] Conversation deleted successfully', { + conversationId, + conversationPath, + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + // File already doesn't exist, count it as deleted + deletedCount++; + logger.debug('[SolidStorage] Conversation file already deleted', { + conversationId, + }); + } else { + logger.error('[SolidStorage] Error deleting conversation file', { + conversationId, + conversationPath, + error: error.message, + }); + // Continue with other conversations even if one fails + } + } + } catch (error) { + logger.error('[SolidStorage] Error processing conversation deletion', { + conversationId, + error: error.message, + }); + // Continue with other conversations even if one fails + } + } + + logger.info('[SolidStorage] Conversations deleted successfully', { + requestedCount: conversationIds.length, + deletedCount, + }); + + return deletedCount; + } catch (error) { + logger.error('[SolidStorage] Error deleting conversations from Solid Pod', { + conversationIds, + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +module.exports = { + getSolidFetch, + getPodUrl, + getBaseStoragePath, + getConversationPath, + getMessagesContainerPath, + getMessagePath, + ensureContainerExists, + ensureBaseStructure, + saveMessageToSolid, + getMessagesFromSolid, + updateMessageInSolid, + deleteMessagesFromSolid, + saveConvoToSolid, + getConvoFromSolid, + getConvosByCursorFromSolid, + deleteConvosFromSolid, + // Re-export solid-client functions for convenience + getFile, + saveFileInContainer, + overwriteFile, + deleteFile, +}; diff --git a/api/strategies/jwtStrategy.js b/api/strategies/jwtStrategy.js index 386b7bafa1be..1c0e9d4766f9 100644 --- a/api/strategies/jwtStrategy.js +++ b/api/strategies/jwtStrategy.js @@ -1,13 +1,39 @@ const { logger } = require('@librechat/data-schemas'); const { SystemRoles } = require('librechat-data-provider'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); +const cookies = require('cookie'); const { getUserById, updateUser } = require('~/models'); +// Custom JWT extractor that checks both Authorization header and cookies +const jwtExtractor = (req) => { + // Try Authorization header first (standard way) + const authHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(req); + if (authHeader) { + logger.debug('[jwtStrategy] JWT extracted from Authorization header'); + return authHeader; + } + + // Fallback: Try to extract from cookies (for browser direct access) + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const parsedCookies = cookies.parse(cookieHeader); + // Check for token in cookies (some implementations store it here) + if (parsedCookies.token) { + logger.debug('[jwtStrategy] JWT extracted from cookie'); + return parsedCookies.token; + } + } + + logger.debug('[jwtStrategy] No JWT found in Authorization header or cookies'); + return null; +}; + // JWT strategy const jwtLogin = () => new JwtStrategy( { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + // jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: jwtExtractor, secretOrKey: process.env.JWT_SECRET, }, async (payload, done) => { diff --git a/package-lock.json b/package-lock.json index 10864619ef0d..0b555e4572b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.30.0", "@google/genai": "^1.19.0", + "@inrupt/solid-client": "^1.30.2", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", "@librechat/agents": "^3.1.55", @@ -7294,6 +7295,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@bergos/jsonparse": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@bergos/jsonparse/-/jsonparse-1.4.2.tgz", + "integrity": "sha512-qUt0QNJjvg4s1zk+AuLM6s/zcsQ8MvGn7+1f0vPuxvpCYa08YtTryuDInngbEyW5fNGGYe2znKt61RMGd5HnXg==", + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3" + } + }, "node_modules/@braintree/sanitize-url": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", @@ -9580,6 +9593,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@firebase/analytics": { "version": "0.10.10", "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.10.tgz", @@ -10801,6 +10823,51 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inrupt/solid-client": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/@inrupt/solid-client/-/solid-client-1.30.2.tgz", + "integrity": "sha512-8Lwh0ZC1d9c939O+dAsT5oheSKIBX5A0uk7fhaJ0qDBFZGKT/jnIy6TrBpvn/nYZAvCA2XSvZHgBfEX7r0FCKw==", + "license": "MIT", + "dependencies": { + "@inrupt/universal-fetch": "^1.0.1", + "@rdfjs/dataset": "^1.1.0", + "@types/rdfjs__dataset": "^1.0.4", + "buffer": "^6.0.3", + "http-link-header": "^1.1.0", + "jsonld-context-parser": "^2.3.0", + "jsonld-streaming-parser": "^3.2.0", + "n3": "^1.10.0", + "uuid": "^9.0.0" + }, + "engines": { + "node": "^16.0.0 || ^18.0.0 || ^20.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/@inrupt/universal-fetch": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@inrupt/universal-fetch/-/universal-fetch-1.0.1.tgz", + "integrity": "sha512-oqbG7jS1fa6hVkjSir+u5Ab3eSbyxFyOjsgjDICL27mAd5z8oImTSETnY2hYbkRaJQYKMBOXhtm7L5/+EbeVJg==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.7", + "undici": "^5.19.1" + } + }, + "node_modules/@inrupt/universal-fetch/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", @@ -18426,6 +18493,39 @@ "node": ">=8.x" } }, + "node_modules/@rdfjs/data-model": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@rdfjs/data-model/-/data-model-1.3.4.tgz", + "integrity": "sha512-iKzNcKvJotgbFDdti7GTQDCYmL7GsGldkYStiP0K8EYtN7deJu5t7U11rKTz+nR7RtesUggT+lriZ7BakFv8QQ==", + "license": "MIT", + "dependencies": { + "@rdfjs/types": ">=1.0.1" + }, + "bin": { + "rdfjs-data-model-test": "bin/test.js" + } + }, + "node_modules/@rdfjs/dataset": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rdfjs/dataset/-/dataset-1.1.1.tgz", + "integrity": "sha512-BNwCSvG0cz0srsG5esq6CQKJc1m8g/M0DZpLuiEp0MMpfwguXX7VeS8TCg4UUG3DV/DqEvhy83ZKSEjdsYseeA==", + "license": "MIT", + "dependencies": { + "@rdfjs/data-model": "^1.2.0" + }, + "bin": { + "rdfjs-dataset-test": "bin/test.js" + } + }, + "node_modules/@rdfjs/types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-2.0.1.tgz", + "integrity": "sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@react-aria/focus": { "version": "3.20.5", "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.5.tgz", @@ -21182,6 +21282,15 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, + "node_modules/@types/http-link-header": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/http-link-header/-/http-link-header-1.0.7.tgz", + "integrity": "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -21362,6 +21471,15 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, + "node_modules/@types/rdfjs__dataset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/rdfjs__dataset/-/rdfjs__dataset-1.0.5.tgz", + "integrity": "sha512-8OBC9Kr/ZSgNoUTe5mHTDPHaPt8Xen4XbYfqcbYv56d+4WdKliHXaFmFc0L4I5vsynE5JGu21Hvg2zWgX1Az6Q==", + "license": "MIT", + "dependencies": { + "rdf-js": "^4.0.2" + } + }, "node_modules/@types/react": { "version": "18.2.53", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.53.tgz", @@ -21393,6 +21511,22 @@ "@types/react": "*" } }, + "node_modules/@types/readable-stream": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.15.tgz", + "integrity": "sha512-oM5JSKQCcICF1wvGgmecmHldZ48OZamtMxcGGVICOJA8o8cahXC1zEVAif8iwoc5j8etxFaRFnf095+CDsuoFQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/@types/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -22246,6 +22380,18 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -23724,6 +23870,12 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canonicalize": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-1.0.8.tgz", + "integrity": "sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==", + "license": "Apache-2.0" + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -27211,6 +27363,15 @@ "es5-ext": "~0.10.14" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -29116,6 +29277,15 @@ "node": ">= 0.8" } }, + "node_modules/http-link-header": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.3.tgz", + "integrity": "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/http-parser-js": { "version": "0.5.8", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", @@ -31731,6 +31901,74 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonld-context-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jsonld-context-parser/-/jsonld-context-parser-2.4.0.tgz", + "integrity": "sha512-ZYOfvh525SdPd9ReYY58dxB3E2RUEU4DJ6ZibO8AitcowPeBH4L5rCAitE2om5G1P+HMEgYEYEr4EZKbVN4tpA==", + "license": "MIT", + "dependencies": { + "@types/http-link-header": "^1.0.1", + "@types/node": "^18.0.0", + "cross-fetch": "^3.0.6", + "http-link-header": "^1.0.2", + "relative-to-absolute-iri": "^1.0.5" + }, + "bin": { + "jsonld-context-parse": "bin/jsonld-context-parse.js" + } + }, + "node_modules/jsonld-context-parser/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/jsonld-streaming-parser": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jsonld-streaming-parser/-/jsonld-streaming-parser-3.4.0.tgz", + "integrity": "sha512-897CloyQgQidfkB04dLM5XaAXVX/cN9A2hvgHJo4y4jRhIpvg3KLMBBfcrswepV2N3T8c/Rp2JeFdWfVsbVZ7g==", + "license": "MIT", + "dependencies": { + "@bergos/jsonparse": "^1.4.0", + "@rdfjs/types": "*", + "@types/http-link-header": "^1.0.1", + "@types/readable-stream": "^2.3.13", + "buffer": "^6.0.3", + "canonicalize": "^1.0.1", + "http-link-header": "^1.0.2", + "jsonld-context-parser": "^2.4.0", + "rdf-data-factory": "^1.1.0", + "readable-stream": "^4.0.0" + } + }, + "node_modules/jsonld-streaming-parser/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/jsonld-streaming-parser/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/jsonpointer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", @@ -34766,6 +35004,44 @@ "thenify-all": "^1.0.0" } }, + "node_modules/n3": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/n3/-/n3-1.26.0.tgz", + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">=12.0" + } + }, + "node_modules/n3/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/n3/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -38234,6 +38510,34 @@ "react-dom": ">=16.9.0" } }, + "node_modules/rdf-data-factory": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rdf-data-factory/-/rdf-data-factory-1.1.3.tgz", + "integrity": "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==", + "license": "MIT", + "dependencies": { + "@rdfjs/types": "^1.0.0" + } + }, + "node_modules/rdf-data-factory/node_modules/@rdfjs/types": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rdfjs/types/-/types-1.1.2.tgz", + "integrity": "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/rdf-js": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/rdf-js/-/rdf-js-4.0.2.tgz", + "integrity": "sha512-ApvlFa/WsQh8LpPK/6hctQwG06Z9ztQQGWVtrcrf9L6+sejHNXLPOqL+w7q3hF+iL0C4sv3AX1PUtGkLNzyZ0Q==", + "deprecated": "Use @types/rdf-js instead. See https://github.com/rdfjs/types?tab=readme-ov-file#what-about-typesrdf-js", + "license": "MIT", + "dependencies": { + "@rdfjs/types": "*" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -39036,6 +39340,12 @@ "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.14.0.tgz", "integrity": "sha512-+2FW2CcT0K3P+JMR8YG846bmDwplKUTsWgT2ENwdQ1UdVfRk3GQrh6Mi4sTopy30gI8Uau5CEqHTDZ6YvWIUPA==" }, + "node_modules/relative-to-absolute-iri": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/relative-to-absolute-iri/-/relative-to-absolute-iri-1.0.7.tgz", + "integrity": "sha512-Xjyl4HmIzg2jzK/Un2gELqbcE8Fxy85A/aLSHE6PE/3+OGsFwmKVA1vRyGaz6vLWSqLDMHA+5rjD/xbibSQN1Q==", + "license": "MIT" + }, "node_modules/remark-directive": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.0.tgz", From caf5fe9c75697a454424cdbc3f30e9c3e24b7217 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 3 Feb 2026 11:37:29 +0000 Subject: [PATCH 11/94] updated progress documentation --- SOLID_INTEGRATION_PROGRESS.md | 78 +++++-- api/models/Conversation.js | 29 ++- api/server/middleware/buildEndpointOption.js | 25 +- api/server/middleware/validate/convoAccess.js | 15 +- api/server/routes/agents/chat.js | 1 + api/server/routes/agents/index.js | 1 + .../services/Endpoints/agents/initialize.js | 25 ++ api/server/services/SolidStorage.js | 65 +++++- client/src/data-provider/SSE/queries.ts | 4 +- client/src/hooks/SSE/useResumableSSE.ts | 157 +++++++------ packages/data-provider/src/createPayload.ts | 213 +++++++++++++++++- 11 files changed, 518 insertions(+), 95 deletions(-) diff --git a/SOLID_INTEGRATION_PROGRESS.md b/SOLID_INTEGRATION_PROGRESS.md index 7547ca5ee7c2..2ed715df7f7f 100644 --- a/SOLID_INTEGRATION_PROGRESS.md +++ b/SOLID_INTEGRATION_PROGRESS.md @@ -62,23 +62,56 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - Maintains backward compatibility with MongoDB - Original MongoDB code preserved (commented) for rollback capability +### 8. Conversation Access Validation +- **Status**: Complete +- **Details**: + - Updated `validateConvoAccess` middleware to check Solid storage + - Modified `searchConversation` to query Solid Pod when `USE_SOLID_STORAGE` is enabled + - Ensures users can only access their own conversations from Solid Pod + - Maintains MongoDB fallback for non-Solid users + +### 9. Payload Normalization & Model Extraction +- **Status**: Complete +- **Details**: + - Implemented conversation object normalization in `createPayload` for Solid storage compatibility + - Handles Solid storage format differences (messages as objects vs IDs, null vs undefined) + - Added fallback mechanism for schema validation failures + - Extracts `model` and `endpoint` from messages when missing in conversation metadata + - Ensures `resendFiles` is included in payloads for agents endpoints (defaults to `true`) + - Fixed model extraction in `buildEndpointOption` middleware to load from Solid when missing + +### 10. Full Conversation Flow +- **Status**: Complete +- **Details**: + - Users can start new conversations + - Users can continue existing conversations + - Model information is correctly extracted and passed through the request chain + - All required payload fields are included for agents endpoints + ## Current Status ### Working Features 1. **User Login**: Solid-OIDC authentication working correctly 2. **Conversation Creation**: New conversations are saved to Solid Pod -3. **Message Saving**: User messages are saved to Solid Pod +3. **Message Saving**: User messages and AI responses are saved to Solid Pod 4. **Data Retrieval**: Conversations and messages can be read from Pod 5. **Container Structure**: Proper directory structure created automatically +6. **Conversation Continuation**: Users can send multiple messages in the same conversation +7. **Model Persistence**: Model and endpoint information is correctly stored and retrieved +8. **Access Control**: Conversation access validation works for Solid storage users ### Known Issues 🔧 -1. **Message Updates**: LLM response messages fail to update in Pod - - **Error**: `conversationId is required for updating messages` - - **Root Cause**: `conversationId` not always present in message object during updates - - **Status**: In Progress - -2. **Title Generation**: Endpoint `/api/convos/gen_title/{conversationId}` returns 404 - - **Status**: To be investigated +1. **Title Retrieval on Page Refresh** + - **Issue**: When the page is refreshed, conversation titles revert to "untitled" instead of loading the saved title from Solid Pod + - **Impact**: Users lose visual context of their conversations after refresh + - **Status**: To be fixed + - **Priority**: High + +2. **Conversation Menu Options** + - **Issue**: Share, Rename, Duplicate, Archive, and Delete options need to be implemented for Solid storage + - **Impact**: Users cannot manage their conversations stored in Solid Pod + - **Status**: Not yet implemented + - **Priority**: High ## Technical Implementation @@ -106,25 +139,38 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - Tokens retrieved from multiple sources for robustness ## Next Steps -1. Fix message update functionality to handle missing `conversationId` -2. Investigate and fix title generation endpoint -3. Test full conversation flow (create → send message → receive LLM response → update) -4. Performance testing with multiple conversations -5. Error recovery and retry mechanisms + +1. **Conversation Menu Options Implementation** + - Implement Share functionality for Solid storage conversations + - Implement Rename functionality (update conversation title in Solid Pod) + - Implement Duplicate functionality (create copy in Solid Pod) + - Implement Archive functionality (mark as archived in Solid Pod) + - Implement Delete functionality (remove from Solid Pod) + - Ensure all operations work seamlessly with Solid storage backend + +2. **Fix Title Retrieval on Page Refresh** + - Issue: When page is refreshed, conversation titles revert to "untitled" instead of loading from Solid Pod + - Root Cause: Title not being properly retrieved/loaded from Solid storage on initial page load + - Priority: High (affects user experience) + ## Files Modified - `api/server/services/SolidStorage.js` (NEW) - Core Solid Pod operations - `api/models/Message.js` - Integrated Solid storage -- `api/models/Conversation.js` - Integrated Solid storage +- `api/models/Conversation.js` - Integrated Solid storage, updated `searchConversation` for Solid support - `api/server/routes/oauth.js` - Token logging and storage - `api/server/services/AuthService.js` - Token management - `api/server/index.js` - Session middleware ordering - `api/server/controllers/AuthController.js` - Refresh token handling +- `api/server/middleware/validate/convoAccess.js` - Added Solid storage support for conversation access validation +- `api/server/middleware/buildEndpointOption.js` - Added model extraction from Solid storage when missing +- `api/server/services/Endpoints/agents/initialize.js` - Enhanced model discovery from request body and endpointOption +- `packages/data-provider/src/createPayload.ts` - Added normalization for Solid conversation objects and fallback handling ## Dependencies Added - `@inrupt/solid-client@^1.30.2` - Solid Pod client library --- -**Report Date**: January 30, 2026 -**Status**: 85% Complete - Core functionality working, minor fixes needed +**Report Date**: February 2, 2026 + diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 84d5b5d8c72d..fd3b5ccea82d 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -9,10 +9,37 @@ const USE_SOLID_STORAGE = isEnabled(process.env.USE_SOLID_STORAGE); /** * Searches for a conversation by conversationId and returns a lean document with only conversationId and user. * @param {string} conversationId - The conversation's ID. + * @param {Object} [req] - Optional request object for Solid storage support. * @returns {Promise<{conversationId: string, user: string} | null>} The conversation object with selected fields or null if not found. */ -const searchConversation = async (conversationId) => { +const searchConversation = async (conversationId, req = null) => { try { + // Use Solid storage if enabled and req is provided with openidId + if (USE_SOLID_STORAGE && req && req.user?.openidId) { + try { + const { getConvoFromSolid } = require('~/server/services/SolidStorage'); + const convo = await getConvoFromSolid(req, conversationId); + if (convo) { + // Return only conversationId and user to match MongoDB format + return { + conversationId: convo.conversationId, + user: convo.user, + }; + } + // If Solid storage returns null, fall through to MongoDB + logger.debug('[searchConversation] Conversation not found in Solid Pod, falling back to MongoDB', { + conversationId, + }); + } catch (error) { + logger.error('[searchConversation] Error getting conversation from Solid Pod, falling back to MongoDB', { + error: error.message, + conversationId, + }); + // Fall through to MongoDB storage + } + } + + // MongoDB storage (original code) return await Conversation.findOne({ conversationId }, 'conversationId user').lean(); } catch (error) { logger.error('[searchConversation] Error searching conversation', error); diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index 64ed8e746684..3c360a79500d 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -1,4 +1,4 @@ -const { handleError } = require('@librechat/api'); +const { handleError, isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { EndpointURLs, @@ -12,6 +12,7 @@ const assistants = require('~/server/services/Endpoints/assistants'); const { getEndpointsConfig } = require('~/server/services/Config'); const agents = require('~/server/services/Endpoints/agents'); const { updateFilesUsage } = require('~/models'); +const USE_SOLID_STORAGE = process.env.USE_SOLID_STORAGE; const buildFunction = { [EModelEndpoint.agents]: agents.buildOptions, @@ -89,6 +90,28 @@ async function buildEndpointOption(req, res, next) { } } + // If model is missing and we have a conversationId, try to load it from Solid storage + if (!parsedBody.model && req.body?.conversationId && + req.body.conversationId !== 'new' && + isEnabled(USE_SOLID_STORAGE) && + req.user?.openidId) { + try { + const { getConvoFromSolid } = require('~/server/services/SolidStorage'); + const conversation = await getConvoFromSolid(req, req.body.conversationId); + + if (conversation?.model) { + parsedBody.model = conversation.model; + } + } catch (error) { + // Don't fail the request if we can't load from Solid - just log a warning + logger.warn('[buildEndpointOption] Could not load conversation from Solid to extract model', { + conversationId: req.body.conversationId, + error: error.message, + }); + // Continue without the model - it might be set elsewhere or the request might fail later + } + } + try { const isAgents = isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]); diff --git a/api/server/middleware/validate/convoAccess.js b/api/server/middleware/validate/convoAccess.js index 127bfdc53026..f987e32ffe8e 100644 --- a/api/server/middleware/validate/convoAccess.js +++ b/api/server/middleware/validate/convoAccess.js @@ -1,5 +1,6 @@ const { isEnabled } = require('@librechat/api'); const { Constants, ViolationTypes, Time } = require('librechat-data-provider'); +const { logger } = require('@librechat/data-schemas'); const { searchConversation } = require('~/models/Conversation'); const denyRequest = require('~/server/middleware/denyRequest'); const { logViolation, getLogStores } = require('~/cache'); @@ -51,13 +52,18 @@ const validateConvoAccess = async (req, res, next) => { } } - const conversation = await searchConversation(conversationId); + const conversation = await searchConversation(conversationId, req); if (!conversation) { return next(); } if (conversation.user !== userId) { + logger.error('[validateConvoAccess] Authorization failed', { + conversationId, + conversationUserId: conversation.user, + currentUserId: userId, + }); const errorMessage = { type, error: 'User not authorized for this conversation', @@ -74,7 +80,12 @@ const validateConvoAccess = async (req, res, next) => { } next(); } catch (error) { - console.error('Error validating conversation access:', error); + logger.error('[validateConvoAccess] Error validating conversation access:', { + error: error.message, + stack: error.stack, + conversationId, + userId, + }); res.status(500).json({ error: 'Internal server error' }); } }; diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js index 37b83f4f5447..750ce6fc5ace 100644 --- a/api/server/routes/agents/chat.js +++ b/api/server/routes/agents/chat.js @@ -25,6 +25,7 @@ const checkAgentResourceAccess = canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW, }); + router.use(moderateText); router.use(checkAgentAccess); router.use(checkAgentResourceAccess); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 05afdb9b1f90..8844f331b449 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -277,6 +277,7 @@ router.post('/chat/abort', async (req, res) => { }); const chatRouter = express.Router(); + chatRouter.use(configMiddleware); if (isEnabled(LIMIT_MESSAGE_IP)) { diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index e71270ef8530..f7f886e665c6 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -152,6 +152,31 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { throw new Error('Agent not found'); } + // If agent doesn't have a model, try to get it from multiple sources + // This is especially important for Solid storage where the model might be in the conversation + if (!primaryAgent.model) { + // Try multiple sources for the model + const modelFromEndpointOption = + endpointOption.model || + endpointOption.model_parameters?.model || + req.body?.model || + req.body?.model_parameters?.model; + + if (modelFromEndpointOption) { + logger.info('[initializeClient] Setting model from endpointOption/request body', { + agentId: primaryAgent.id, + model: modelFromEndpointOption, + }); + primaryAgent.model = modelFromEndpointOption; + } else { + logger.warn('[initializeClient] No model found in endpointOption or request body', { + agentId: primaryAgent.id, + endpointOptionKeys: Object.keys(endpointOption), + reqBodyKeys: Object.keys(req.body || {}), + }); + } + } + const modelsConfig = await getModelsConfig(req); const validationResult = await validateAgentModel({ req, diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js index b665ac14a133..33f5c49b8db5 100644 --- a/api/server/services/SolidStorage.js +++ b/api/server/services/SolidStorage.js @@ -1229,13 +1229,40 @@ async function saveConvoToSolid(req, convoData, metadata) { createdAt: msg.createdAt, })); + // If model or endpoint are missing, try to extract them from messages + let finalModel = convoData.model; + let finalEndpoint = convoData.endpoint; + + if (!finalModel || !finalEndpoint) { + // Find the first message with a model (usually the AI response) + const messageWithModel = messages.find(msg => msg.model && msg.endpoint); + + if (messageWithModel) { + if (!finalModel && messageWithModel.model) { + logger.info('[SolidStorage] Extracting model from messages when saving', { + conversationId: finalConversationId, + extractedModel: messageWithModel.model, + }); + finalModel = messageWithModel.model; + } + + if (!finalEndpoint && messageWithModel.endpoint) { + logger.info('[SolidStorage] Extracting endpoint from messages when saving', { + conversationId: finalConversationId, + extractedEndpoint: messageWithModel.endpoint, + }); + finalEndpoint = messageWithModel.endpoint; + } + } + } + // Prepare conversation object const conversationToSave = { conversationId: finalConversationId, user: req.user.id, title: convoData.title || null, - endpoint: convoData.endpoint || null, - model: convoData.model || null, + endpoint: finalEndpoint || null, + model: finalModel || null, agent_id: convoData.agent_id || null, assistant_id: convoData.assistant_id || null, spec: convoData.spec || null, @@ -1392,6 +1419,40 @@ async function getConvoFromSolid(req, conversationId) { return null; } + // If model or endpoint are missing, try to extract them from messages + if (!conversationData.model || !conversationData.endpoint) { + try { + const messages = await getMessagesFromSolid(req, conversationId); + + // Find the first message with a model (usually the AI response) + const messageWithModel = messages.find(msg => msg.model && msg.endpoint); + + if (messageWithModel) { + if (!conversationData.model && messageWithModel.model) { + logger.info('[SolidStorage] Extracting model from messages', { + conversationId, + extractedModel: messageWithModel.model, + }); + conversationData.model = messageWithModel.model; + } + + if (!conversationData.endpoint && messageWithModel.endpoint) { + logger.info('[SolidStorage] Extracting endpoint from messages', { + conversationId, + extractedEndpoint: messageWithModel.endpoint, + }); + conversationData.endpoint = messageWithModel.endpoint; + } + } + } catch (error) { + // If we can't get messages, log but don't fail + logger.warn('[SolidStorage] Could not extract model/endpoint from messages', { + conversationId, + error: error.message, + }); + } + } + logger.info('[SolidStorage] Conversation retrieved successfully', { conversationId, title: conversationData.title, diff --git a/client/src/data-provider/SSE/queries.ts b/client/src/data-provider/SSE/queries.ts index 76c500c5303d..94e71fad8baa 100644 --- a/client/src/data-provider/SSE/queries.ts +++ b/client/src/data-provider/SSE/queries.ts @@ -140,7 +140,7 @@ export function useTitleGeneration(enabled = true) { * - Shows generation indicators in conversation list */ export function useActiveJobs(enabled = true) { - return useQuery({ + const query = useQuery({ queryKey: [QueryKeys.activeJobs], queryFn: () => dataService.getActiveJobs(), enabled, @@ -150,4 +150,6 @@ export function useActiveJobs(enabled = true) { refetchInterval: (data) => ((data?.activeJobIds?.length ?? 0) > 0 ? 5_000 : false), retry: false, }); + + return query; } diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 831bf042adf5..f20dfa9dc750 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -149,7 +149,6 @@ export default function useResumableSSE( const baseUrl = `${apiBaseUrl()}/api/agents/chat/stream/${encodeURIComponent(currentStreamId)}`; const url = isResume ? `${baseUrl}?resume=true` : baseUrl; - console.log('[ResumableSSE] Subscribing to stream:', url, { isResume }); const sse = new SSE(url, { headers: { Authorization: `Bearer ${token}` }, @@ -158,7 +157,6 @@ export default function useResumableSSE( sseRef.current = sse; sse.addEventListener('open', () => { - console.log('[ResumableSSE] Stream connected'); setAbortScroll(false); // Restore UI state on successful connection (including reconnection) setIsSubmitting(true); @@ -224,10 +222,6 @@ export default function useResumableSSE( } if (data.sync != null) { - console.log('[ResumableSSE] SYNC received', { - runSteps: data.resumeState?.runSteps?.length ?? 0, - }); - const runId = v4(); setActiveRunId(runId); @@ -262,34 +256,18 @@ export default function useResumableSSE( ); } - console.log('[ResumableSSE] SYNC update', { - userMsgId, - serverResponseId, - responseIdx, - foundMessageId: responseIdx >= 0 ? messages[responseIdx]?.messageId : null, - messagesCount: messages.length, - aggregatedContentLength: data.resumeState.aggregatedContent?.length, - }); - if (responseIdx >= 0) { // Update existing response message with aggregatedContent const updated = [...messages]; - const oldContent = updated[responseIdx]?.content; updated[responseIdx] = { ...updated[responseIdx], content: data.resumeState.aggregatedContent, }; - console.log('[ResumableSSE] SYNC updating message', { - messageId: updated[responseIdx]?.messageId, - oldContentLength: Array.isArray(oldContent) ? oldContent.length : 0, - newContentLength: data.resumeState.aggregatedContent?.length, - }); setMessages(updated); // Sync both content handler and step handler with the updated message // so subsequent deltas build on synced content, not stale content resetContentHandler(); syncStepMessage(updated[responseIdx]); - console.log('[ResumableSSE] SYNC complete, handlers synced'); } else { // Add new response message const responseId = serverResponseId ?? `${userMsgId}_`; @@ -350,7 +328,6 @@ export default function useResumableSSE( // 404 means job doesn't exist (completed/deleted) - don't retry if (responseCode === 404) { - console.log('[ResumableSSE] Stream not found (404) - job completed or expired'); sse.close(); removeActiveJob(currentStreamId); setIsSubmitting(false); @@ -385,7 +362,6 @@ export default function useResumableSSE( * Only check e.data if there's no HTTP responseCode, since HTTP errors may also have body data. */ if (!responseCode && e.data) { - console.log('[ResumableSSE] Server-sent error event received:', e.data); sse.close(); removeActiveJob(currentStreamId); @@ -409,8 +385,6 @@ export default function useResumableSSE( // Not JSON or parsing failed - treat as generic error } - console.log('[ResumableSSE] Error type check:', { isKnownError, errorString }); - // Display the error to user via errorHandler errorHandler({ data: { text: errorString } as unknown as Parameters[0]['data'], @@ -483,8 +457,6 @@ export default function useResumableSSE( console.log('[ResumableSSE] Stream closed for reconnect - preserving state'); return; } - - console.log('[ResumableSSE] Stream aborted (intentional close) - no reconnect'); // Clear any pending reconnect attempts if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); @@ -553,13 +525,26 @@ export default function useResumableSSE( */ const startGeneration = useCallback( async (currentSubmission: TSubmission): Promise => { - const payloadData = createPayload(currentSubmission); - let { payload } = payloadData; - payload = removeNullishValues(payload) as TPayload; + // Check if submission is still valid + if (!currentSubmission || !currentSubmission.userMessage || !currentSubmission.endpointOption) { + return null; + } + + try { + let payloadData; + try { + payloadData = createPayload(currentSubmission); + } catch (payloadError) { + console.error('[ResumableSSE] Error in createPayload:', payloadError); + throw payloadError; + } + + let { payload } = payloadData; + payload = removeNullishValues(payload) as TPayload; - clearStepMaps(); + clearStepMaps(); - const url = payloadData.server; + const url = payloadData.server; const maxRetries = 3; let lastError: unknown = null; @@ -568,9 +553,13 @@ export default function useResumableSSE( try { // Use request.post which handles auth token refresh via axios interceptors const data = (await request.post(url, payload)) as { streamId: string }; - console.log('[ResumableSSE] Generation started:', { streamId: data.streamId }); return data.streamId; } catch (error) { + console.error('[ResumableSSE] POST request failed:', { + attempt, + error: error instanceof Error ? error.message : String(error), + url, + }); lastError = error; // Check if it's a network error (retry) vs server error (don't retry) const isNetworkError = @@ -592,29 +581,35 @@ export default function useResumableSSE( } } - console.error('[ResumableSSE] Error starting generation:', lastError); - - const axiosError = lastError as { response?: { data?: Record } }; - const errorData = axiosError?.response?.data; - if (errorData) { - errorHandler({ - data: { text: JSON.stringify(errorData) } as unknown as Parameters< - typeof errorHandler - >[0]['data'], - submission: currentSubmission as EventSubmission, - }); - } else { - errorHandler({ data: undefined, submission: currentSubmission as EventSubmission }); + console.error('[ResumableSSE] Error starting generation after all retries:', lastError); + + const axiosError = lastError as { response?: { data?: Record } }; + const errorData = axiosError?.response?.data; + if (errorData) { + console.error('[ResumableSSE] Error data from server:', errorData); + errorHandler({ + data: { text: JSON.stringify(errorData) } as unknown as Parameters< + typeof errorHandler + >[0]['data'], + submission: currentSubmission as EventSubmission, + }); + } else { + console.error('[ResumableSSE] No error data from server'); + errorHandler({ data: undefined, submission: currentSubmission as EventSubmission }); + } + setIsSubmitting(false); + return null; + } catch (error) { + console.error('[ResumableSSE] Unexpected error in startGeneration:', error); + setIsSubmitting(false); + return null; } - setIsSubmitting(false); - return null; }, [clearStepMaps, errorHandler, setIsSubmitting], ); useEffect(() => { if (!submission || Object.keys(submission).length === 0) { - console.log('[ResumableSSE] No submission, cleaning up'); // Clear reconnect timeout if submission is cleared if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); @@ -631,48 +626,68 @@ export default function useResumableSSE( return; } + // Validate submission has required properties + if (!submission.userMessage || !submission.endpointOption) { + return; + } + const resumeStreamId = (submission as TSubmission & { resumeStreamId?: string }).resumeStreamId; - console.log('[ResumableSSE] Effect triggered', { - conversationId: submission.conversation?.conversationId, - hasResumeStreamId: !!resumeStreamId, - resumeStreamId, - userMessageId: submission.userMessage?.messageId, - }); submissionRef.current = submission; const initStream = async () => { + // Check if submission was cleared while we were waiting + if (!submissionRef.current || submissionRef.current !== submission) { + return; + } + setIsSubmitting(true); setShowStopButton(true); if (resumeStreamId) { // Resume: just subscribe to existing stream, don't start new generation - console.log('[ResumableSSE] Resuming existing stream:', resumeStreamId); setStreamId(resumeStreamId); // Optimistically add to active jobs (in case it's not already there) addActiveJob(resumeStreamId); subscribeToStream(resumeStreamId, submission, true); // isResume=true } else { // New generation: start and then subscribe - console.log('[ResumableSSE] Starting NEW generation'); - const newStreamId = await startGeneration(submission); - if (newStreamId) { - setStreamId(newStreamId); - // Optimistically add to active jobs - addActiveJob(newStreamId); - // Queue title generation if this is a new conversation (first message) - const isNewConvo = submission.userMessage?.parentMessageId === Constants.NO_PARENT; - if (isNewConvo) { - queueTitleGeneration(newStreamId); + // Check if submission is still valid before calling startGeneration + if (!submission || !submission.userMessage || !submission.endpointOption) { + setIsSubmitting(false); + setShowStopButton(false); + return; + } + + try { + const newStreamId = await startGeneration(submission); + + if (newStreamId) { + setStreamId(newStreamId); + // Optimistically add to active jobs + addActiveJob(newStreamId); + // Queue title generation if this is a new conversation (first message) + const isNewConvo = submission.userMessage?.parentMessageId === Constants.NO_PARENT; + if (isNewConvo) { + queueTitleGeneration(newStreamId); + } + subscribeToStream(newStreamId, submission); + } else { + console.error('[ResumableSSE] Failed to get streamId from startGeneration'); + setIsSubmitting(false); + setShowStopButton(false); } - subscribeToStream(newStreamId, submission); - } else { - console.error('[ResumableSSE] Failed to get streamId from startGeneration'); + } catch (error) { + console.error('[ResumableSSE] Error in startGeneration call:', error); + setIsSubmitting(false); + setShowStopButton(false); } } }; - initStream(); + initStream().catch((error) => { + console.error('[ResumableSSE] Error in initStream:', error); + }); return () => { console.log('[ResumableSSE] Cleanup - closing SSE, resetting UI state'); diff --git a/packages/data-provider/src/createPayload.ts b/packages/data-provider/src/createPayload.ts index 3056a7021b99..2d2a89e709a7 100644 --- a/packages/data-provider/src/createPayload.ts +++ b/packages/data-provider/src/createPayload.ts @@ -15,7 +15,98 @@ export default function createPayload(submission: t.TSubmission) { ephemeralAgent, endpointOption, } = submission; - const { conversationId } = s.tConvoUpdateSchema.parse(conversation); + + // Normalize conversation object for Solid storage compatibility + // Solid storage may return messages as objects instead of IDs, and null values instead of undefined + // Wrap in try-catch to ensure we can always fall back if normalization fails + let normalizedConversation: Record; + try { + normalizedConversation = { ...conversation }; + + // Convert messages array from objects to IDs if needed + if (Array.isArray(normalizedConversation.messages)) { + normalizedConversation.messages = normalizedConversation.messages.map((msg: unknown) => { + if (typeof msg === 'string') { + return msg; + } + if (typeof msg === 'object' && msg !== null && 'messageId' in msg) { + return (msg as { messageId: string }).messageId; + } + return String(msg); + }) as string[]; + } + } catch (normalizationError) { + // If normalization fails, just use the original conversation and let the fallback handle it + normalizedConversation = conversation as Record; + } + + // Convert ALL null values to undefined for optional fields that expect specific types + // This is necessary because Zod schemas use .optional() which means undefined, not null + const nullableNumberFields = [ + 'topP', 'top_p', 'topK', 'frequency_penalty', 'presence_penalty', + 'temperature', 'maxOutputTokens', 'maxContextTokens', 'max_tokens', + 'thinkingBudget', 'fileTokenLimit' + ]; + + const nullableStringFields = [ + 'assistant_id', 'agent_id', 'model', 'modelLabel', 'userLabel', + 'promptPrefix', 'system', 'context', 'title', 'conversationId', + 'endpoint', 'endpointType', 'parentMessageId', 'artifacts', + 'imageDetail', 'reasoning_effort', 'reasoning_summary', 'verbosity' + ]; + + const nullableBooleanFields = [ + 'isArchived', 'promptCache', 'thinking', 'stream', 'resendFiles' + ]; + + // Convert null to undefined for all nullable fields + for (const field of nullableNumberFields) { + if (field in normalizedConversation && normalizedConversation[field] === null) { + normalizedConversation[field] = undefined; + } + } + + for (const field of nullableStringFields) { + if (field in normalizedConversation && normalizedConversation[field] === null) { + normalizedConversation[field] = undefined; + } + } + + for (const field of nullableBooleanFields) { + if (field in normalizedConversation && normalizedConversation[field] === null) { + normalizedConversation[field] = undefined; + } + } + + // Handle createdAt and updatedAt - convert Date objects to strings, or null to undefined + if ('createdAt' in normalizedConversation) { + if (normalizedConversation.createdAt === null) { + normalizedConversation.createdAt = undefined; + } else if (normalizedConversation.createdAt instanceof Date) { + normalizedConversation.createdAt = normalizedConversation.createdAt.toISOString(); + } + } + + if ('updatedAt' in normalizedConversation) { + if (normalizedConversation.updatedAt === null) { + normalizedConversation.updatedAt = undefined; + } else if (normalizedConversation.updatedAt instanceof Date) { + normalizedConversation.updatedAt = normalizedConversation.updatedAt.toISOString(); + } + } + + // Use safeParse to handle validation errors gracefully + // safeParse never throws, it always returns { success: true, data } or { success: false, error } + const parseResult = s.tConvoUpdateSchema.safeParse(normalizedConversation); + + if (!parseResult.success) { + // Fallback: try to extract conversationId directly if parse fails + // For new conversations, conversationId can be null or 'new', which is valid + const conversationIdRaw = conversation?.conversationId ?? normalizedConversation?.conversationId ?? null; + + // Allow null or 'new' for new conversations + if (conversationIdRaw === null || conversationIdRaw === 'new') { + const conversationId = conversationIdRaw; const { endpoint: _e, endpointType } = endpointOption as { endpoint: s.EModelEndpoint; endpointType?: s.EModelEndpoint; @@ -29,6 +120,122 @@ export default function createPayload(submission: t.TSubmission) { (isEdited ? '/modify' : ''); } + const payload: t.TPayload = { + ...userMessage, + ...endpointOption, + endpoint, + addedConvo, + isTemporary, + isRegenerate, + editedContent, + conversationId: conversationId as string | null, + isContinued: !!(isEdited && isContinued), + ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent, + }; + + return { server, payload }; + } + + // For existing conversations, conversationId must be a valid string + if (typeof conversationIdRaw !== 'string') { + throw new Error('Invalid conversation: conversationId must be a string'); + } + + const conversationId: string = conversationIdRaw; + + // Extract important fields from the conversation object and endpointOption + // This is especially important for Solid storage where the conversation might have the model + // Check endpointOption first, then conversation, as endpointOption takes precedence + const conversationFields = { + model: (endpointOption as { model?: string })?.model ?? conversation?.model ?? normalizedConversation?.model, + modelLabel: (endpointOption as { modelLabel?: string })?.modelLabel ?? conversation?.modelLabel ?? normalizedConversation?.modelLabel, + temperature: (endpointOption as { temperature?: number })?.temperature ?? conversation?.temperature ?? normalizedConversation?.temperature, + topP: (endpointOption as { topP?: number })?.topP ?? conversation?.topP ?? normalizedConversation?.topP, + top_p: (endpointOption as { top_p?: number })?.top_p ?? conversation?.top_p ?? normalizedConversation?.top_p, + frequency_penalty: (endpointOption as { frequency_penalty?: number })?.frequency_penalty ?? conversation?.frequency_penalty ?? normalizedConversation?.frequency_penalty, + presence_penalty: (endpointOption as { presence_penalty?: number })?.presence_penalty ?? conversation?.presence_penalty ?? normalizedConversation?.presence_penalty, + maxOutputTokens: (endpointOption as { maxOutputTokens?: number })?.maxOutputTokens ?? conversation?.maxOutputTokens ?? normalizedConversation?.maxOutputTokens, + max_tokens: (endpointOption as { max_tokens?: number })?.max_tokens ?? conversation?.max_tokens ?? normalizedConversation?.max_tokens, + system: (endpointOption as { system?: string })?.system ?? conversation?.system ?? normalizedConversation?.system, + promptPrefix: (endpointOption as { promptPrefix?: string })?.promptPrefix ?? conversation?.promptPrefix ?? normalizedConversation?.promptPrefix, + }; + + // Filter out null/undefined values + const validConversationFields = Object.fromEntries( + Object.entries(conversationFields).filter(([_, value]) => value !== null && value !== undefined) + ); + + // Continue with the fallback conversationId + const { endpoint: _e, endpointType } = endpointOption as { + endpoint: s.EModelEndpoint; + endpointType?: s.EModelEndpoint; + }; + + const endpoint = _e as s.EModelEndpoint; + let server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`; + if (s.isAssistantsEndpoint(endpoint)) { + server = + EndpointURLs[(endpointType ?? endpoint) as 'assistants' | 'azureAssistants'] + + (isEdited ? '/modify' : ''); + } + + // Ensure model is explicitly included - check all sources + const finalModel = (endpointOption as { model?: string })?.model || + validConversationFields.model || + conversation?.model || + normalizedConversation?.model; + + // Extract resendFiles - default to true for agents endpoints if not specified + const resendFilesFromOption = (endpointOption as { resendFiles?: boolean })?.resendFiles; + const resendFilesFromConversation = typeof conversation?.resendFiles === 'boolean' ? conversation.resendFiles : undefined; + const resendFilesFromNormalized = typeof normalizedConversation?.resendFiles === 'boolean' ? normalizedConversation.resendFiles as boolean : undefined; + const resendFiles: boolean = resendFilesFromOption ?? resendFilesFromConversation ?? resendFilesFromNormalized ?? (s.isAgentsEndpoint(endpoint) ? true : false); + + const payload: t.TPayload = { + ...userMessage, + ...endpointOption, + ...validConversationFields, // Merge conversation fields, with endpointOption taking precedence + endpoint, + addedConvo, + isTemporary, + isRegenerate, + editedContent, + conversationId, + isContinued: !!(isEdited && isContinued), + ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent, + // Explicitly include model at top level - this ensures it's always in the payload if available + ...(finalModel ? { model: finalModel as string } : {}), + // Explicitly include resendFiles - always true for agents endpoints if not specified + ...(s.isAgentsEndpoint(endpoint) ? { resendFiles } : {}), + }; + + return { server, payload }; + } + const { conversationId } = parseResult.data; + const { endpoint: _e, endpointType } = endpointOption as { + endpoint: s.EModelEndpoint; + endpointType?: s.EModelEndpoint; + }; + + const endpoint = _e as s.EModelEndpoint; + let server = `${EndpointURLs[s.EModelEndpoint.agents]}/${endpoint}`; + if (s.isAssistantsEndpoint(endpoint)) { + server = + EndpointURLs[(endpointType ?? endpoint) as 'assistants' | 'azureAssistants'] + + (isEdited ? '/modify' : ''); + } + + // Extract model from conversation if not in endpointOption (for Solid storage compatibility) + const modelFromConversation = conversation?.model ?? normalizedConversation?.model; + const modelFromEndpointOption = (endpointOption as { model?: string })?.model; + const finalModel = (modelFromEndpointOption || modelFromConversation) as string | undefined; + + // Extract resendFiles - default to true for agents endpoints if not specified + const resendFilesFromOption = (endpointOption as { resendFiles?: boolean })?.resendFiles; + const resendFilesFromConversation = typeof conversation?.resendFiles === 'boolean' ? conversation.resendFiles : undefined; + const resendFilesFromNormalized = typeof normalizedConversation?.resendFiles === 'boolean' ? normalizedConversation.resendFiles as boolean : undefined; + const resendFiles: boolean = resendFilesFromOption ?? resendFilesFromConversation ?? resendFilesFromNormalized ?? (s.isAgentsEndpoint(endpoint) ? true : false); + const payload: t.TPayload = { ...userMessage, ...endpointOption, @@ -40,6 +247,10 @@ export default function createPayload(submission: t.TSubmission) { conversationId, isContinued: !!(isEdited && isContinued), ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent, + // Explicitly include model at top level for buildEndpointOption to find it + ...(finalModel ? { model: finalModel } : {}), + // Explicitly include resendFiles - always true for agents endpoints if not specified + ...(s.isAgentsEndpoint(endpoint) ? { resendFiles } : {}), }; return { server, payload }; From 4c4e8e074a0a64288609dac888ae297780f4da19 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 3 Feb 2026 12:05:50 +0000 Subject: [PATCH 12/94] fix(solid): Save conversation titles to Solid Pod and preserve existing data on updates - Added req.user?.openidId check in saveConvo to ensure Solid storage is only used for Solid users - Fix saveConvoToSolid to merge updates with existing conversation data instead of overwriting - Preserve all conversation fields when only updating specific fields (e.g., title) - Fixes issue where conversation titles were not being saved to Solid Pod and where titles would revert to "untitled" after page refresh --- api/models/Conversation.js | 10 +++- api/server/services/SolidStorage.js | 91 ++++++++++++++++------------- 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/api/models/Conversation.js b/api/models/Conversation.js index fd3b5ccea82d..931e81565588 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -142,8 +142,8 @@ module.exports = { * @returns {Promise} The conversation object. */ saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => { - // Use Solid storage if enabled - if (USE_SOLID_STORAGE) { + // Use Solid storage if enabled and user is a Solid user + if (USE_SOLID_STORAGE && req && req.user?.openidId) { try { if (metadata?.context) { logger.debug(`[saveConvo] ${metadata.context}`); @@ -177,7 +177,11 @@ module.exports = { if (metadata && metadata?.context) { logger.info(`[saveConvo] ${metadata.context}`); } - return { message: 'Error saving conversation' }; + // Fall through to MongoDB if Solid save fails + logger.warn('[saveConvo] Falling back to MongoDB after Solid save failure', { + conversationId, + error: error.message, + }); } } diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js index 33f5c49b8db5..306679a29f4b 100644 --- a/api/server/services/SolidStorage.js +++ b/api/server/services/SolidStorage.js @@ -1221,6 +1221,29 @@ async function saveConvoToSolid(req, convoData, metadata) { // Get conversation file path const conversationPath = getConversationPath(podUrl, finalConversationId); + // Check if conversation already exists and load it to preserve existing data + let existingConversation = null; + try { + const existingFile = await getFile(conversationPath, { fetch: authenticatedFetch }); + const existingFileText = await existingFile.text(); + existingConversation = JSON.parse(existingFileText); + logger.debug('[SolidStorage] Loaded existing conversation for merge', { + conversationId: finalConversationId, + hasTitle: !!existingConversation.title, + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + logger.debug('[SolidStorage] Conversation does not exist yet, will create new', { + conversationId: finalConversationId, + }); + } else { + logger.warn('[SolidStorage] Error loading existing conversation, will create new', { + conversationId: finalConversationId, + error: error.message, + }); + } + } + // Get messages for this conversation (just IDs for reference) // Note: We call getMessagesFromSolid directly since it's in the same module const messages = await getMessagesFromSolid(req, finalConversationId); @@ -1229,9 +1252,16 @@ async function saveConvoToSolid(req, convoData, metadata) { createdAt: msg.createdAt, })); + // Start with existing conversation data if available, otherwise use defaults + const baseConversation = existingConversation || { + conversationId: finalConversationId, + user: req.user.id, + createdAt: new Date().toISOString(), + }; + // If model or endpoint are missing, try to extract them from messages - let finalModel = convoData.model; - let finalEndpoint = convoData.endpoint; + let finalModel = convoData.model ?? baseConversation.model; + let finalEndpoint = convoData.endpoint ?? baseConversation.endpoint; if (!finalModel || !finalEndpoint) { // Find the first message with a model (usually the AI response) @@ -1256,26 +1286,28 @@ async function saveConvoToSolid(req, convoData, metadata) { } } - // Prepare conversation object + // Merge existing conversation with updates from convoData + // convoData takes precedence for fields that are explicitly provided const conversationToSave = { conversationId: finalConversationId, user: req.user.id, - title: convoData.title || null, - endpoint: finalEndpoint || null, - model: finalModel || null, - agent_id: convoData.agent_id || null, - assistant_id: convoData.assistant_id || null, - spec: convoData.spec || null, - iconURL: convoData.iconURL || null, + // Title: use new value if provided, otherwise preserve existing, otherwise null + title: convoData.title !== undefined ? (convoData.title || null) : (baseConversation.title || null), + endpoint: finalEndpoint || baseConversation.endpoint || null, + model: finalModel || baseConversation.model || null, + agent_id: convoData.agent_id !== undefined ? (convoData.agent_id || null) : (baseConversation.agent_id || null), + assistant_id: convoData.assistant_id !== undefined ? (convoData.assistant_id || null) : (baseConversation.assistant_id || null), + spec: convoData.spec !== undefined ? (convoData.spec || null) : (baseConversation.spec || null), + iconURL: convoData.iconURL !== undefined ? (convoData.iconURL || null) : (baseConversation.iconURL || null), messages: messageRefs, - files: convoData.files || [], - promptPrefix: convoData.promptPrefix || null, - temperature: convoData.temperature || null, - topP: convoData.topP || null, - presence_penalty: convoData.presence_penalty || null, - frequency_penalty: convoData.frequency_penalty || null, - expiredAt: convoData.expiredAt || null, - createdAt: convoData.createdAt || new Date().toISOString(), + files: convoData.files !== undefined ? (convoData.files || []) : (baseConversation.files || []), + promptPrefix: convoData.promptPrefix !== undefined ? (convoData.promptPrefix || null) : (baseConversation.promptPrefix || null), + temperature: convoData.temperature !== undefined ? (convoData.temperature || null) : (baseConversation.temperature || null), + topP: convoData.topP !== undefined ? (convoData.topP || null) : (baseConversation.topP || null), + presence_penalty: convoData.presence_penalty !== undefined ? (convoData.presence_penalty || null) : (baseConversation.presence_penalty || null), + frequency_penalty: convoData.frequency_penalty !== undefined ? (convoData.frequency_penalty || null) : (baseConversation.frequency_penalty || null), + expiredAt: convoData.expiredAt !== undefined ? (convoData.expiredAt || null) : (baseConversation.expiredAt || null), + createdAt: baseConversation.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -1289,27 +1321,8 @@ async function saveConvoToSolid(req, convoData, metadata) { messageCount: messageRefs.length, }); - // Check if conversation already exists - let conversationExists = false; - try { - await getFile(conversationPath, { fetch: authenticatedFetch }); - conversationExists = true; - logger.debug('[SolidStorage] Conversation file already exists, will overwrite', { - conversationPath, - }); - } catch (error) { - if (error.status === 404 || error.message?.includes('404')) { - conversationExists = false; - logger.debug('[SolidStorage] Conversation file does not exist, will create', { - conversationPath, - }); - } else { - logger.warn('[SolidStorage] Error checking if conversation exists, will try to save anyway', { - conversationPath, - error: error.message, - }); - } - } + // Determine if conversation exists (we already loaded it above if it exists) + const conversationExists = existingConversation !== null; // If conversationId changed, delete old file if (convoData.newConversationId && convoData.conversationId !== convoData.newConversationId) { From 7c091a06e00d66378a179fafbb5647d7f592c8d0 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 3 Feb 2026 12:08:27 +0000 Subject: [PATCH 13/94] update progress documentation --- SOLID_INTEGRATION_PROGRESS.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/SOLID_INTEGRATION_PROGRESS.md b/SOLID_INTEGRATION_PROGRESS.md index 2ed715df7f7f..4f2b9f77926c 100644 --- a/SOLID_INTEGRATION_PROGRESS.md +++ b/SOLID_INTEGRATION_PROGRESS.md @@ -88,6 +88,14 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - Model information is correctly extracted and passed through the request chain - All required payload fields are included for agents endpoints +### 11. Title Persistence +- **Status**: Complete +- **Details**: + - Conversation titles are properly saved to Solid Pod when generated + - Titles persist correctly after page refresh + - `saveConvo` correctly identifies Solid users before saving to Solid Pod + - `saveConvoToSolid` merges updates with existing conversation data to prevent data loss + ## Current Status ### Working Features @@ -99,15 +107,10 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u 6. **Conversation Continuation**: Users can send multiple messages in the same conversation 7. **Model Persistence**: Model and endpoint information is correctly stored and retrieved 8. **Access Control**: Conversation access validation works for Solid storage users +9. **Title Persistence**: Conversation titles are saved to Solid Pod and persist after page refresh ### Known Issues 🔧 -1. **Title Retrieval on Page Refresh** - - **Issue**: When the page is refreshed, conversation titles revert to "untitled" instead of loading the saved title from Solid Pod - - **Impact**: Users lose visual context of their conversations after refresh - - **Status**: To be fixed - - **Priority**: High - -2. **Conversation Menu Options** +1. **Conversation Menu Options** - **Issue**: Share, Rename, Duplicate, Archive, and Delete options need to be implemented for Solid storage - **Impact**: Users cannot manage their conversations stored in Solid Pod - **Status**: Not yet implemented @@ -148,11 +151,6 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - Implement Delete functionality (remove from Solid Pod) - Ensure all operations work seamlessly with Solid storage backend -2. **Fix Title Retrieval on Page Refresh** - - Issue: When page is refreshed, conversation titles revert to "untitled" instead of loading from Solid Pod - - Root Cause: Title not being properly retrieved/loaded from Solid storage on initial page load - - Priority: High (affects user experience) - ## Files Modified - `api/server/services/SolidStorage.js` (NEW) - Core Solid Pod operations @@ -166,11 +164,14 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - `api/server/middleware/buildEndpointOption.js` - Added model extraction from Solid storage when missing - `api/server/services/Endpoints/agents/initialize.js` - Enhanced model discovery from request body and endpointOption - `packages/data-provider/src/createPayload.ts` - Added normalization for Solid conversation objects and fallback handling +- `api/models/Conversation.js` - Fixed `saveConvo` to check for Solid users before saving to Solid Pod +- `api/server/services/SolidStorage.js` - Enhanced `saveConvoToSolid` to merge updates with existing conversation data ## Dependencies Added - `@inrupt/solid-client@^1.30.2` - Solid Pod client library --- -**Report Date**: February 2, 2026 +**Report Date**: February 3, 2026 + From 673df961d94e9887d87f63d173131ba79f38d8f7 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 3 Feb 2026 15:00:53 +0000 Subject: [PATCH 14/94] fix(solid): Enable conversation duplication for Solid storage users - Add req parameter to duplicateConversation function for Solid storage support - Implement individual save operations for Solid users (saveConvo/saveMessage) - Maintain parent-child message relationships when duplicating - Use getMessagesFromSolid to retrieve messages from Solid Pod - Preserve existing bulk save approach for MongoDB users --- api/server/routes/convos.js | 1 + api/server/utils/import/fork.js | 109 ++++++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 7 deletions(-) diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 3081a7904a17..a3a11c9cd1a5 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -325,6 +325,7 @@ router.post('/duplicate', async (req, res) => { userId: req.user.id, conversationId, title, + req, // Pass req for Solid storage support }); res.status(201).json(result); } catch (error) { diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index c4ce8cb5d4b0..cbccb5042bab 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -358,26 +358,121 @@ function splitAtTargetLevel(messages, targetMessageId) { * @param {object} params - The parameters for duplicating the conversation. * @param {string} params.userId - The ID of the user duplicating the conversation. * @param {string} params.conversationId - The ID of the conversation to duplicate. + * @param {object} [params.req] - Optional Express request object for Solid storage support. * @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages. */ -async function duplicateConversation({ userId, conversationId }) { +async function duplicateConversation({ userId, conversationId, req }) { + const { isEnabled } = require('@librechat/api'); + const USE_SOLID_STORAGE = isEnabled(process.env.USE_SOLID_STORAGE); + const isSolidUser = USE_SOLID_STORAGE && req && req.user?.openidId; + // Get original conversation - const originalConvo = await getConvo(userId, conversationId); + const originalConvo = await getConvo(userId, conversationId, req); if (!originalConvo) { throw new Error('Conversation not found'); } // Get original messages - const originalMessages = await getMessages({ - user: userId, - conversationId, - }); + let originalMessages; + if (isSolidUser) { + const { getMessagesFromSolid } = require('~/server/services/SolidStorage'); + originalMessages = await getMessagesFromSolid(req, conversationId); + } else { + originalMessages = await getMessages({ + user: userId, + conversationId, + }); + } + + if (!originalMessages || originalMessages.length === 0) { + throw new Error('No messages found in conversation'); + } const messagesToClone = getMessagesUpToTargetLevel( originalMessages, originalMessages[originalMessages.length - 1].messageId, ); + // For Solid users, save individually to support Solid storage + if (isSolidUser) { + const { saveConvo } = require('~/models/Conversation'); + const { saveMessage } = require('~/models/Message'); + const { v4: uuidv4 } = require('uuid'); + const newConversationId = uuidv4(); + const now = new Date(); + + // Create ID mapping for parent message relationships + const idMapping = new Map(); + const sortedMessages = [...messagesToClone].sort((a, b) => { + if (a.parentMessageId === Constants.NO_PARENT) { + return -1; + } + if (b.parentMessageId === Constants.NO_PARENT) { + return 1; + } + return 0; + }); + + // First pass: create ID mapping + for (const message of sortedMessages) { + const newMessageId = uuidv4(); + idMapping.set(message.messageId, newMessageId); + } + + // Create new conversation + const newConvo = { + ...originalConvo, + conversationId: newConversationId, + title: originalConvo.title, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; + delete newConvo._id; + + // Save conversation + await saveConvo(req, newConvo, { context: 'duplicateConversation' }); + + // Save messages individually with proper parent ID mapping + const savedMessages = []; + for (const message of sortedMessages) { + const newMessageId = idMapping.get(message.messageId); + const newParentMessageId = + message.parentMessageId === Constants.NO_PARENT + ? Constants.NO_PARENT + : idMapping.get(message.parentMessageId) || Constants.NO_PARENT; + + const newMessage = { + ...message, + messageId: newMessageId, + parentMessageId: newParentMessageId, + conversationId: newConversationId, + createdAt: message.createdAt || now.toISOString(), + updatedAt: message.updatedAt || now.toISOString(), + }; + delete newMessage._id; + + const savedMessage = await saveMessage(req, newMessage, { + context: 'duplicateConversation', + }); + savedMessages.push(savedMessage); + } + + logger.debug( + `user: ${userId} | New conversation "${originalConvo.title}" duplicated from conversation ID ${conversationId}`, + ); + + // Get the saved conversation and messages + const { getMessagesFromSolid } = require('~/server/services/SolidStorage'); + const conversation = await getConvo(userId, newConversationId, req); + const messages = await getMessagesFromSolid(req, newConversationId); + + return { + conversation, + messages, + }; + } + + // For MongoDB users, use the existing bulk save approach const importBatchBuilder = createImportBatchBuilder(userId); importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); @@ -393,7 +488,7 @@ async function duplicateConversation({ userId, conversationId }) { `user: ${userId} | New conversation "${originalConvo.title}" duplicated from conversation ID ${conversationId}`, ); - const conversation = await getConvo(userId, result.conversation.conversationId); + const conversation = await getConvo(userId, result.conversation.conversationId, req); const messages = await getMessages({ user: userId, conversationId: conversation.conversationId, From f539c850afd750cc508496e2ed9976dfd8bd93f0 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 3 Feb 2026 15:05:24 +0000 Subject: [PATCH 15/94] update progress document --- SOLID_INTEGRATION_PROGRESS.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/SOLID_INTEGRATION_PROGRESS.md b/SOLID_INTEGRATION_PROGRESS.md index 4f2b9f77926c..ebc734b3305c 100644 --- a/SOLID_INTEGRATION_PROGRESS.md +++ b/SOLID_INTEGRATION_PROGRESS.md @@ -96,6 +96,15 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - `saveConvo` correctly identifies Solid users before saving to Solid Pod - `saveConvoToSolid` merges updates with existing conversation data to prevent data loss +### 12. Conversation Management Operations +- **Status**: Partially Complete +- **Details**: + - **Rename**: Working - Users can rename conversations stored in Solid Pod + - **Duplicate**: Working - Users can duplicate conversations and all messages from Solid Pod + - **Share**: Not yet implemented + - **Archive**: Not yet implemented + - **Delete**: Not yet implemented + ## Current Status ### Working Features @@ -108,11 +117,13 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u 7. **Model Persistence**: Model and endpoint information is correctly stored and retrieved 8. **Access Control**: Conversation access validation works for Solid storage users 9. **Title Persistence**: Conversation titles are saved to Solid Pod and persist after page refresh +10. **Conversation Rename**: Users can rename conversations stored in Solid Pod +11. **Conversation Duplicate**: Users can duplicate conversations and all their messages from Solid Pod ### Known Issues 🔧 1. **Conversation Menu Options** - - **Issue**: Share, Rename, Duplicate, Archive, and Delete options need to be implemented for Solid storage - - **Impact**: Users cannot manage their conversations stored in Solid Pod + - **Issue**: Share, Archive, and Delete options need to be implemented for Solid storage + - **Impact**: Users cannot share, archive, or delete conversations stored in Solid Pod - **Status**: Not yet implemented - **Priority**: High @@ -145,8 +156,6 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u 1. **Conversation Menu Options Implementation** - Implement Share functionality for Solid storage conversations - - Implement Rename functionality (update conversation title in Solid Pod) - - Implement Duplicate functionality (create copy in Solid Pod) - Implement Archive functionality (mark as archived in Solid Pod) - Implement Delete functionality (remove from Solid Pod) - Ensure all operations work seamlessly with Solid storage backend @@ -166,6 +175,8 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - `packages/data-provider/src/createPayload.ts` - Added normalization for Solid conversation objects and fallback handling - `api/models/Conversation.js` - Fixed `saveConvo` to check for Solid users before saving to Solid Pod - `api/server/services/SolidStorage.js` - Enhanced `saveConvoToSolid` to merge updates with existing conversation data +- `api/server/utils/import/fork.js` - Added Solid storage support to `duplicateConversation` function +- `api/server/routes/convos.js` - Updated duplicate endpoint to pass `req` for Solid storage support ## Dependencies Added - `@inrupt/solid-client@^1.30.2` - Solid Pod client library From 5adb126e8b7df04901e1e38f0a4c1ac4fc179038 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 3 Feb 2026 17:27:25 +0000 Subject: [PATCH 16/94] fix(solid): Enable conversation deletion for Solid storage users - Added req parameter to deleteConvos function for Solid storage support - Implemented Solid storage deletion using deleteConvosFromSolid - Delete all associated messages when deleting conversations - Handle both single conversation and bulk deletion scenarios - Maintained MongoDB compatibility for non-Solid users --- api/models/Conversation.js | 70 ++++++++++++++++++++++++++++- api/server/routes/convos.js | 4 +- api/server/services/SolidStorage.js | 40 ++++++++++++++++- 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 931e81565588..8e0b6f6837d5 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -459,6 +459,7 @@ module.exports = { * @function * @param {string|ObjectId} user - The user's ID. * @param {Object} filter - Additional filter criteria for the conversations to be deleted. + * @param {Object} [req] - Optional Express request object for Solid storage support. * @returns {Promise<{ n: number, ok: number, deletedCount: number, messages: { n: number, ok: number, deletedCount: number } }>} * An object containing the count of deleted conversations and associated messages. * @throws {Error} Throws an error if there's an issue with the database operations. @@ -469,7 +470,74 @@ module.exports = { * const result = await deleteConvos(user, filter); * logger.error(result); // { n: 5, ok: 1, deletedCount: 5, messages: { n: 10, ok: 1, deletedCount: 10 } } */ - deleteConvos: async (user, filter) => { + deleteConvos: async (user, filter, req = null) => { + // Use Solid storage if enabled and req is provided with openidId + if (USE_SOLID_STORAGE && req && req.user?.openidId) { + try { + const { deleteConvosFromSolid } = require('~/server/services/SolidStorage'); + const { getConvosByCursorFromSolid } = require('~/server/services/SolidStorage'); + + let conversationIds = []; + + // If conversationId is specified in filter, use it directly + if (filter.conversationId) { + conversationIds = [filter.conversationId]; + } else { + // Otherwise, get all conversations matching the filter from Solid Pod + // For now, we'll get all conversations and filter in memory + // This is not ideal for large datasets, but Solid Pod doesn't support complex queries + const allConversations = await getConvosByCursorFromSolid(req, { + limit: 10000, // Large limit to get all conversations + isArchived: filter.isArchived, + }); + + // Filter conversations based on the filter criteria + let filteredConversations = allConversations.conversations; + + if (filter.conversationId) { + filteredConversations = filteredConversations.filter( + (c) => c.conversationId === filter.conversationId + ); + } + + if (filter.endpoint) { + filteredConversations = filteredConversations.filter( + (c) => c.endpoint === filter.endpoint + ); + } + + conversationIds = filteredConversations.map((c) => c.conversationId); + } + + if (!conversationIds.length) { + throw new Error('Conversation not found or already deleted.'); + } + + const deletedCount = await deleteConvosFromSolid(req, conversationIds); + + // Return format similar to MongoDB deleteMany result + return { + n: deletedCount, + ok: deletedCount > 0 ? 1 : 0, + deletedCount, + messages: { + n: deletedCount, // Messages are deleted as part of deleteConvosFromSolid + ok: deletedCount > 0 ? 1 : 0, + deletedCount, + }, + }; + } catch (error) { + logger.error('[deleteConvos] Error deleting conversations from Solid Pod', { + error: error.message, + stack: error.stack, + filter, + userId: user, + }); + throw error; + } + } + + // MongoDB storage (original code) try { const userFilter = { ...filter, user }; const conversations = await Conversation.find(userFilter).select('conversationId'); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index a3a11c9cd1a5..0e50e86a7486 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -165,7 +165,7 @@ router.delete('/', async (req, res) => { } try { - const dbResponse = await deleteConvos(req.user.id, filter); + const dbResponse = await deleteConvos(req.user.id, filter, req); // Pass req for Solid storage support if (filter.conversationId) { await deleteToolCalls(req.user.id, filter.conversationId); await deleteConvoSharedLink(req.user.id, filter.conversationId); @@ -179,7 +179,7 @@ router.delete('/', async (req, res) => { router.delete('/all', async (req, res) => { try { - const dbResponse = await deleteConvos(req.user.id, {}); + const dbResponse = await deleteConvos(req.user.id, {}, req); // Pass req for Solid storage support await deleteToolCalls(req.user.id); await deleteAllSharedLinks(req.user.id); res.status(201).json(dbResponse); diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js index 306679a29f4b..94ba63ae1b56 100644 --- a/api/server/services/SolidStorage.js +++ b/api/server/services/SolidStorage.js @@ -1918,14 +1918,50 @@ async function deleteConvosFromSolid(req, conversationIds) { const messagesDeleted = await deleteMessagesFromSolid(req, { conversationId, }); - logger.debug('[SolidStorage] Messages deleted for conversation', { + + logger.info('[SolidStorage] Messages deleted for conversation', { conversationId, messagesDeleted, }); + + if (messagesDeleted === 0) { + logger.warn('[SolidStorage] No messages were deleted - this may indicate an issue', { + conversationId, + }); + } + + // Also try to delete the messages container directory if it's empty + // This is optional - some Solid servers handle empty containers automatically + try { + const messagesContainerPath = getMessagesContainerPath(podUrl, conversationId); + // Try to delete the container - this may fail if it's not empty or not allowed + // We'll just log and continue if it fails + try { + await deleteFile(messagesContainerPath, { fetch: authenticatedFetch }); + logger.debug('[SolidStorage] Messages container directory deleted', { + conversationId, + messagesContainerPath, + }); + } catch (containerError) { + // It's okay if we can't delete the container - the files are already deleted + logger.debug('[SolidStorage] Could not delete messages container (may not be empty or not allowed)', { + conversationId, + messagesContainerPath, + error: containerError.message, + }); + } + } catch (containerPathError) { + // Ignore errors when trying to delete the container + logger.debug('[SolidStorage] Error getting messages container path for deletion', { + conversationId, + error: containerPathError.message, + }); + } } catch (error) { - logger.warn('[SolidStorage] Error deleting messages for conversation, continuing', { + logger.error('[SolidStorage] Error deleting messages for conversation - THIS IS A PROBLEM', { conversationId, error: error.message, + stack: error.stack, }); // Continue with conversation deletion even if messages fail } From 399b46c3dbed27a90e387891b1f7c0937f215138 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Tue, 3 Feb 2026 17:30:50 +0000 Subject: [PATCH 17/94] Update progress document --- SOLID_INTEGRATION_PROGRESS.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/SOLID_INTEGRATION_PROGRESS.md b/SOLID_INTEGRATION_PROGRESS.md index ebc734b3305c..afdc6ba3e2c6 100644 --- a/SOLID_INTEGRATION_PROGRESS.md +++ b/SOLID_INTEGRATION_PROGRESS.md @@ -101,9 +101,9 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - **Details**: - **Rename**: Working - Users can rename conversations stored in Solid Pod - **Duplicate**: Working - Users can duplicate conversations and all messages from Solid Pod + - **Delete**: Working - Users can delete conversations and all associated messages from Solid Pod - **Share**: Not yet implemented - **Archive**: Not yet implemented - - **Delete**: Not yet implemented ## Current Status @@ -119,11 +119,12 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u 9. **Title Persistence**: Conversation titles are saved to Solid Pod and persist after page refresh 10. **Conversation Rename**: Users can rename conversations stored in Solid Pod 11. **Conversation Duplicate**: Users can duplicate conversations and all their messages from Solid Pod +12. **Conversation Delete**: Users can delete conversations and all associated messages from Solid Pod ### Known Issues 🔧 1. **Conversation Menu Options** - - **Issue**: Share, Archive, and Delete options need to be implemented for Solid storage - - **Impact**: Users cannot share, archive, or delete conversations stored in Solid Pod + - **Issue**: Share and Archive options need to be implemented for Solid storage + - **Impact**: Users cannot share or archive conversations stored in Solid Pod - **Status**: Not yet implemented - **Priority**: High @@ -157,7 +158,6 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u 1. **Conversation Menu Options Implementation** - Implement Share functionality for Solid storage conversations - Implement Archive functionality (mark as archived in Solid Pod) - - Implement Delete functionality (remove from Solid Pod) - Ensure all operations work seamlessly with Solid storage backend @@ -173,10 +173,10 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - `api/server/middleware/buildEndpointOption.js` - Added model extraction from Solid storage when missing - `api/server/services/Endpoints/agents/initialize.js` - Enhanced model discovery from request body and endpointOption - `packages/data-provider/src/createPayload.ts` - Added normalization for Solid conversation objects and fallback handling -- `api/models/Conversation.js` - Fixed `saveConvo` to check for Solid users before saving to Solid Pod -- `api/server/services/SolidStorage.js` - Enhanced `saveConvoToSolid` to merge updates with existing conversation data +- `api/models/Conversation.js` - Fixed `saveConvo` to check for Solid users before saving to Solid Pod, added Solid storage support to `deleteConvos` +- `api/server/services/SolidStorage.js` - Enhanced `saveConvoToSolid` to merge updates with existing conversation data, improved message deletion logging - `api/server/utils/import/fork.js` - Added Solid storage support to `duplicateConversation` function -- `api/server/routes/convos.js` - Updated duplicate endpoint to pass `req` for Solid storage support +- `api/server/routes/convos.js` - Updated duplicate and delete endpoints to pass `req` for Solid storage support ## Dependencies Added - `@inrupt/solid-client@^1.30.2` - Solid Pod client library From 8f9cfbe1943222927ff01e236e66b28616bdc011 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 4 Feb 2026 11:06:14 +0000 Subject: [PATCH 18/94] feat: implement archive functionality for Solid storage users - Add support for archiving and unarchiving conversations stored in Solid Pods. - The isArchived field is now properly saved, retrieved, and filtered when fetching conversations for Solid-authenticated users. --- api/server/services/SolidStorage.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js index 94ba63ae1b56..ef8bebebe699 100644 --- a/api/server/services/SolidStorage.js +++ b/api/server/services/SolidStorage.js @@ -1307,6 +1307,7 @@ async function saveConvoToSolid(req, convoData, metadata) { presence_penalty: convoData.presence_penalty !== undefined ? (convoData.presence_penalty || null) : (baseConversation.presence_penalty || null), frequency_penalty: convoData.frequency_penalty !== undefined ? (convoData.frequency_penalty || null) : (baseConversation.frequency_penalty || null), expiredAt: convoData.expiredAt !== undefined ? (convoData.expiredAt || null) : (baseConversation.expiredAt || null), + isArchived: convoData.isArchived !== undefined ? (convoData.isArchived || false) : (baseConversation.isArchived || false), createdAt: baseConversation.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -1838,6 +1839,7 @@ async function getConvosByCursorFromSolid(req, options = {}) { assistant_id: convo.assistant_id, spec: convo.spec, iconURL: convo.iconURL, + isArchived: convo.isArchived || false, })); logger.info('[SolidStorage] Conversations retrieved successfully', { From 77f4c08e87af8a8eb7424767ff30f52d711cc058 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Wed, 4 Feb 2026 11:19:17 +0000 Subject: [PATCH 19/94] Update progress document --- SOLID_INTEGRATION_PROGRESS.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/SOLID_INTEGRATION_PROGRESS.md b/SOLID_INTEGRATION_PROGRESS.md index afdc6ba3e2c6..15c4c8c0d89a 100644 --- a/SOLID_INTEGRATION_PROGRESS.md +++ b/SOLID_INTEGRATION_PROGRESS.md @@ -102,8 +102,8 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - **Rename**: Working - Users can rename conversations stored in Solid Pod - **Duplicate**: Working - Users can duplicate conversations and all messages from Solid Pod - **Delete**: Working - Users can delete conversations and all associated messages from Solid Pod + - **Archive**: Working - Users can archive and unarchive conversations stored in Solid Pod - **Share**: Not yet implemented - - **Archive**: Not yet implemented ## Current Status @@ -120,13 +120,14 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u 10. **Conversation Rename**: Users can rename conversations stored in Solid Pod 11. **Conversation Duplicate**: Users can duplicate conversations and all their messages from Solid Pod 12. **Conversation Delete**: Users can delete conversations and all associated messages from Solid Pod +13. **Conversation Archive**: Users can archive and unarchive conversations stored in Solid Pod ### Known Issues 🔧 1. **Conversation Menu Options** - - **Issue**: Share and Archive options need to be implemented for Solid storage - - **Impact**: Users cannot share or archive conversations stored in Solid Pod + - **Issue**: Share option needs to be implemented for Solid storage + - **Impact**: Users cannot share conversations stored in Solid Pod - **Status**: Not yet implemented - - **Priority**: High + - **Priority**: Medium ## Technical Implementation @@ -157,7 +158,6 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u 1. **Conversation Menu Options Implementation** - Implement Share functionality for Solid storage conversations - - Implement Archive functionality (mark as archived in Solid Pod) - Ensure all operations work seamlessly with Solid storage backend @@ -177,6 +177,7 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - `api/server/services/SolidStorage.js` - Enhanced `saveConvoToSolid` to merge updates with existing conversation data, improved message deletion logging - `api/server/utils/import/fork.js` - Added Solid storage support to `duplicateConversation` function - `api/server/routes/convos.js` - Updated duplicate and delete endpoints to pass `req` for Solid storage support +- `api/server/services/SolidStorage.js` - Added `isArchived` field support in `saveConvoToSolid` and `getConvosByCursorFromSolid` for archive functionality ## Dependencies Added - `@inrupt/solid-client@^1.30.2` - Solid Pod client library From 43844477354baa8885a436c51e8c49d953dc4a3f Mon Sep 17 00:00:00 2001 From: Jesse Wright <63333554+jeswr@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:54:59 +0000 Subject: [PATCH 20/94] Comment out unused fields and add TODOs Commented out several lines in conversation object construction and added TODOs for future enhancements. --- api/server/services/SolidStorage.js | 35 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js index ef8bebebe699..4857b9bbdf8a 100644 --- a/api/server/services/SolidStorage.js +++ b/api/server/services/SolidStorage.js @@ -1213,6 +1213,7 @@ async function saveConvoToSolid(req, convoData, metadata) { // Get authenticated fetch and Pod URL const authenticatedFetch = await getSolidFetch(req); + // const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); // Ensure base structure exists @@ -1289,25 +1290,27 @@ async function saveConvoToSolid(req, convoData, metadata) { // Merge existing conversation with updates from convoData // convoData takes precedence for fields that are explicitly provided const conversationToSave = { + ...baseConversation, + ...convoData, conversationId: finalConversationId, user: req.user.id, // Title: use new value if provided, otherwise preserve existing, otherwise null - title: convoData.title !== undefined ? (convoData.title || null) : (baseConversation.title || null), - endpoint: finalEndpoint || baseConversation.endpoint || null, - model: finalModel || baseConversation.model || null, - agent_id: convoData.agent_id !== undefined ? (convoData.agent_id || null) : (baseConversation.agent_id || null), - assistant_id: convoData.assistant_id !== undefined ? (convoData.assistant_id || null) : (baseConversation.assistant_id || null), - spec: convoData.spec !== undefined ? (convoData.spec || null) : (baseConversation.spec || null), - iconURL: convoData.iconURL !== undefined ? (convoData.iconURL || null) : (baseConversation.iconURL || null), + // title: convoData.title !== undefined ? (convoData.title || null) : (baseConversation.title || null), + // endpoint: finalEndpoint || baseConversation.endpoint || null, + // model: finalModel || baseConversation.model || null, + // agent_id: convoData.agent_id !== undefined ? (convoData.agent_id || null) : (baseConversation.agent_id || null), + // assistant_id: convoData.assistant_id !== undefined ? (convoData.assistant_id || null) : (baseConversation.assistant_id || null), + // spec: convoData.spec !== undefined ? (convoData.spec || null) : (baseConversation.spec || null), + // iconURL: convoData.iconURL !== undefined ? (convoData.iconURL || null) : (baseConversation.iconURL || null), messages: messageRefs, - files: convoData.files !== undefined ? (convoData.files || []) : (baseConversation.files || []), - promptPrefix: convoData.promptPrefix !== undefined ? (convoData.promptPrefix || null) : (baseConversation.promptPrefix || null), - temperature: convoData.temperature !== undefined ? (convoData.temperature || null) : (baseConversation.temperature || null), - topP: convoData.topP !== undefined ? (convoData.topP || null) : (baseConversation.topP || null), - presence_penalty: convoData.presence_penalty !== undefined ? (convoData.presence_penalty || null) : (baseConversation.presence_penalty || null), - frequency_penalty: convoData.frequency_penalty !== undefined ? (convoData.frequency_penalty || null) : (baseConversation.frequency_penalty || null), - expiredAt: convoData.expiredAt !== undefined ? (convoData.expiredAt || null) : (baseConversation.expiredAt || null), - isArchived: convoData.isArchived !== undefined ? (convoData.isArchived || false) : (baseConversation.isArchived || false), + // files: convoData.files !== undefined ? (convoData.files || []) : (baseConversation.files || []), + // promptPrefix: convoData.promptPrefix !== undefined ? (convoData.promptPrefix || null) : (baseConversation.promptPrefix || null), + // temperature: convoData.temperature !== undefined ? (convoData.temperature || null) : (baseConversation.temperature || null), + // topP: convoData.topP !== undefined ? (convoData.topP || null) : (baseConversation.topP || null), + // presence_penalty: convoData.presence_penalty !== undefined ? (convoData.presence_penalty || null) : (baseConversation.presence_penalty || null), + // frequency_penalty: convoData.frequency_penalty !== undefined ? (convoData.frequency_penalty || null) : (baseConversation.frequency_penalty || null), + // expiredAt: convoData.expiredAt !== undefined ? (convoData.expiredAt || null) : (baseConversation.expiredAt || null), + // isArchived: convoData.isArchived !== undefined ? (convoData.isArchived || false) : (baseConversation.isArchived || false), createdAt: baseConversation.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -1551,6 +1554,7 @@ async function getConvosByCursorFromSolid(req, options = {}) { // Get authenticated fetch and Pod URL const authenticatedFetch = await getSolidFetch(req); + // TODO: Allow user to select their storage (can happen after the initial PR). const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); // Get conversations container path @@ -1590,6 +1594,7 @@ async function getConvosByCursorFromSolid(req, options = {}) { // Parse Turtle format to extract all items from ldp:contains // Handle both single and comma-separated items: ldp:contains , . + // TODO: Use object mapper to parse this. const ldpContainsPattern = /ldp:contains\s+((?:<[^>]+>(?:\s*,\s*<[^>]+>)*))/g; const allItems = []; let match; From 69a12105dcd876f9d126c8ac7de00294c2e46023 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 5 Feb 2026 09:59:13 +0000 Subject: [PATCH 21/94] debugging sharing --- api/server/routes/convos.js | 4 +- api/server/routes/share.js | 51 +- api/server/services/SolidStorage.js | 516 ++++++++++++++++++++- packages/data-schemas/src/methods/share.ts | 341 +++++++++++++- packages/data-schemas/src/schema/share.ts | 5 + 5 files changed, 891 insertions(+), 26 deletions(-) diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 0e50e86a7486..01e966c2f8e7 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -167,8 +167,8 @@ router.delete('/', async (req, res) => { try { const dbResponse = await deleteConvos(req.user.id, filter, req); // Pass req for Solid storage support if (filter.conversationId) { - await deleteToolCalls(req.user.id, filter.conversationId); - await deleteConvoSharedLink(req.user.id, filter.conversationId); + await deleteToolCalls(req.user.id, filter.conversationId); + await deleteConvoSharedLink(req.user.id, filter.conversationId, req); } res.status(201).json(dbResponse); } catch (error) { diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 6400b8b637a5..e4c49409701a 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -99,16 +99,59 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => { try { + logger.info('[share route] Creating shared link', { + userId: req.user.id, + conversationId: req.params.conversationId, + targetMessageId: req.body.targetMessageId, + hasOpenidId: !!req.user.openidId, + openidId: req.user.openidId, + }); const { targetMessageId } = req.body; - const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId); + + // Check if createSharedLink exists + if (typeof createSharedLink !== 'function') { + logger.error('[share route] createSharedLink is not a function', { + type: typeof createSharedLink, + createSharedLink, + }); + return res.status(500).json({ message: 'Share service not available' }); + } + + logger.info('[share route] Calling createSharedLink', { + userId: req.user.id, + conversationId: req.params.conversationId, + targetMessageId, + hasReq: !!req, + }); + + const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId, req); + + logger.info('[share route] createSharedLink returned', { + hasResult: !!created, + resultType: typeof created, + }); + if (created) { res.status(200).json(created); } else { res.status(404).end(); } } catch (error) { - logger.error('Error creating shared link:', error); - res.status(500).json({ message: 'Error creating shared link' }); + console.error('[share route] Error caught:', error); + logger.error('Error creating shared link:', { + error: error?.message || String(error), + errorName: error?.name, + errorCode: error?.code, + stack: error?.stack, + errorType: typeof error, + errorString: String(error), + errorKeys: error ? Object.keys(error) : [], + userId: req.user?.id, + conversationId: req.params?.conversationId, + fullError: error, + }); + const errorMessage = error?.message || error?.code || 'Error creating shared link'; + res.status(500).json({ message: errorMessage }); } }); @@ -128,7 +171,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => { router.delete('/:shareId', requireJwtAuth, async (req, res) => { try { - const result = await deleteSharedLink(req.user.id, req.params.shareId); + const result = await deleteSharedLink(req.user.id, req.params.shareId, req); if (!result) { return res.status(404).json({ message: 'Share not found' }); diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js index 4857b9bbdf8a..2be69effe2e7 100644 --- a/api/server/services/SolidStorage.js +++ b/api/server/services/SolidStorage.js @@ -7,6 +7,9 @@ const { deleteFile, getPodUrlAll, createContainerAt, + getPublicAccess, + setPublicAccess, + removePublicAccess, } = require('@inrupt/solid-client'); /** @@ -1426,12 +1429,84 @@ async function getConvoFromSolid(req, conversationId) { const fileText = await file.text(); const conversationData = JSON.parse(fileText); + logger.info('[SolidStorage] Conversation file read successfully, checking user ID', { + conversationId, + conversationUserId: conversationData.user, + conversationUserIdType: typeof conversationData.user, + currentUserId: req.user.id, + currentUserIdType: typeof req.user.id, + conversationHasUser: 'user' in conversationData, + conversationDataKeys: Object.keys(conversationData), + }); + // Validate that this conversation belongs to the current user - if (conversationData.user !== req.user.id) { - logger.warn('[SolidStorage] Conversation belongs to different user', { + // Convert both to strings for comparison to handle ObjectId vs string mismatches + // Handle cases where user might be an ObjectId object (MongoDB) or a string + let conversationUserId = conversationData.user; + if (!conversationUserId) { + logger.error('[SolidStorage] Conversation has no user field - RETURNING NULL', { conversationId, - conversationUserId: conversationData.user, - currentUserId: req.user.id, + conversationDataKeys: Object.keys(conversationData), + }); + return null; + } + + if (conversationUserId && typeof conversationUserId === 'object' && conversationUserId.toString) { + conversationUserId = conversationUserId.toString(); + } else { + conversationUserId = String(conversationUserId || ''); + } + + let currentUserId = req.user.id; + if (!currentUserId) { + logger.error('[SolidStorage] Request has no user ID - RETURNING NULL', { + conversationId, + hasUser: !!req.user, + userKeys: req.user ? Object.keys(req.user) : [], + }); + return null; + } + + if (currentUserId && typeof currentUserId === 'object' && currentUserId.toString) { + currentUserId = currentUserId.toString(); + } else { + currentUserId = String(currentUserId || ''); + } + + // Trim whitespace and compare + conversationUserId = conversationUserId.trim(); + currentUserId = currentUserId.trim(); + + logger.info('[SolidStorage] Comparing user IDs in getConvoFromSolid', { + conversationId, + conversationUserIdRaw: conversationData.user, + conversationUserIdString: conversationUserId, + currentUserIdRaw: req.user.id, + currentUserIdString: currentUserId, + userIdType: typeof req.user.id, + conversationUserIdType: typeof conversationData.user, + userIdsMatch: conversationUserId === currentUserId, + conversationUserIdLength: conversationUserId.length, + currentUserIdLength: currentUserId.length, + areEqual: conversationUserId === currentUserId, + conversationUserIdCharCodes: conversationUserId.split('').map(c => c.charCodeAt(0)), + currentUserIdCharCodes: currentUserId.split('').map(c => c.charCodeAt(0)), + }); + + if (conversationUserId !== currentUserId) { + logger.error('[SolidStorage] Conversation belongs to different user - RETURNING NULL', { + conversationId, + conversationUserIdRaw: conversationData.user, + conversationUserIdString: conversationUserId, + currentUserIdRaw: req.user.id, + currentUserIdString: currentUserId, + userIdType: typeof req.user.id, + conversationUserIdType: typeof conversationData.user, + conversationUserIdLength: conversationUserId.length, + currentUserIdLength: currentUserId.length, + areEqual: conversationUserId === currentUserId, + conversationUserIdCharCodes: conversationUserId.split('').map(c => c.charCodeAt(0)), + currentUserIdCharCodes: currentUserId.split('').map(c => c.charCodeAt(0)), }); return null; } @@ -2022,6 +2097,436 @@ async function deleteConvosFromSolid(req, conversationIds) { } } +/** + * Set public read access for a shared conversation and its messages + * This makes the conversation and all its messages publicly accessible + * + * @param {Object} req - Express request object + * @param {string} conversationId - The conversation ID to share + * @returns {Promise} + */ +async function setPublicAccessForShare(req, conversationId) { + try { + logger.info('[SolidStorage] Setting public access for shared conversation', { + conversationId, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!conversationId) { + throw new Error('conversationId is required'); + } + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // Get conversation file path + const conversationPath = getConversationPath(podUrl, conversationId); + + // Verify conversation belongs to user before sharing + try { + const conversation = await getConvoFromSolid(req, conversationId); + if (!conversation) { + throw new Error('Conversation not found or does not belong to user'); + } + } catch (error) { + logger.error('[SolidStorage] Error verifying conversation ownership before sharing', { + conversationId, + error: error.message, + }); + throw error; + } + + // Get all messages for this conversation + const messages = await getMessagesFromSolid(req, conversationId); + + if (messages.length === 0) { + logger.warn('[SolidStorage] No messages found for conversation to share', { + conversationId, + }); + throw new Error('No messages to share'); + } + + // Set public read access on conversation file + try { + const conversationFile = await getFile(conversationPath, { fetch: authenticatedFetch }); + await setPublicAccess(conversationFile, { read: true }, { fetch: authenticatedFetch }); + logger.info('[SolidStorage] Public read access set on conversation file', { + conversationPath, + conversationId, + }); + } catch (error) { + logger.error('[SolidStorage] Error setting public access on conversation file', { + conversationPath, + conversationId, + error: error.message, + stack: error.stack, + }); + throw error; + } + + // Set public read access on messages container + const messagesContainerPath = getMessagesContainerPath(podUrl, conversationId); + try { + const messagesContainer = await getFile(messagesContainerPath, { fetch: authenticatedFetch }); + await setPublicAccess(messagesContainer, { read: true }, { fetch: authenticatedFetch }); + logger.info('[SolidStorage] Public read access set on messages container', { + messagesContainerPath, + conversationId, + }); + } catch (error) { + logger.error('[SolidStorage] Error setting public access on messages container', { + messagesContainerPath, + conversationId, + error: error.message, + stack: error.stack, + }); + // Continue with message files even if container access fails + } + + // Set public read access on each message file + let messagesShared = 0; + for (const message of messages) { + try { + const messagePath = getMessagePath(podUrl, conversationId, message.messageId); + const messageFile = await getFile(messagePath, { fetch: authenticatedFetch }); + await setPublicAccess(messageFile, { read: true }, { fetch: authenticatedFetch }); + messagesShared++; + } catch (error) { + logger.error('[SolidStorage] Error setting public access on message file', { + messageId: message.messageId, + conversationId, + error: error.message, + }); + // Continue with other messages even if one fails + } + } + + logger.info('[SolidStorage] Public access set for shared conversation', { + conversationId, + messagesShared, + totalMessages: messages.length, + }); + } catch (error) { + logger.error('[SolidStorage] Error setting public access for share', { + conversationId, + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +/** + * Remove public read access for a shared conversation and its messages + * This unshares the conversation and makes it private again + * + * @param {Object} req - Express request object + * @param {string} conversationId - The conversation ID to unshare + * @returns {Promise} + */ +async function removePublicAccessForShare(req, conversationId) { + try { + logger.info('[SolidStorage] Removing public access for shared conversation', { + conversationId, + }); + + // Validate required fields + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + + if (!conversationId) { + throw new Error('conversationId is required'); + } + + // Get authenticated fetch and Pod URL + const authenticatedFetch = await getSolidFetch(req); + const podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + + // Get conversation file path + const conversationPath = getConversationPath(podUrl, conversationId); + + // Remove public read access from conversation file + try { + const conversationFile = await getFile(conversationPath, { fetch: authenticatedFetch }); + await removePublicAccess(conversationFile, { fetch: authenticatedFetch }); + logger.info('[SolidStorage] Public read access removed from conversation file', { + conversationPath, + conversationId, + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + logger.debug('[SolidStorage] Conversation file not found when removing public access', { + conversationPath, + conversationId, + }); + } else { + logger.error('[SolidStorage] Error removing public access from conversation file', { + conversationPath, + conversationId, + error: error.message, + stack: error.stack, + }); + // Continue with other files even if one fails + } + } + + // Remove public read access from messages container + const messagesContainerPath = getMessagesContainerPath(podUrl, conversationId); + try { + const messagesContainer = await getFile(messagesContainerPath, { fetch: authenticatedFetch }); + await removePublicAccess(messagesContainer, { fetch: authenticatedFetch }); + logger.info('[SolidStorage] Public read access removed from messages container', { + messagesContainerPath, + conversationId, + }); + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + logger.debug('[SolidStorage] Messages container not found when removing public access', { + messagesContainerPath, + conversationId, + }); + } else { + logger.error('[SolidStorage] Error removing public access from messages container', { + messagesContainerPath, + conversationId, + error: error.message, + }); + // Continue with message files even if container access fails + } + } + + // Try to get messages (they might not exist if conversation was deleted) + let messages = []; + try { + messages = await getMessagesFromSolid(req, conversationId); + } catch (error) { + logger.debug('[SolidStorage] Could not fetch messages when removing public access (conversation may be deleted)', { + conversationId, + error: error.message, + }); + } + + // Remove public read access from each message file + let messagesUnshared = 0; + for (const message of messages) { + try { + const messagePath = getMessagePath(podUrl, conversationId, message.messageId); + const messageFile = await getFile(messagePath, { fetch: authenticatedFetch }); + await removePublicAccess(messageFile, { fetch: authenticatedFetch }); + messagesUnshared++; + } catch (error) { + if (error.status === 404 || error.message?.includes('404')) { + logger.debug('[SolidStorage] Message file not found when removing public access', { + messageId: message.messageId, + conversationId, + }); + messagesUnshared++; // Count as unshared if already deleted + } else { + logger.error('[SolidStorage] Error removing public access from message file', { + messageId: message.messageId, + conversationId, + error: error.message, + }); + // Continue with other messages even if one fails + } + } + } + + logger.info('[SolidStorage] Public access removed for shared conversation', { + conversationId, + messagesUnshared, + totalMessages: messages.length, + }); + } catch (error) { + logger.error('[SolidStorage] Error removing public access for share', { + conversationId, + error: error.message, + stack: error.stack, + }); + throw error; + } +} + +/** + * Get shared messages from Solid Pod using public access (no authentication required) + * + * @param {string} shareId - The share ID + * @param {string} conversationId - The conversation ID (from SharedLink) + * @param {string} podUrl - The Pod URL where the conversation is stored + * @param {string} [targetMessageId] - Optional target message ID for branch sharing + * @returns {Promise} Shared conversation with messages, or null if not found + */ +async function getSharedMessagesFromSolid(shareId, conversationId, podUrl, targetMessageId) { + try { + logger.info('[SolidStorage] Getting shared messages from Solid Pod', { + shareId, + conversationId, + targetMessageId, + }); + + // Use unauthenticated fetch for public access + const publicFetch = fetch; + + // Get conversation file path + const conversationPath = getConversationPath(podUrl, conversationId); + + // Try to fetch conversation file with public access + let conversationData; + try { + const file = await getFile(conversationPath, { fetch: publicFetch }); + const fileText = await file.text(); + conversationData = JSON.parse(fileText); + } catch (error) { + if (error.status === 404 || error.status === 403) { + logger.warn('[SolidStorage] Conversation not found or not publicly accessible', { + conversationId, + shareId, + error: error.message, + }); + return null; + } + throw error; + } + + // Get all messages for this conversation using public access + const messagesContainerPath = getMessagesContainerPath(podUrl, conversationId); + let messages = []; + + try { + // Try to get container contents + const containerResponse = await publicFetch(messagesContainerPath, { + method: 'GET', + headers: { + 'Accept': 'text/turtle', + }, + }); + + if (!containerResponse.ok) { + if (containerResponse.status === 404 || containerResponse.status === 403) { + logger.warn('[SolidStorage] Messages container not found or not publicly accessible', { + conversationId, + shareId, + status: containerResponse.status, + }); + return null; + } + throw new Error(`Failed to fetch messages container: ${containerResponse.status} ${containerResponse.statusText}`); + } + + const containerText = await containerResponse.text(); + + // Parse Turtle format to extract all items from ldp:contains + const ldpContainsPattern = /ldp:contains\s+((?:<[^>]+>(?:\s*,\s*<[^>]+>)*))/g; + const allItems = []; + let match; + + while ((match = ldpContainsPattern.exec(containerText)) !== null) { + const itemsString = match[1]; + const itemPattern = /<([^>]+)>/g; + let itemMatch; + while ((itemMatch = itemPattern.exec(itemsString)) !== null) { + const itemUrl = itemMatch[1]; + const absoluteUrl = itemUrl.startsWith('http') + ? itemUrl + : new URL(itemUrl, messagesContainerPath).href; + allItems.push({ url: absoluteUrl }); + } + } + + // Filter for JSON files only + const containerContents = allItems.filter((item) => { + const url = item.url || ''; + return url.endsWith('.json') && !url.endsWith('.meta.json'); + }); + + // Read all message files + for (const item of containerContents) { + const url = item.url || ''; + if (url.endsWith('.json') && !url.endsWith('.meta.json')) { + try { + const messageFile = await getFile(url, { fetch: publicFetch }); + const messageText = await messageFile.text(); + const messageData = JSON.parse(messageText); + messages.push(messageData); + } catch (error) { + logger.warn('[SolidStorage] Error reading message file from public access', { + messageUrl: url, + error: error.message, + }); + // Continue with other messages + } + } + } + + // Sort messages by createdAt + messages.sort((a, b) => { + const dateA = new Date(a.createdAt || 0); + const dateB = new Date(b.createdAt || 0); + return dateA - dateB; + }); + } catch (error) { + logger.error('[SolidStorage] Error fetching messages from Solid Pod', { + conversationId, + shareId, + error: error.message, + stack: error.stack, + }); + return null; + } + + // Filter messages by targetMessageId if present (branch sharing) + let messagesToShare = messages; + if (targetMessageId) { + // Find the target message and get all messages up to it + const targetIndex = messages.findIndex(msg => msg.messageId === targetMessageId); + if (targetIndex >= 0) { + messagesToShare = messages.slice(0, targetIndex + 1); + } else { + logger.warn('[SolidStorage] Target message not found in shared messages', { + targetMessageId, + conversationId, + shareId, + }); + messagesToShare = messages; // Return all messages if target not found + } + } + + if (messagesToShare.length === 0) { + logger.warn('[SolidStorage] No messages to share', { + conversationId, + shareId, + }); + return null; + } + + // Return shared conversation data (anonymization will be done in the share methods) + return { + shareId, + title: conversationData.title || 'Untitled', + isPublic: true, + createdAt: conversationData.createdAt, + updatedAt: conversationData.updatedAt, + conversationId: conversationId, // Will be anonymized later + messages: messagesToShare, // Will be anonymized later + targetMessageId, + }; + } catch (error) { + logger.error('[SolidStorage] Error getting shared messages from Solid Pod', { + shareId, + conversationId, + error: error.message, + stack: error.stack, + }); + return null; + } +} + module.exports = { getSolidFetch, getPodUrl, @@ -2039,6 +2544,9 @@ module.exports = { getConvoFromSolid, getConvosByCursorFromSolid, deleteConvosFromSolid, + setPublicAccessForShare, + removePublicAccessForShare, + getSharedMessagesFromSolid, // Re-export solid-client functions for convenience getFile, saveFileInContainer, diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 2a0d2bc3bd88..94c064e7f323 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -162,6 +162,56 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { try { const SharedLink = mongoose.models.SharedLink as Model; const share = (await SharedLink.findOne({ shareId, isPublic: true }) + .select('-_id -__v -user') + .lean()) as t.ISharedLink | null; + + if (!share?.conversationId || !share.isPublic) { + return null; + } + + // Check if this is a Solid share (has podUrl) + if (share.podUrl) { + // Get shared messages from Solid Pod + try { + const { getSharedMessagesFromSolid } = require('~/server/services/SolidStorage'); + const solidShare = await getSharedMessagesFromSolid( + shareId, + share.conversationId, + share.podUrl, + share.targetMessageId, + ); + + if (!solidShare) { + return null; + } + + // Anonymize the conversation and messages + const newConvoId = anonymizeConvoId(solidShare.conversationId); + const result: t.SharedMessagesResult = { + shareId: solidShare.shareId || shareId, + title: solidShare.title, + isPublic: solidShare.isPublic, + createdAt: solidShare.createdAt, + updatedAt: solidShare.updatedAt, + conversationId: newConvoId, + messages: anonymizeMessages(solidShare.messages, newConvoId), + }; + + return result; + } catch (error) { + logger.error('[getSharedMessages] Error getting shared messages from Solid Pod', { + error: error instanceof Error ? error.message : 'Unknown error', + shareId, + conversationId: share.conversationId, + podUrl: share.podUrl, + }); + // Fall through to return null if Solid fetch fails + return null; + } + } + + // MongoDB share - use existing logic + const shareWithMessages = (await SharedLink.findOne({ shareId, isPublic: true }) .populate({ path: 'messages', select: '-_id -__v -user', @@ -169,23 +219,23 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { .select('-_id -__v -user') .lean()) as (t.ISharedLink & { messages: t.IMessage[] }) | null; - if (!share?.conversationId || !share.isPublic) { + if (!shareWithMessages?.messages) { return null; } /** Filtered messages based on targetMessageId if present (branch-specific sharing) */ - let messagesToShare: t.IMessage[] = share.messages; - if (share.targetMessageId) { - messagesToShare = getMessagesUpToTarget(share.messages, share.targetMessageId); + let messagesToShare: t.IMessage[] = shareWithMessages.messages; + if (shareWithMessages.targetMessageId) { + messagesToShare = getMessagesUpToTarget(shareWithMessages.messages, shareWithMessages.targetMessageId); } - const newConvoId = anonymizeConvoId(share.conversationId); + const newConvoId = anonymizeConvoId(shareWithMessages.conversationId); const result: t.SharedMessagesResult = { - shareId: share.shareId || shareId, - title: share.title, - isPublic: share.isPublic, - createdAt: share.createdAt, - updatedAt: share.updatedAt, + shareId: shareWithMessages.shareId || shareId, + title: shareWithMessages.title, + isPublic: shareWithMessages.isPublic, + createdAt: shareWithMessages.createdAt, + updatedAt: shareWithMessages.updatedAt, conversationId: newConvoId, messages: anonymizeMessages(messagesToShare, newConvoId), }; @@ -316,6 +366,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { async function deleteConvoSharedLink( user: string, conversationId: string, + req?: any, // Optional Express request object for Solid storage support ): Promise { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -323,6 +374,39 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { try { const SharedLink = mongoose.models.SharedLink as Model; + + // Find all shares for this conversation to check if any are Solid shares + const shares = await SharedLink.find({ user, conversationId }).lean(); + + // If any shares are Solid shares, remove public access + if (req) { + const USE_SOLID_STORAGE = process.env.USE_SOLID_STORAGE; + const isSolidUser = USE_SOLID_STORAGE && req?.user?.openidId; + + if (isSolidUser) { + const solidShares = shares.filter(share => share.podUrl); + for (const share of solidShares) { + try { + const { removePublicAccessForShare } = require('~/server/services/SolidStorage'); + await removePublicAccessForShare(req, conversationId); + logger.info('[deleteConvoSharedLink] Public access removed for Solid share', { + shareId: share.shareId, + conversationId, + }); + // Only need to remove access once per conversation + break; + } catch (error) { + logger.error('[deleteConvoSharedLink] Error removing public access for Solid share', { + error: error instanceof Error ? error.message : 'Unknown error', + shareId: share.shareId, + conversationId, + }); + // Continue with deletion even if removing public access fails + } + } + } + } + const result = await SharedLink.deleteMany({ user, conversationId }); return { message: 'Shared links deleted successfully', @@ -345,7 +429,28 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { user: string, conversationId: string, targetMessageId?: string, + req?: any, // Optional Express request object for Solid storage support ): Promise { + // Use both console and logger to ensure we see the log + console.error('[createSharedLink] FUNCTION CALLED', { + user, + conversationId, + targetMessageId, + hasReq: !!req, + hasUser: !!req?.user, + userId: req?.user?.id, + openidId: req?.user?.openidId, + }); + logger.error('[createSharedLink] FUNCTION CALLED', { + user, + conversationId, + targetMessageId, + hasReq: !!req, + hasUser: !!req?.user, + userId: req?.user?.id, + openidId: req?.user?.openidId, + }); + if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); } @@ -354,7 +459,148 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { const SharedLink = mongoose.models.SharedLink as Model; const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; - const [existingShare, conversationMessages] = await Promise.all([ + // Check if this is a Solid user + const USE_SOLID_STORAGE = process.env.USE_SOLID_STORAGE; + const isSolidUser = USE_SOLID_STORAGE && req?.user?.openidId; + + logger.info('[createSharedLink] Checking user type', { + user, + conversationId, + USE_SOLID_STORAGE: !!USE_SOLID_STORAGE, + hasReq: !!req, + hasUser: !!req?.user, + hasOpenidId: !!req?.user?.openidId, + openidId: req?.user?.openidId, + isSolidUser, + }); + + let conversation; + let conversationMessages; + let podUrl: string | undefined; + + if (isSolidUser) { + logger.info('[createSharedLink] Detected Solid user, using Solid storage path'); + // Check for existing share first (same as MongoDB) + const existingShare = (await SharedLink.findOne({ + conversationId, + user, + isPublic: true, + ...(targetMessageId && { targetMessageId }), + }) + .select('-_id -__v -user') + .lean()) as t.ISharedLink | null; + + if (existingShare && existingShare.isPublic) { + logger.error('[createSharedLink] Share already exists', { + user, + conversationId, + targetMessageId, + }); + throw new ShareServiceError('Share already exists', 'SHARE_EXISTS'); + } else if (existingShare) { + await SharedLink.deleteOne({ + conversationId, + user, + ...(targetMessageId && { targetMessageId }), + }); + } + + // For Solid users, get conversation and messages from Solid Pod + try { + logger.info('[createSharedLink] Starting Solid share creation', { + user, + conversationId, + userId: req.user?.id, + openidId: req.user?.openidId, + }); + + const { getConvoFromSolid, getMessagesFromSolid, getPodUrl, setPublicAccessForShare } = require('~/server/services/SolidStorage'); + const authenticatedFetch = await require('~/server/services/SolidStorage').getSolidFetch(req); + + // Get Pod URL + podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + logger.info('[createSharedLink] Pod URL retrieved', { + podUrl, + conversationId, + }); + + // Get conversation from Solid + try { + logger.info('[createSharedLink] Getting conversation from Solid Pod', { + user, + conversationId, + podUrl, + userId: req.user?.id, + openidId: req.user?.openidId, + }); + conversation = await getConvoFromSolid(req, conversationId); + if (!conversation) { + logger.error('[createSharedLink] Conversation not found in Solid Pod (returned null)', { + user, + conversationId, + podUrl, + userId: req.user?.id, + openidId: req.user?.openidId, + userIdType: typeof req.user?.id, + }); + throw new ShareServiceError( + 'Conversation not found or access denied', + 'CONVERSATION_NOT_FOUND', + ); + } + logger.info('[createSharedLink] Conversation retrieved from Solid Pod', { + conversationId, + hasTitle: !!conversation.title, + conversationUserId: conversation.user, + conversationUserIdType: typeof conversation.user, + requestUserId: req.user?.id, + requestUserIdType: typeof req.user?.id, + userIdsMatch: String(conversation.user) === String(req.user?.id), + }); + } catch (convoError) { + if (convoError instanceof ShareServiceError) { + throw convoError; + } + logger.error('[createSharedLink] Error calling getConvoFromSolid', { + error: convoError instanceof Error ? convoError.message : 'Unknown error', + errorStack: convoError instanceof Error ? convoError.stack : undefined, + errorName: convoError instanceof Error ? convoError.name : undefined, + user, + conversationId, + podUrl, + userId: req.user?.id, + openidId: req.user?.openidId, + }); + throw new ShareServiceError( + 'Conversation not found or access denied', + 'CONVERSATION_NOT_FOUND', + ); + } + + // Get messages from Solid + conversationMessages = await getMessagesFromSolid(req, conversationId); + if (!conversationMessages || conversationMessages.length === 0) { + logger.error('[createSharedLink] No messages found in Solid Pod', { + user, + conversationId, + }); + throw new ShareServiceError('No messages to share', 'NO_MESSAGES'); + } + } catch (error) { + if (error instanceof ShareServiceError) { + throw error; + } + logger.error('[createSharedLink] Error getting conversation/messages from Solid Pod', { + error: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : undefined, + user, + conversationId, + }); + throw new ShareServiceError('Error accessing Solid Pod', 'SOLID_ACCESS_ERROR'); + } + } else { + // For MongoDB users, use existing logic + const [existingShare, mongoMessages] = await Promise.all([ SharedLink.findOne({ conversationId, user, @@ -381,7 +627,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { }); } - const conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { + conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { title?: string; } | null; @@ -394,21 +640,57 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { } // Check if there are any messages to share - if (!conversationMessages || conversationMessages.length === 0) { + if (!mongoMessages || mongoMessages.length === 0) { throw new ShareServiceError('No messages to share', 'NO_MESSAGES'); + } + + conversationMessages = mongoMessages; } const title = conversation.title || 'Untitled'; const shareId = nanoid(); - await SharedLink.create({ + + // Create SharedLink with podUrl if Solid user + const sharedLinkData: any = { shareId, conversationId, - messages: conversationMessages, title, user, ...(targetMessageId && { targetMessageId }), - }); + }; + + if (isSolidUser && podUrl) { + sharedLinkData.podUrl = podUrl; + // For Solid, we don't store message ObjectIds, but we still need the array for schema compatibility + sharedLinkData.messages = []; + } else { + // For MongoDB, store message ObjectIds + sharedLinkData.messages = conversationMessages; + } + + await SharedLink.create(sharedLinkData); + + // For Solid users, set public access on conversation and messages + if (isSolidUser && req) { + try { + const { setPublicAccessForShare } = require('~/server/services/SolidStorage'); + await setPublicAccessForShare(req, conversationId); + logger.info('[createSharedLink] Public access set for Solid share', { + shareId, + conversationId, + }); + } catch (error) { + logger.error('[createSharedLink] Error setting public access for Solid share', { + error: error instanceof Error ? error.message : 'Unknown error', + shareId, + conversationId, + }); + // Delete the SharedLink if setting public access fails + await SharedLink.deleteOne({ shareId }); + throw new ShareServiceError('Error setting public access', 'SOLID_ACCESS_ERROR'); + } + } return { shareId, conversationId }; } catch (error) { @@ -519,6 +801,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { async function deleteSharedLink( user: string, shareId: string, + req?: any, // Optional Express request object for Solid storage support ): Promise { if (!user || !shareId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -526,6 +809,32 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { try { const SharedLink = mongoose.models.SharedLink as Model; + const share = await SharedLink.findOne({ shareId, user }).lean(); + + if (!share) { + return null; + } + + // If this is a Solid share, remove public access before deleting + if (share.podUrl && req) { + try { + const { removePublicAccessForShare } = require('~/server/services/SolidStorage'); + await removePublicAccessForShare(req, share.conversationId); + logger.info('[deleteSharedLink] Public access removed for Solid share', { + shareId, + conversationId: share.conversationId, + }); + } catch (error) { + logger.error('[deleteSharedLink] Error removing public access for Solid share', { + error: error instanceof Error ? error.message : 'Unknown error', + shareId, + conversationId: share.conversationId, + }); + // Continue with deletion even if removing public access fails + } + } + + // Delete the SharedLink const result = await SharedLink.findOneAndDelete({ shareId, user }).lean(); if (!result) { diff --git a/packages/data-schemas/src/schema/share.ts b/packages/data-schemas/src/schema/share.ts index 987dd10fc2ac..698f4475614a 100644 --- a/packages/data-schemas/src/schema/share.ts +++ b/packages/data-schemas/src/schema/share.ts @@ -8,6 +8,7 @@ export interface ISharedLink extends Document { shareId?: string; targetMessageId?: string; isPublic: boolean; + podUrl?: string; // Pod URL for Solid storage shares createdAt?: Date; updatedAt?: Date; } @@ -40,6 +41,10 @@ const shareSchema: Schema = new Schema( type: Boolean, default: true, }, + podUrl: { + type: String, + required: false, + }, }, { timestamps: true }, ); From 6ae866b891a7e1d37ab9bfa23ce3ff8a12a60452 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 5 Feb 2026 14:28:39 +0000 Subject: [PATCH 22/94] feat: Implement share functionality for Solid users - Added support for sharing conversations stored in Solid Pods by granting public read access via ACL (Access Control List) while preserving owner write permissions. This allows Solid users to share conversations while maintaining the ability to continue adding more messages to the conversation(write access) --- api/package.json | 1 + api/server/routes/share.js | 29 +- api/server/services/SolidStorage.js | 510 +++++++++++++++++++-- package-lock.json | 1 + packages/data-schemas/src/methods/share.ts | 31 -- packages/data-schemas/src/types/share.ts | 1 + 6 files changed, 475 insertions(+), 98 deletions(-) diff --git a/api/package.json b/api/package.json index 3d3055447364..e35ae9a2ae4d 100644 --- a/api/package.json +++ b/api/package.json @@ -89,6 +89,7 @@ "module-alias": "^2.2.3", "mongoose": "^8.12.1", "multer": "^2.1.0", + "n3": "^1.26.0", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", "nodemailer": "^7.0.11", diff --git a/api/server/routes/share.js b/api/server/routes/share.js index e4c49409701a..e850c76ab6c2 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -99,56 +99,29 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => { try { - logger.info('[share route] Creating shared link', { - userId: req.user.id, - conversationId: req.params.conversationId, - targetMessageId: req.body.targetMessageId, - hasOpenidId: !!req.user.openidId, - openidId: req.user.openidId, - }); const { targetMessageId } = req.body; // Check if createSharedLink exists if (typeof createSharedLink !== 'function') { - logger.error('[share route] createSharedLink is not a function', { - type: typeof createSharedLink, - createSharedLink, - }); + logger.error('[share route] createSharedLink is not a function'); return res.status(500).json({ message: 'Share service not available' }); } - logger.info('[share route] Calling createSharedLink', { - userId: req.user.id, - conversationId: req.params.conversationId, - targetMessageId, - hasReq: !!req, - }); - const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId, req); - logger.info('[share route] createSharedLink returned', { - hasResult: !!created, - resultType: typeof created, - }); - if (created) { res.status(200).json(created); } else { res.status(404).end(); } } catch (error) { - console.error('[share route] Error caught:', error); logger.error('Error creating shared link:', { error: error?.message || String(error), errorName: error?.name, errorCode: error?.code, stack: error?.stack, - errorType: typeof error, - errorString: String(error), - errorKeys: error ? Object.keys(error) : [], userId: req.user?.id, conversationId: req.params?.conversationId, - fullError: error, }); const errorMessage = error?.message || error?.code || 'Error creating shared link'; res.status(500).json({ message: errorMessage }); diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js index 2be69effe2e7..448ce891a7af 100644 --- a/api/server/services/SolidStorage.js +++ b/api/server/services/SolidStorage.js @@ -1,5 +1,6 @@ const { logger } = require('@librechat/data-schemas'); const fetch = require('node-fetch'); +const { DataFactory, Writer } = require('n3'); const { getFile, saveFileInContainer, @@ -7,11 +8,13 @@ const { deleteFile, getPodUrlAll, createContainerAt, - getPublicAccess, - setPublicAccess, - removePublicAccess, } = require('@inrupt/solid-client'); +// ACL/ACP namespaces +const ACL_NS = 'http://www.w3.org/ns/auth/acl#'; +const RDF_NS = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'; +const FOAF_NS = 'http://xmlns.com/foaf/0.1/'; + /** * Solid Storage Utility Module * @@ -1297,23 +1300,7 @@ async function saveConvoToSolid(req, convoData, metadata) { ...convoData, conversationId: finalConversationId, user: req.user.id, - // Title: use new value if provided, otherwise preserve existing, otherwise null - // title: convoData.title !== undefined ? (convoData.title || null) : (baseConversation.title || null), - // endpoint: finalEndpoint || baseConversation.endpoint || null, - // model: finalModel || baseConversation.model || null, - // agent_id: convoData.agent_id !== undefined ? (convoData.agent_id || null) : (baseConversation.agent_id || null), - // assistant_id: convoData.assistant_id !== undefined ? (convoData.assistant_id || null) : (baseConversation.assistant_id || null), - // spec: convoData.spec !== undefined ? (convoData.spec || null) : (baseConversation.spec || null), - // iconURL: convoData.iconURL !== undefined ? (convoData.iconURL || null) : (baseConversation.iconURL || null), messages: messageRefs, - // files: convoData.files !== undefined ? (convoData.files || []) : (baseConversation.files || []), - // promptPrefix: convoData.promptPrefix !== undefined ? (convoData.promptPrefix || null) : (baseConversation.promptPrefix || null), - // temperature: convoData.temperature !== undefined ? (convoData.temperature || null) : (baseConversation.temperature || null), - // topP: convoData.topP !== undefined ? (convoData.topP || null) : (baseConversation.topP || null), - // presence_penalty: convoData.presence_penalty !== undefined ? (convoData.presence_penalty || null) : (baseConversation.presence_penalty || null), - // frequency_penalty: convoData.frequency_penalty !== undefined ? (convoData.frequency_penalty || null) : (baseConversation.frequency_penalty || null), - // expiredAt: convoData.expiredAt !== undefined ? (convoData.expiredAt || null) : (baseConversation.expiredAt || null), - // isArchived: convoData.isArchived !== undefined ? (convoData.isArchived || false) : (baseConversation.isArchived || false), createdAt: baseConversation.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString(), }; @@ -2097,6 +2084,398 @@ async function deleteConvosFromSolid(req, conversationIds) { } } +/** + * Gets the ACL URL for a resource + * @param {string} resourceUrl - The resource URL + * @param {Function} fetchFn - Authenticated fetch function + * @returns {Promise} The ACL URL + */ +async function getAclUrl(resourceUrl, fetchFn) { + try { + const response = await fetchFn(resourceUrl, { + method: 'HEAD', + headers: { + Accept: '*/*', + }, + }); + // Even if we get 403, we can still try to get the Link header + const linkHeader = response.headers.get('Link'); + if (linkHeader) { + const aclMatch = linkHeader.match(/<([^>]+)>;\s*rel=["']acl["']/i); + if (aclMatch && aclMatch[1]) { + return aclMatch[1]; + } + } + } catch (error) { + // If HEAD fails (including 403), fall back to appending .acl + logger.debug('[SolidStorage] Failed to discover ACL URL via Link header, using fallback', { + resourceUrl, + error: error.message, + errorStatus: error.status, + }); + } + // Default: append .acl to the resource URL + if (resourceUrl.endsWith('/')) { + return resourceUrl + '.acl'; + } + return resourceUrl + '.acl'; +} + +/** + * Fetches an existing ACL or returns null if it doesn't exist + * @param {string} aclUrl - The ACL URL + * @param {Function} fetchFn - Authenticated fetch function + * @returns {Promise} The ACL Turtle content or null + */ +async function fetchAcl(aclUrl, fetchFn) { + try { + const response = await fetchFn(aclUrl, { + method: 'GET', + headers: { + Accept: 'text/turtle', + }, + }); + if (!response.ok) { + // Treat 404 and 403 as "no ACL exists" - we'll create a new one + if (response.status === 404 || response.status === 403) { + return null; + } + throw new Error(`Failed to fetch ACL: ${response.statusText}`); + } + return await response.text(); + } catch (error) { + // Treat 404 and 403 as "no ACL exists" + if (error.status === 404 || error.status === 403 || error.message?.includes('404') || error.message?.includes('403')) { + return null; + } + throw error; + } +} + +/** + * Checks if public access already exists in ACL Turtle content + * @param {string} aclTurtle - The ACL Turtle content + * @returns {boolean} True if public access exists + */ +function hasPublicAccessInAcl(aclTurtle) { + return aclTurtle.includes('acl:agentClass') && aclTurtle.includes('foaf:Agent'); +} + +/** + * Creates a new ACL with public read access and owner permissions + * @param {string} resourceUrl - The resource URL + * @param {string} aclUrl - The ACL URL + * @param {boolean} isContainer - Whether this is a container (needs acl:default) + * @param {string} [ownerWebId] - Optional owner WebID to grant full permissions + * @returns {Promise} The ACL Turtle content + */ +async function createPublicAcl(resourceUrl, aclUrl, isContainer = false, ownerWebId = null) { + const { namedNode, blankNode, quad } = DataFactory; + const quads = []; + + // If owner WebID is provided, create Authorization for owner with full permissions + if (ownerWebId) { + const ownerAuthNode = blankNode('ownerAuth'); + + // Authorization: type + quads.push(quad(ownerAuthNode, namedNode(`${RDF_NS}type`), namedNode(`${ACL_NS}Authorization`))); + + // Authorization: agent (the owner) + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}agent`), namedNode(ownerWebId))); + + // Authorization: accessTo (the resource) + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}accessTo`), namedNode(resourceUrl))); + + // If this is a container, also add default access for resources within it + if (isContainer) { + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}default`), namedNode(resourceUrl))); + } + + // Authorization: mode (Write, Append, Control for owner) + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}mode`), namedNode(`${ACL_NS}Write`))); + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}mode`), namedNode(`${ACL_NS}Append`))); + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}mode`), namedNode(`${ACL_NS}Control`))); + } + + // Create Authorization for public access + const authNode = blankNode('publicAuth'); + + // Authorization: type + quads.push(quad(authNode, namedNode(`${RDF_NS}type`), namedNode(`${ACL_NS}Authorization`))); + + // Authorization: agentClass (foaf:Agent = anyone/public) + quads.push(quad(authNode, namedNode(`${ACL_NS}agentClass`), namedNode(`${FOAF_NS}Agent`))); + + // Authorization: accessTo (the resource) + quads.push(quad(authNode, namedNode(`${ACL_NS}accessTo`), namedNode(resourceUrl))); + + // If this is a container, also add default access for resources within it + if (isContainer) { + quads.push(quad(authNode, namedNode(`${ACL_NS}default`), namedNode(resourceUrl))); + } + + // Authorization: mode (Read access) + quads.push(quad(authNode, namedNode(`${ACL_NS}mode`), namedNode(`${ACL_NS}Read`))); + + // Convert quads to Turtle using N3 Writer + return new Promise((resolve, reject) => { + const writer = new Writer({ prefixes: { acl: ACL_NS, rdf: RDF_NS, foaf: FOAF_NS } }); + quads.forEach((q) => writer.addQuad(q)); + writer.end((error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); +} + +/** + * Updates an existing ACL by adding public read access and ensuring owner permissions + * @param {string} existingTurtle - The existing ACL Turtle content + * @param {string} aclUrl - The ACL URL + * @param {string} resourceUrl - The resource URL + * @param {boolean} isContainer - Whether this is a container (needs acl:default) + * @param {string} [ownerWebId] - Optional owner WebID to ensure full permissions + * @returns {Promise} The updated ACL Turtle content + */ +async function updateAclWithPublicAccess(existingTurtle, aclUrl, resourceUrl, isContainer = false, ownerWebId = null) { + const { namedNode, blankNode, quad } = DataFactory; + const quads = []; + + // Check if owner permissions exist in the existing ACL + const hasOwnerPermissions = ownerWebId && existingTurtle.includes(ownerWebId); + + // If owner WebID is provided and owner permissions don't exist, add them + if (ownerWebId && !hasOwnerPermissions) { + const ownerAuthNode = blankNode('ownerAuth'); + + // Authorization: type + quads.push(quad(ownerAuthNode, namedNode(`${RDF_NS}type`), namedNode(`${ACL_NS}Authorization`))); + + // Authorization: agent (the owner) + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}agent`), namedNode(ownerWebId))); + + // Authorization: accessTo (the resource) + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}accessTo`), namedNode(resourceUrl))); + + // If this is a container, also add default access for resources within it + if (isContainer) { + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}default`), namedNode(resourceUrl))); + } + + // Authorization: mode (Write, Append, Control for owner) + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}mode`), namedNode(`${ACL_NS}Write`))); + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}mode`), namedNode(`${ACL_NS}Append`))); + quads.push(quad(ownerAuthNode, namedNode(`${ACL_NS}mode`), namedNode(`${ACL_NS}Control`))); + } + + // Check if public access already exists + if (hasPublicAccessInAcl(existingTurtle)) { + // If we added owner permissions, combine them with existing ACL + if (quads.length > 0) { + const newTurtle = await new Promise((resolve, reject) => { + const writer = new Writer({ prefixes: { acl: ACL_NS, rdf: RDF_NS, foaf: FOAF_NS } }); + quads.forEach((q) => writer.addQuad(q)); + writer.end((error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); + return existingTurtle + '\n' + newTurtle; + } + return existingTurtle; // Public access already exists, no changes needed + } + + // Create Authorization for public access + const authNode = blankNode('publicAuth'); + quads.push(quad(authNode, namedNode(`${RDF_NS}type`), namedNode(`${ACL_NS}Authorization`))); + quads.push(quad(authNode, namedNode(`${ACL_NS}agentClass`), namedNode(`${FOAF_NS}Agent`))); + quads.push(quad(authNode, namedNode(`${ACL_NS}accessTo`), namedNode(resourceUrl))); + + // If this is a container, also add default access for resources within it + if (isContainer) { + quads.push(quad(authNode, namedNode(`${ACL_NS}default`), namedNode(resourceUrl))); + } + + quads.push(quad(authNode, namedNode(`${ACL_NS}mode`), namedNode(`${ACL_NS}Read`))); + + // Convert new quads to Turtle + const newTurtle = await new Promise((resolve, reject) => { + const writer = new Writer({ prefixes: { acl: ACL_NS, rdf: RDF_NS, foaf: FOAF_NS } }); + quads.forEach((q) => writer.addQuad(q)); + writer.end((error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); + + // Combine existing and new Turtle + return existingTurtle + '\n' + newTurtle; +} + +/** + * Grants public read access to a Solid resource using manual ACL Turtle approach + * @param {string} resourceUrl - The resource URL + * @param {Function} fetchFn - Authenticated fetch function + * @param {boolean} isContainer - Whether this is a container (needs acl:default) + * @param {string} [ownerWebId] - Optional owner WebID to grant full permissions + * @returns {Promise} + */ +async function grantPublicReadAccess(resourceUrl, fetchFn, isContainer = false, ownerWebId = null) { + // Get ACL URL - if HEAD fails with 403, we'll still try to create the ACL file + let aclUrl; + try { + aclUrl = await getAclUrl(resourceUrl, fetchFn); + } catch (error) { + // If HEAD fails, fall back to appending .acl + logger.debug('[SolidStorage] Failed to get ACL URL via HEAD, using fallback', { + resourceUrl, + error: error.message, + }); + if (resourceUrl.endsWith('/')) { + aclUrl = resourceUrl + '.acl'; + } else { + aclUrl = resourceUrl + '.acl'; + } + } + + // Fetch existing ACL or create new one + // If we get 403, treat it as "no ACL exists" and create a new one + let existingTurtle = null; + try { + existingTurtle = await fetchAcl(aclUrl, fetchFn); + } catch (error) { + // If fetch fails (including 403), treat as no ACL exists + logger.debug('[SolidStorage] Failed to fetch existing ACL, will create new one', { + aclUrl, + error: error.message, + }); + existingTurtle = null; + } + + let turtle; + + if (existingTurtle) { + // Update existing ACL with public access and ensure owner permissions + turtle = await updateAclWithPublicAccess(existingTurtle, aclUrl, resourceUrl, isContainer, ownerWebId); + } else { + // Create new ACL with public access and owner permissions + turtle = await createPublicAcl(resourceUrl, aclUrl, isContainer, ownerWebId); + } + + // Save ACL - even if we got 403 before, we should be able to PUT the ACL file + const response = await fetchFn(aclUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'text/turtle', + }, + body: turtle, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(`Failed to save ACL: ${response.status} ${response.statusText} - ${errorText}`); + } + + logger.debug('[SolidStorage] ACL file created/updated successfully', { + aclUrl, + resourceUrl, + isContainer, + hasOwnerPermissions: !!ownerWebId, + }); +} + +/** + * Removes public read access from a Solid resource using manual ACL Turtle approach + * @param {string} resourceUrl - The resource URL + * @param {Function} fetchFn - Authenticated fetch function + * @returns {Promise} + */ +async function removePublicReadAccess(resourceUrl, fetchFn) { + const aclUrl = await getAclUrl(resourceUrl, fetchFn); + + // Fetch existing ACL + const existingTurtle = await fetchAcl(aclUrl, fetchFn); + + if (!existingTurtle) { + // No ACL exists, nothing to remove + return; + } + + // Check if public access exists + if (!hasPublicAccessInAcl(existingTurtle)) { + // Public access doesn't exist, nothing to remove + return; + } + + // Remove public access lines from Turtle + // This is a simple approach - remove lines containing foaf:Agent + const lines = existingTurtle.split('\n'); + const filteredLines = []; + let skipNext = false; + let inPublicAuth = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Detect start of public authorization (blank node with foaf:Agent) + if (line.includes('foaf:Agent') && (line.includes('acl:agentClass') || line.includes('acl:Authorization'))) { + inPublicAuth = true; + skipNext = true; + continue; + } + + // Skip lines that are part of the public authorization + if (inPublicAuth) { + if (line.trim().endsWith('.') && !line.includes('foaf:Agent')) { + inPublicAuth = false; + skipNext = false; + } + continue; + } + + // Skip lines that are clearly part of public auth block + if (skipNext && (line.includes('acl:accessTo') || line.includes('acl:mode') || line.includes('acl:Read'))) { + if (line.trim().endsWith('.')) { + skipNext = false; + } + continue; + } + + skipNext = false; + filteredLines.push(line); + } + + const updatedTurtle = filteredLines.join('\n'); + + // Save updated ACL (or delete if empty) + if (updatedTurtle.trim().length === 0) { + // Delete ACL file if it's empty + try { + await fetchFn(aclUrl, { + method: 'DELETE', + }); + } catch (error) { + logger.warn('[SolidStorage] Error deleting empty ACL', { + aclUrl, + error: error.message, + }); + } + } else { + const response = await fetchFn(aclUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'text/turtle', + }, + body: updatedTurtle, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(`Failed to save ACL: ${response.status} ${response.statusText} - ${errorText}`); + } + } +} + /** * Set public read access for a shared conversation and its messages * This makes the conversation and all its messages publicly accessible @@ -2151,10 +2530,17 @@ async function setPublicAccessForShare(req, conversationId) { throw new Error('No messages to share'); } - // Set public read access on conversation file + // Get owner WebID for preserving owner permissions + const ownerWebId = req.user.openidId; + + // Set public read access on conversation file using manual Turtle approach try { - const conversationFile = await getFile(conversationPath, { fetch: authenticatedFetch }); - await setPublicAccess(conversationFile, { read: true }, { fetch: authenticatedFetch }); + logger.debug('[SolidStorage] Setting public access on conversation file', { + conversationPath, + conversationId, + ownerWebId, + }); + await grantPublicReadAccess(conversationPath, authenticatedFetch, false, ownerWebId); logger.info('[SolidStorage] Public read access set on conversation file', { conversationPath, conversationId, @@ -2164,17 +2550,21 @@ async function setPublicAccessForShare(req, conversationId) { conversationPath, conversationId, error: error.message, + errorName: error.name, + errorCode: error.code, stack: error.stack, }); throw error; } - // Set public read access on messages container + // Set public read access on messages container using manual Turtle approach + // IMPORTANT: For containers, we need to set acl:default so files within inherit access + // IMPORTANT: We must preserve owner permissions so the owner can still add messages const messagesContainerPath = getMessagesContainerPath(podUrl, conversationId); try { - const messagesContainer = await getFile(messagesContainerPath, { fetch: authenticatedFetch }); - await setPublicAccess(messagesContainer, { read: true }, { fetch: authenticatedFetch }); - logger.info('[SolidStorage] Public read access set on messages container', { + + await grantPublicReadAccess(messagesContainerPath, authenticatedFetch, true, ownerWebId); // isContainer=true, ownerWebId + logger.info('[SolidStorage] Public read access set on messages container (with default)', { messagesContainerPath, conversationId, }); @@ -2188,19 +2578,52 @@ async function setPublicAccessForShare(req, conversationId) { // Continue with message files even if container access fails } - // Set public read access on each message file + // Set public read access on each message file using manual Turtle approach let messagesShared = 0; for (const message of messages) { try { const messagePath = getMessagePath(podUrl, conversationId, message.messageId); - const messageFile = await getFile(messagePath, { fetch: authenticatedFetch }); - await setPublicAccess(messageFile, { read: true }, { fetch: authenticatedFetch }); - messagesShared++; + + try { + await grantPublicReadAccess(messagePath, authenticatedFetch, false, ownerWebId); + messagesShared++; + } catch (grantError) { + // If grantPublicReadAccess fails, try to create ACL file directly + // This handles cases where we get 403 on HEAD/GET but can still PUT the ACL + logger.debug('[SolidStorage] grantPublicReadAccess failed, trying direct ACL creation', { + messagePath, + messageId: message.messageId, + error: grantError.message, + }); + + const aclUrl = messagePath.endsWith('/') ? messagePath + '.acl' : messagePath + '.acl'; + const turtle = await createPublicAcl(messagePath, aclUrl, false, ownerWebId); + + const response = await authenticatedFetch(aclUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'text/turtle', + }, + body: turtle, + }); + + if (response.ok) { + logger.info('[SolidStorage] ACL file created directly for message file', { + messagePath, + messageId: message.messageId, + }); + messagesShared++; + } else { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(`Failed to create ACL directly: ${response.status} ${response.statusText} - ${errorText}`); + } + } } catch (error) { logger.error('[SolidStorage] Error setting public access on message file', { messageId: message.messageId, conversationId, error: error.message, + errorStatus: error.status, }); // Continue with other messages even if one fails } @@ -2251,10 +2674,13 @@ async function removePublicAccessForShare(req, conversationId) { // Get conversation file path const conversationPath = getConversationPath(podUrl, conversationId); - // Remove public read access from conversation file + // Remove public read access from conversation file using manual Turtle approach try { - const conversationFile = await getFile(conversationPath, { fetch: authenticatedFetch }); - await removePublicAccess(conversationFile, { fetch: authenticatedFetch }); + logger.debug('[SolidStorage] Removing public access from conversation file', { + conversationPath, + conversationId, + }); + await removePublicReadAccess(conversationPath, authenticatedFetch); logger.info('[SolidStorage] Public read access removed from conversation file', { conversationPath, conversationId, @@ -2276,11 +2702,14 @@ async function removePublicAccessForShare(req, conversationId) { } } - // Remove public read access from messages container + // Remove public read access from messages container using manual Turtle approach const messagesContainerPath = getMessagesContainerPath(podUrl, conversationId); try { - const messagesContainer = await getFile(messagesContainerPath, { fetch: authenticatedFetch }); - await removePublicAccess(messagesContainer, { fetch: authenticatedFetch }); + logger.debug('[SolidStorage] Removing public access from messages container', { + messagesContainerPath, + conversationId, + }); + await removePublicReadAccess(messagesContainerPath, authenticatedFetch); logger.info('[SolidStorage] Public read access removed from messages container', { messagesContainerPath, conversationId, @@ -2312,13 +2741,16 @@ async function removePublicAccessForShare(req, conversationId) { }); } - // Remove public read access from each message file + // Remove public read access from each message file using manual Turtle approach let messagesUnshared = 0; for (const message of messages) { try { const messagePath = getMessagePath(podUrl, conversationId, message.messageId); - const messageFile = await getFile(messagePath, { fetch: authenticatedFetch }); - await removePublicAccess(messageFile, { fetch: authenticatedFetch }); + logger.debug('[SolidStorage] Removing public access from message file', { + messagePath, + messageId: message.messageId, + }); + await removePublicReadAccess(messagePath, authenticatedFetch); messagesUnshared++; } catch (error) { if (error.status === 404 || error.message?.includes('404')) { diff --git a/package-lock.json b/package-lock.json index 0b555e4572b2..f2f2b4d39114 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,6 +104,7 @@ "module-alias": "^2.2.3", "mongoose": "^8.12.1", "multer": "^2.1.0", + "n3": "^1.26.0", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", "nodemailer": "^7.0.11", diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 94c064e7f323..61bc8417367e 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -431,26 +431,6 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { targetMessageId?: string, req?: any, // Optional Express request object for Solid storage support ): Promise { - // Use both console and logger to ensure we see the log - console.error('[createSharedLink] FUNCTION CALLED', { - user, - conversationId, - targetMessageId, - hasReq: !!req, - hasUser: !!req?.user, - userId: req?.user?.id, - openidId: req?.user?.openidId, - }); - logger.error('[createSharedLink] FUNCTION CALLED', { - user, - conversationId, - targetMessageId, - hasReq: !!req, - hasUser: !!req?.user, - userId: req?.user?.id, - openidId: req?.user?.openidId, - }); - if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); } @@ -463,17 +443,6 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { const USE_SOLID_STORAGE = process.env.USE_SOLID_STORAGE; const isSolidUser = USE_SOLID_STORAGE && req?.user?.openidId; - logger.info('[createSharedLink] Checking user type', { - user, - conversationId, - USE_SOLID_STORAGE: !!USE_SOLID_STORAGE, - hasReq: !!req, - hasUser: !!req?.user, - hasOpenidId: !!req?.user?.openidId, - openidId: req?.user?.openidId, - isSolidUser, - }); - let conversation; let conversationMessages; let podUrl: string | undefined; diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 8b54990cf49b..8b2647b655f1 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -10,6 +10,7 @@ export interface ISharedLink { shareId?: string; targetMessageId?: string; isPublic: boolean; + podUrl?: string; // Pod URL for Solid storage shares createdAt?: Date; updatedAt?: Date; } From 5d603d86e7695bb1e90a8b81719bdbc598a0dda8 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Thu, 5 Feb 2026 14:47:19 +0000 Subject: [PATCH 23/94] uodate progress document --- SOLID_INTEGRATION_PROGRESS.md | 52 +++++++++++++++++++++++------ api/server/services/SolidStorage.js | 2 +- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/SOLID_INTEGRATION_PROGRESS.md b/SOLID_INTEGRATION_PROGRESS.md index 15c4c8c0d89a..1e2b8e02a952 100644 --- a/SOLID_INTEGRATION_PROGRESS.md +++ b/SOLID_INTEGRATION_PROGRESS.md @@ -97,13 +97,24 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - `saveConvoToSolid` merges updates with existing conversation data to prevent data loss ### 12. Conversation Management Operations -- **Status**: Partially Complete +- **Status**: Complete - **Details**: - **Rename**: Working - Users can rename conversations stored in Solid Pod - **Duplicate**: Working - Users can duplicate conversations and all messages from Solid Pod - **Delete**: Working - Users can delete conversations and all associated messages from Solid Pod - **Archive**: Working - Users can archive and unarchive conversations stored in Solid Pod - - **Share**: Not yet implemented + - **Share**: Working - Users can share conversations stored in Solid Pod with public read access while maintaining owner write permissions + +### 13. Share Functionality +- **Status**: Complete +- **Details**: + - Implemented public read access via ACL (Access Control List) for shared conversations + - Uses manual ACL Turtle format for reliable permission management + - Preserves owner permissions (Write, Append, Control) when granting public read access + - Applies `acl:default` on message containers so new messages automatically inherit public access + - Dynamically detects Solid users and routes to appropriate sharing method + - Fetches shared messages directly from Pod using unauthenticated requests + - Properly removes public access when share is deleted ## Current Status @@ -121,13 +132,14 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u 11. **Conversation Duplicate**: Users can duplicate conversations and all their messages from Solid Pod 12. **Conversation Delete**: Users can delete conversations and all associated messages from Solid Pod 13. **Conversation Archive**: Users can archive and unarchive conversations stored in Solid Pod +14. **Conversation Share**: Users can share conversations stored in Solid Pod with public read access while maintaining full write permissions ### Known Issues 🔧 -1. **Conversation Menu Options** - - **Issue**: Share option needs to be implemented for Solid storage - - **Impact**: Users cannot share conversations stored in Solid Pod - - **Status**: Not yet implemented +1. **Solid Authentication UI** + - **Issue**: Solid authentication is currently tied to the OpenID button instead of having its own dedicated button + - **Status**: Needs implementation - **Priority**: Medium + - **Solution**: Create a dedicated Solid login button similar to other social authentication providers (Google, GitHub, etc.) ## Technical Implementation @@ -154,11 +166,24 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - Falls back to cookies if session unavailable - Tokens retrieved from multiple sources for robustness -## Next Steps +### Access Control (ACL) +- Uses manual ACL Turtle format for permission management +- Grants public read access for shared conversations while preserving owner permissions +- Applies `acl:default` on containers to ensure new resources inherit permissions +- Owner retains Write, Append, and Control permissions when sharing +- Properly removes public access when share is deleted + +## Future Improvements + +1. **User Storage Selection** + - Allow users to select their storage Pod (currently uses default Pod URL) + - Location: `api/server/services/SolidStorage.js:1619` + - Priority: Low (can be implemented after initial PR) -1. **Conversation Menu Options Implementation** - - Implement Share functionality for Solid storage conversations - - Ensure all operations work seamlessly with Solid storage backend +2. **RDF Parsing Enhancement** + - Use RDF object mapper to parse Turtle format instead of regex patterns + - Location: `api/server/services/SolidStorage.js:1659` + - Priority: Low (current regex parsing works but could be more robust) ## Files Modified @@ -178,12 +203,17 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u - `api/server/utils/import/fork.js` - Added Solid storage support to `duplicateConversation` function - `api/server/routes/convos.js` - Updated duplicate and delete endpoints to pass `req` for Solid storage support - `api/server/services/SolidStorage.js` - Added `isArchived` field support in `saveConvoToSolid` and `getConvosByCursorFromSolid` for archive functionality +- `api/server/services/SolidStorage.js` - Added share functionality: `setPublicAccessForShare`, `removePublicAccessForShare`, `getSharedMessagesFromSolid`, and ACL helper functions (`createPublicAcl`, `updateAclWithPublicAccess`, `grantPublicReadAccess`, `removePublicReadAccess`) +- `packages/data-schemas/src/methods/share.ts` - Updated `createSharedLink`, `getSharedMessages`, `deleteSharedLink`, and `deleteConvoSharedLink` to support Solid storage +- `packages/data-schemas/src/schema/share.ts` - Added `podUrl` field to `ISharedLink` schema +- `packages/data-schemas/src/types/share.ts` - Added `podUrl` field to `ISharedLink` interface +- `api/server/routes/share.js` - Updated share routes to pass `req` object for Solid storage support ## Dependencies Added - `@inrupt/solid-client@^1.30.2` - Solid Pod client library --- -**Report Date**: February 3, 2026 +**Report Date**: February 5, 2026 diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js index 448ce891a7af..eb81113ab8da 100644 --- a/api/server/services/SolidStorage.js +++ b/api/server/services/SolidStorage.js @@ -1656,7 +1656,7 @@ async function getConvosByCursorFromSolid(req, options = {}) { // Parse Turtle format to extract all items from ldp:contains // Handle both single and comma-separated items: ldp:contains , . - // TODO: Use object mapper to parse this. + // TODO: Use RDF object mapper to parse this. const ldpContainsPattern = /ldp:contains\s+((?:<[^>]+>(?:\s*,\s*<[^>]+>)*))/g; const allItems = []; let match; From c7ab28f29139d734415444030f26ea6e1037ef20 Mon Sep 17 00:00:00 2001 From: Precious Oritsedere Date: Fri, 6 Feb 2026 12:18:30 +0000 Subject: [PATCH 24/94] feat: Add separate Solid login button alongside OpenID button - Added SolidIcon component for Solid authentication branding - Added Solid-specific configuration (solidLoginEnabled, solidLabel, solidImageUrl) - Added SOLID_OPENID_* environment variables for Solid authentication - Configure both Solid and generic OpenID strategies independently - Both buttons use the same /oauth/openid route and 'openid' strategy - Update TypeScript types to include Solid configuration fields This allows users to have separate "Login with Solid" and "Login with OpenID" buttons with different labels while maintaining the same authentication flow. The Solid button appears when SOLID_OPENID_* env vars are configured, and the OpenID button appears when OPENID_* env vars are configured (or when Solid is enabled, since they share the same strategy). --- api/server/routes/config.js | 12 ++- api/server/socialLogins.js | 77 ++++++++++++++++++- api/strategies/SolidOpenidStrategy.js | 12 +-- api/strategies/index.js | 1 + .../src/components/Auth/SocialLoginRender.tsx | 18 +++++ packages/client/src/svgs/SolidIcon.tsx | 36 +++++++++ packages/client/src/svgs/index.ts | 1 + packages/data-provider/src/config.ts | 4 + 8 files changed, 150 insertions(+), 11 deletions(-) create mode 100644 packages/client/src/svgs/SolidIcon.tsx diff --git a/api/server/routes/config.js b/api/server/routes/config.js index ccbbaf8a0114..068dbc66d3f1 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -49,6 +49,11 @@ router.get('/', async function (req, res) { !!process.env.OPENID_ISSUER && !!process.env.OPENID_SESSION_SECRET; + const isSolidEnabled = + !!process.env.SOLID_OPENID_CLIENT_ID && + !!process.env.SOLID_OPENID_ISSUER && + !!process.env.SOLID_OPENID_SESSION_SECRET; + const isSamlEnabled = !!process.env.SAML_ENTRY_POINT && !!process.env.SAML_ISSUER && @@ -71,10 +76,15 @@ router.get('/', async function (req, res) { !!process.env.APPLE_TEAM_ID && !!process.env.APPLE_KEY_ID && !!process.env.APPLE_PRIVATE_KEY_PATH, - openidLoginEnabled: isOpenIdEnabled, + // If Solid is enabled but OpenID is not, still show OpenID button since they use the same strategy + openidLoginEnabled: isOpenIdEnabled || isSolidEnabled, openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID', openidImageUrl: process.env.OPENID_IMAGE_URL, openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT), + solidLoginEnabled: isSolidEnabled, + solidLabel: process.env.SOLID_OPENID_BUTTON_LABEL || 'Continue with Solid', + solidImageUrl: process.env.SOLID_OPENID_IMAGE_URL, + solidAutoRedirect: isEnabled(process.env.SOLID_OPENID_AUTO_REDIRECT), samlLoginEnabled: !isOpenIdEnabled && isSamlEnabled, samlLabel: process.env.SAML_BUTTON_LABEL, samlImageUrl: process.env.SAML_IMAGE_URL, diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index a65e996be15a..f23c3207f7dc 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -8,6 +8,7 @@ const { facebookLogin, discordLogin, setupSolidOpenId, + setupOpenId, googleLogin, githubLogin, appleLogin, @@ -15,16 +16,74 @@ const { } = require('~/strategies'); const { getLogStores } = require('~/cache'); + +/** + * Configures Solid OpenID Connect for the application. + * @param {Express.Application} app - The Express application instance. + * @returns {Promise} + */ +async function configureSolidOpenId(app) { + logger.info('Configuring Solid OpenID Connect...'); + const sessionOptions = { + secret: process.env.SOLID_OPENID_SESSION_SECRET, + resave: false, + saveUninitialized: false, + store: getLogStores(CacheKeys.OPENID_SESSION), + }; + app.use(session(sessionOptions)); + app.use(passport.session()); + + const config = await setupSolidOpenId(); + if (!config) { + logger.error('Solid OpenID Connect configuration failed - strategy not registered.'); + return; + } + + if (isEnabled(process.env.OPENID_REUSE_TOKENS)) { + logger.info('Solid OpenID token reuse is enabled.'); + passport.use('solidJwt', openIdJwtLogin(config)); + } + logger.info('Solid OpenID Connect configured successfully.'); +} + /** - * Configures OpenID Connect for the application. +* Configures Solid OpenID Connect for the application. + * @param {Express.Application} app - The Express application instance. + * @returns {Promise} + */ +async function configureSolidOpenId(app) { + logger.info('Configuring Solid OpenID Connect...'); + const sessionOptions = { + secret: process.env.SOLID_OPENID_SESSION_SECRET, + resave: false, + saveUninitialized: false, + store: getLogStores(CacheKeys.OPENID_SESSION), + }; + app.use(session(sessionOptions)); + app.use(passport.session()); + + const config = await setupSolidOpenId(); + if (!config) { + logger.error('Solid OpenID Connect configuration failed - strategy not registered.'); + return; + } + + if (isEnabled(process.env.OPENID_REUSE_TOKENS)) { + logger.info('Solid OpenID token reuse is enabled.'); + passport.use('solidJwt', openIdJwtLogin(config)); + } + logger.info('Solid OpenID Connect configured successfully.'); +} + +/** + * Configures generic OpenID Connect for the application. * @param {Express.Application} app - The Express application instance. * @returns {Promise} */ async function configureOpenId(app) { logger.info('Configuring OpenID Connect...'); - const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; const sessionOptions = { - secret: process.env.OPENID_SESSION_SECRET, + secret: process.env.SOLID_OPENID_SESSION_SECRET, resave: false, saveUninitialized: false, store: getLogStores(CacheKeys.OPENID_SESSION), @@ -36,7 +95,7 @@ async function configureOpenId(app) { app.use(session(sessionOptions)); app.use(passport.session()); - const config = await setupSolidOpenId(); + const config = await setupOpenId(); if (!config) { logger.error('OpenID Connect configuration failed - strategy not registered.'); return; @@ -71,6 +130,16 @@ const configureSocialLogins = async (app) => { if (process.env.APPLE_CLIENT_ID && process.env.APPLE_PRIVATE_KEY_PATH) { passport.use(appleLogin()); } + // Configure Solid OpenID if SOLID_OPENID_* env vars are present + if ( + process.env.SOLID_OPENID_CLIENT_ID && + process.env.SOLID_OPENID_ISSUER && + process.env.SOLID_OPENID_SCOPE && + process.env.SOLID_OPENID_SESSION_SECRET + ) { + await configureSolidOpenId(app); + } + // Configure generic OpenID if OPENID_* env vars are present if ( process.env.OPENID_CLIENT_ID && process.env.OPENID_ISSUER && diff --git a/api/strategies/SolidOpenidStrategy.js b/api/strategies/SolidOpenidStrategy.js index d5ea0a0e4705..0ce3de9332c0 100644 --- a/api/strategies/SolidOpenidStrategy.js +++ b/api/strategies/SolidOpenidStrategy.js @@ -303,8 +303,8 @@ async function setupSolidOpenId() { /** @type {ClientMetadata} */ const clientMetadata = { - client_id: process.env.OPENID_CLIENT_ID, - client_secret: process.env.OPENID_CLIENT_SECRET, + client_id: process.env.SOLID_OPENID_CLIENT_ID, + client_secret: process.env.SOLID_OPENID_CLIENT_SECRET, }; if (shouldGenerateNonce) { @@ -315,8 +315,8 @@ async function setupSolidOpenId() { /** @type {Configuration} */ openidConfig = await client.discovery( - new URL(process.env.OPENID_ISSUER), - process.env.OPENID_CLIENT_ID, + new URL(process.env.SOLID_OPENID_ISSUER), + process.env.SOLID_OPENID_CLIENT_ID, clientMetadata, undefined, { @@ -345,8 +345,8 @@ async function setupSolidOpenId() { const openidLogin = new CustomOpenIDStrategy( { config: openidConfig, - scope: process.env.OPENID_SCOPE, - callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL, + scope: process.env.SOLID_OPENID_SCOPE, + callbackURL: process.env.DOMAIN_SERVER + process.env.SOLID_OPENID_CALLBACK_URL, clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, usePKCE, }, diff --git a/api/strategies/index.js b/api/strategies/index.js index 67c105ca1d78..5ec3c2bcc36b 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -8,6 +8,7 @@ const githubLogin = require('./githubStrategy'); const discordLogin = require('./discordStrategy'); const facebookLogin = require('./facebookStrategy'); const { setupSolidOpenId, getSolidOpenIdConfig } = require('./SolidOpenidStrategy'); +const { setupOpenId, getOpenIdConfig } = require('./openidStrategy'); const jwtLogin = require('./jwtStrategy'); const ldapLogin = require('./ldapStrategy'); const { setupSaml } = require('./samlStrategy'); diff --git a/client/src/components/Auth/SocialLoginRender.tsx b/client/src/components/Auth/SocialLoginRender.tsx index ad76354a5360..b648f7e35d0e 100644 --- a/client/src/components/Auth/SocialLoginRender.tsx +++ b/client/src/components/Auth/SocialLoginRender.tsx @@ -2,6 +2,7 @@ import { GoogleIcon, FacebookIcon, OpenIDIcon, + SolidIcon, GithubIcon, DiscordIcon, AppleIcon, @@ -98,6 +99,23 @@ function SocialLoginRender({ id="openid" /> ), + solid: startupConfig.solidLoginEnabled && ( + + startupConfig.solidImageUrl ? ( + Solid Logo + ) : ( + + ) + } + label={startupConfig.solidLabel} + id="solid" + /> + ), saml: startupConfig.samlLoginEnabled && (