diff --git a/.env.example b/.env.example index 06d509a3ae29..fda1985bec9c 100644 --- a/.env.example +++ b/.env.example @@ -496,6 +496,22 @@ APPLE_KEY_ID= APPLE_PRIVATE_KEY_PATH= APPLE_CALLBACK_URL=/oauth/apple/callback +#SolidOpenID (dynamic: use SOLID_OPENID_PROVIDERS) +# Client ID document is served at DOMAIN_SERVER/solid-client-id; use that URL as clientId for each provider. +# SOLID_OPENID_CLIENT_ID is only needed if your client_id document URL is not DOMAIN_SERVER/solid-client-id. +SOLID_OPENID_CLIENT_ID=http://localhost:3080/solid-client-id +SOLID_OPENID_SESSION_SECRET=[JustGenerateARandomSessionSecret] +# Required: JSON array of Solid IdPs. Each item: issuer, clientId, clientSecret (optional), scope (optional), label (optional). +SOLID_OPENID_PROVIDERS=[{"issuer":"http://localhost:3000/","clientId":"http://localhost:3080/solid-client-id","clientSecret":"","label":"Local CSS"}] +# Optional: add more issuers to the array, e.g. {"issuer":"https://solidcommunity.net/","clientId":"https://your-app.com/solid-client-id","clientSecret":"...","label":"Solid Community"} +SOLID_OPENID_SCOPE="openid webid offline_access" +SOLID_OPENID_CALLBACK_URL=/oauth/openid/callback +SOLID_OPENID_BUTTON_LABEL=Continue with Solid +# Optional: allow users to type any Solid OIDC provider URL in the login modal. +# SOLID_OPENID_CUSTOM_CLIENT_ID= +# SOLID_OPENID_CUSTOM_CLIENT_SECRET= +# SOLID_OPENID_CUSTOM_SCOPE="openid webid offline_access" + # OpenID OPENID_CLIENT_ID= OPENID_CLIENT_SECRET= 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..f681fd15258b 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -2,14 +2,34 @@ const { logger } = require('@librechat/data-schemas'); const { createTempChatExpirationDate } = require('@librechat/api'); const { getMessages, deleteMessages } = require('./Message'); const { Conversation } = require('~/db/models'); +const { isSolidUser } = require('~/server/utils/isSolidUser'); +const { + getConvoFromSolid, + saveConvoToSolid, + getConvosByCursorFromSolid, + deleteConvosFromSolid, +} = require('~/server/services/SolidStorage'); /** * 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 { + // Solid users: use Solid storage only; no MongoDB fallback + if (isSolidUser(req)) { + const convo = await getConvoFromSolid(req, conversationId); + if (convo) { + return { + conversationId: convo.conversationId, + user: convo.user, + }; + } + return null; + } + return await Conversation.findOne({ conversationId }, 'conversationId user').lean(); } catch (error) { logger.error('[searchConversation] Error searching conversation', error); @@ -21,9 +41,16 @@ 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) => { + // Solid users: use Solid storage only; no MongoDB fallback + if (isSolidUser(req)) { + const convo = await getConvoFromSolid(req, conversationId); + return convo ?? null; + } + try { return await Conversation.findOne({ user, conversationId }).lean(); } catch (error) { @@ -87,31 +114,58 @@ module.exports = { * @returns {Promise} The conversation object. */ saveConvo: async (req, { conversationId, newConversationId, ...convo }, metadata) => { - try { - if (metadata?.context) { - logger.debug(`[saveConvo] ${metadata.context}`); + if (metadata?.context) { + logger.debug(`[saveConvo] ${metadata.context}`); + } + + // Build shared payload: expiredAt and base convo fields + let expiredAt = null; + if (req?.body?.isTemporary) { + try { + const appConfig = req.config; + expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig); + } catch (err) { + logger.error('Error creating temporary chat expiration date:', err); + logger.info(`---\`saveConvo\` context: ${metadata?.context}`); } + } + const baseConvo = { + conversationId, + newConversationId, + ...convo, + expiredAt, + }; - const messages = await getMessages({ conversationId }, '_id'); - const update = { ...convo, messages, user: req.user.id }; + if (isSolidUser(req)) { + try { + // Full document aligned with schema (same shape as MongoDB); SolidStorage adds messages + timestamps + const finalConversationId = newConversationId || conversationId; + const convoDocument = { + ...baseConvo, + conversationId: finalConversationId, + user: req.user.id, + }; + if (newConversationId && newConversationId !== conversationId) { + convoDocument.previousConversationId = conversationId; + } + const savedConvo = await saveConvoToSolid(req, convoDocument, metadata); + return savedConvo; + } catch (error) { + logger.error('[saveConvo] Error saving conversation to Solid Pod', error); + if (metadata && metadata?.context) { + logger.info(`[saveConvo] ${metadata.context}`); + } + throw error; + } + } + try { + const messages = await getMessages({ conversationId }, '_id'); + const update = { ...convo, messages, user: req.user.id, expiredAt }; if (newConversationId) { update.conversationId = newConversationId; } - 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(`---\`saveConvo\` context: ${metadata?.context}`); - update.expiredAt = null; - } - } else { - update.expiredAt = null; - } - /** @type {{ $set: Partial; $unset?: Record }} */ const updateOperation = { $set: update }; if (metadata && metadata.unsetFields && Object.keys(metadata.unsetFields).length > 0) { @@ -122,10 +176,7 @@ module.exports = { const conversation = await Conversation.findOneAndUpdate( { conversationId, user: req.user.id }, updateOperation, - { - new: true, - upsert: metadata?.noUpsert !== true, - }, + { new: true, upsert: metadata?.noUpsert !== true }, ); if (!conversation) { @@ -170,8 +221,20 @@ module.exports = { search, sortBy = 'updatedAt', sortDirection = 'desc', + req, // Optional req object for Solid storage } = {}, ) => { + if (isSolidUser(req)) { + return await getConvosByCursorFromSolid(req, { + cursor, + limit, + isArchived, + tags, + search, + sortBy, + sortDirection, + }); + } const filters = [{ user }]; if (isArchived) { filters.push({ isArchived: true }); @@ -228,7 +291,7 @@ module.exports = { }, ], }; - } catch (err) { + } catch (_err) { logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning'); } if (cursorFilter) { @@ -337,6 +400,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. @@ -347,7 +411,71 @@ 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 when user logged in via "Continue with Solid" + if (isSolidUser(req)) { + try { + 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/models/Message.js b/api/models/Message.js index 8fe04f6f5409..59d01a5bbd92 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -2,6 +2,12 @@ const { z } = require('zod'); const { logger } = require('@librechat/data-schemas'); const { createTempChatExpirationDate } = require('@librechat/api'); const { Message } = require('~/db/models'); +const { isSolidUser } = require('~/server/utils/isSolidUser'); +const { + saveMessageToSolid, + updateMessageInSolid, + deleteMessagesFromSolid, +} = require('~/server/services/SolidStorage'); const idSchema = z.string().uuid(); @@ -47,33 +53,53 @@ async function saveMessage(req, params, metadata) { return; } - 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; + // Build shared payload once: messageId, expiredAt, tokenCount + let expiredAt = null; + if (req?.body?.isTemporary) { + try { + const appConfig = req.config; + expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig); + } catch (err) { + logger.error('Error creating temporary chat expiration date:', err); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); } + } + const messageId = params.newMessageId || params.messageId; + let tokenCount = params.tokenCount; + if (tokenCount != null && isNaN(tokenCount)) { + logger.warn( + `Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${tokenCount}`, + ); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); + tokenCount = 0; + } + const baseMessage = { + ...params, + messageId, + expiredAt, + tokenCount: tokenCount ?? 0, + }; - if (update.tokenCount != null && isNaN(update.tokenCount)) { - logger.warn( - `Resetting invalid \`tokenCount\` for message \`${params.messageId}\`: ${update.tokenCount}`, - ); + if (isSolidUser(req)) { + try { + // Full document aligned with schema (same shape as MongoDB); SolidStorage only persists it + const messageDocument = { + ...baseMessage, + user: req.user.id, + createdAt: baseMessage.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const savedMessage = await saveMessageToSolid(req, messageDocument, metadata); + return savedMessage; + } catch (err) { + logger.error('Error saving message to Solid Pod:', err); logger.info(`---\`saveMessage\` context: ${metadata?.context}`); - update.tokenCount = 0; + throw err; } + } + + try { + const update = { ...baseMessage, user: req.user.id }; const message = await Message.findOneAndUpdate( { messageId: params.messageId, user: req.user.id }, update, @@ -237,6 +263,31 @@ 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 when user logged in via "Continue with Solid" + if (isSolidUser(req)) { + try { + 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 +334,21 @@ 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 when user logged in via "Continue with Solid" + if (isSolidUser(req)) { + try { + 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 +375,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..b0a363984d74 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", @@ -70,6 +71,7 @@ "firebase": "^11.0.2", "form-data": "^4.0.4", "handlebars": "^4.7.7", + "http-link-header": "^1.1.3", "https-proxy-agent": "^7.0.6", "ioredis": "^5.3.2", "js-yaml": "^4.1.1", @@ -88,6 +90,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/controllers/AuthController.js b/api/server/controllers/AuthController.js index 13d024cd036f..1c1b7d2751f9 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -18,7 +18,7 @@ const { findUser, } = require('~/models'); const { getGraphApiToken } = require('~/server/services/GraphTokenService'); -const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); +const { getOpenIdConfig, getSolidOpenIdConfig, getOpenIdEmail } = require('~/strategies'); const registrationController = async (req, res) => { try { @@ -64,75 +64,201 @@ const resetPasswordController = async (req, res) => { } }; -const refreshController = async (req, res) => { - const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {}; - const token_provider = parsedCookies.token_provider; +/** + * Shared OpenID/Solid refresh flow: exchange refresh token for new tokenset, find user, update session, return token and user. + * @param {Object} req - Express request + * @param {Object} res - Express response + * @param {Object} openIdConfig - Issuer config from getOpenIdConfig() or getSolidOpenIdConfig() + * @param {string} refreshToken - Refresh token from session or cookie + * @param {Record} [refreshParams] - Optional params for token endpoint (e.g. { scope: process.env.SOLID_OPENID_SCOPE }) + * @param {string} [tokenProvider] - 'solid' or 'openid' from cookie so setOpenIDAuthTokens sets the correct token_provider cookie + * @returns {Promise} True if response was sent, false if caller should continue to next handler + */ +async function performOpenIDRefresh( + req, + res, + openIdConfig, + refreshToken, + refreshParams = {}, + tokenProvider, +) { + try { + const tokenset = await openIdClient.refreshTokenGrant( + openIdConfig, + refreshToken, + Object.keys(refreshParams).length ? refreshParams : undefined, + ); + const claims = tokenset.claims(); + const { user, error, migration } = await findOpenIDUser({ + findUser, + email: getOpenIdEmail(claims), + openidId: claims.sub, + idOnTheSource: claims.oid, + strategyName: 'refreshController', + }); - if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { - /** For OpenID users, read refresh token from session to avoid large cookie issues */ - const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken; + logger.debug( + `[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`, + ); - if (!refreshToken) { - return res.status(200).send('Refresh token not provided'); + if (error || !user) { + logger.warn( + `[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`, + ); + return res.status(401).redirect('/login'); } - try { - const openIdConfig = getOpenIdConfig(); - const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {}; - const tokenset = await openIdClient.refreshTokenGrant( - openIdConfig, - refreshToken, - refreshParams, - ); - const claims = tokenset.claims(); - const { user, error, migration } = await findOpenIDUser({ - findUser, - email: getOpenIdEmail(claims), + // 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: user.provider || 'openid', openidId: claims.sub, - idOnTheSource: claims.oid, - strategyName: 'refreshController', }); - - logger.debug( - `[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${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'); - } + // setOpenIDAuthTokens sets token_provider cookie correctly (solid vs openid) + req.user = user; + if (tokenProvider) { + user.provider = user.provider || tokenProvider; + } + + 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, + }; + + res.status(200).send({ token, user }); + return true; + } catch (error) { + logger.error('[refreshController] OpenID token refresh error', error); + return false; + } +} + +const refreshController = async (req, res) => { + const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {}; + const token_provider = parsedCookies.token_provider; + + // Handle OpenID or Solid users with OPENID_REUSE_TOKENS enabled + const useOpenIDRefresh = + (token_provider === 'openid' || token_provider === 'solid') && + isEnabled(process.env.OPENID_REUSE_TOKENS); - // 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 (useOpenIDRefresh) { + const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken; + + if (!refreshToken) { + // No refresh token (e.g. Solid IdP didn't return one) but we may have a valid session from the OAuth callback + const sessionToken = + req.session?.openidTokens?.idToken || req.session?.openidTokens?.accessToken; + if (sessionToken) { + try { + const payload = jwt.decode(sessionToken); + const sub = payload?.sub; + if (sub) { + const { user, error } = await findOpenIDUser({ + findUser, + email: payload.email, + openidId: sub, + idOnTheSource: payload.oid, + strategyName: 'refreshController', + }); + if (!error && user) { + const token = sessionToken; + logger.debug( + '[refreshController] Returning token from session (no refresh token available)', + ); + return res.status(200).send({ token, user }); + } + } + } catch (err) { + logger.debug('[refreshController] Session token decode/lookup failed', err.message); + } } + logger.warn( + '[refreshController] No OpenID refresh token available, falling back to standard refresh', + ); + return res.status(200).send('Refresh token not provided'); + } - const token = setOpenIDAuthTokens(tokenset, req, res, user._id.toString(), refreshToken); + const openIdConfig = + token_provider === 'solid' + ? (() => { + try { + return getSolidOpenIdConfig(); + } catch (e) { + logger.warn('[refreshController] Solid OpenID config not initialized', { + message: e?.message, + }); + return null; + } + })() + : (() => { + try { + return getOpenIdConfig(); + } catch (e) { + logger.warn('[refreshController] OpenID config not initialized', { + message: e?.message, + }); + return null; + } + })(); - user.federatedTokens = { - access_token: tokenset.access_token, - id_token: tokenset.id_token, - refresh_token: refreshToken, - expires_at: claims.exp, - }; + if (!openIdConfig) { + const sessionToken = + req.session?.openidTokens?.idToken || req.session?.openidTokens?.accessToken; + if (sessionToken) { + try { + const payload = jwt.decode(sessionToken); + const sub = payload?.sub; + if (sub) { + const { user, error } = await findOpenIDUser({ + findUser, + email: payload.email, + openidId: sub, + idOnTheSource: payload.oid, + strategyName: 'refreshController', + }); + if (!error && user) { + return res.status(200).send({ token: sessionToken, user }); + } + } + } catch (err) { + logger.debug('[refreshController] Session token decode/lookup failed', err.message); + } + } + return res.status(200).send('Refresh token not provided'); + } - 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'); + let refreshParams = {}; + if (token_provider === 'solid' && process.env.SOLID_OPENID_SCOPE) { + refreshParams = { scope: process.env.SOLID_OPENID_SCOPE }; + } else if (process.env.OPENID_SCOPE) { + refreshParams = { scope: process.env.OPENID_SCOPE }; } + + const sent = await performOpenIDRefresh( + req, + res, + openIdConfig, + refreshToken, + refreshParams, + token_provider, + ); + if (sent) return; } + /** 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..4ca2426a2cf3 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, - }); + // Emit the final event to all subscribers + GenerationJobManager.emitDone(streamId, finalEvent); + await decrementPendingRequest(userId); - await GenerationJobManager.emitDone(streamId, finalEvent); + // 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); - await decrementPendingRequest(userId); } 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); } @@ -622,7 +630,7 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle let response = await client.sendMessage(text, messageOptions); // Extract what we need and immediately break reference - const messageId = response.messageId; + const _messageId = response.messageId; const endpoint = endpointOption.endpoint; response.endpoint = endpoint; @@ -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/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 039ed630c225..b475b2d8bb5a 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -6,7 +6,9 @@ const { getOpenIdConfig } = require('~/strategies'); const logoutController = async (req, res) => { const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {}; - const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid'; + const isOpenIdUser = + req.user?.openidId != null && + (req.user?.provider === 'openid' || req.user?.provider === 'solid'); /** For OpenID users, read tokens from session (with cookie fallback) */ let refreshToken; diff --git a/api/server/controllers/auth/solidOpenIdDynamic.js b/api/server/controllers/auth/solidOpenIdDynamic.js new file mode 100644 index 000000000000..5e7971404f40 --- /dev/null +++ b/api/server/controllers/auth/solidOpenIdDynamic.js @@ -0,0 +1,224 @@ +/** + * Dynamic Solid OpenID flow: start and callback for multi-issuer (issuer in query / state). + * When frontend sends ?issuer=..., we do discovery for that issuer, store PKCE in session, + * redirect to IdP. On callback we read state (contains issuer), exchange code, verify user. + */ + +const client = require('openid-client'); +const { logger } = require('@librechat/data-schemas'); +const { getSolidOpenIdProviderByIssuer } = require('~/server/services/Config/solidOpenId'); +const { verifySolidUser } = require('~/strategies'); +const undici = require('undici'); + +const SESSION_KEY = 'solidOpenIdPKCE'; +const STATE_PREFIX = 'solid_'; + +/** + * Encode state payload (issuer + random) for round-trip. + * @param {{ issuer: string, rnd: string }} payload + * @returns {string} + */ +function encodeState(payload) { + return STATE_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +} + +/** + * Decode state if it's our format (starts with solid_). + * @param {string} state + * @returns {{ issuer: string, rnd: string } | null} + */ +function decodeState(state) { + if (!state || typeof state !== 'string' || !state.startsWith(STATE_PREFIX)) { + return null; + } + try { + const json = Buffer.from(state.slice(STATE_PREFIX.length), 'base64url').toString('utf8'); + const payload = JSON.parse(json); + if (payload && typeof payload.issuer === 'string') { + return payload; + } + } catch (e) { + logger.debug('[solidOpenIdDynamic] decodeState failed', { error: e?.message }); + } + return null; +} + +async function customFetchForDiscovery(url, options) { + let fetchOptions = options; + if (process.env.PROXY) { + fetchOptions = { ...options, dispatcher: new undici.ProxyAgent(process.env.PROXY) }; + } + return undici.fetch(url, fetchOptions); +} + +const discoveryOptions = { + [client.customFetch]: customFetchForDiscovery, + execute: [client.allowInsecureRequests], +}; + +/** + * Start Solid OpenID flow for the given issuer (from query). Validate issuer, do discovery, + * store PKCE in session, redirect to IdP. + */ +async function startSolidOpenIdFlow(req, res, next) { + const issuer = req.query.issuer; + if (!issuer || typeof issuer !== 'string') { + return next(); + } + + const provider = getSolidOpenIdProviderByIssuer(issuer.trim()); + if (!provider) { + logger.warn('[solidOpenIdDynamic] Unknown or unconfigured issuer', { + issuer: issuer.slice(0, 80), + }); + res + .status(400) + .send( + 'Unknown or unconfigured Solid Identity Provider. Use one of the options from the login page.', + ); + return; + } + + try { + const clientMetadata = { + client_id: provider.clientId, + client_secret: provider.clientSecret || undefined, + }; + const clientAuth = provider.clientSecret + ? client.ClientSecretPost(provider.clientSecret) + : undefined; + + const config = await client.discovery( + new URL(provider.issuer), + provider.clientId, + clientMetadata, + clientAuth, + discoveryOptions, + ); + + const statePayload = { + issuer: provider.issuer, + rnd: client.randomState(), + }; + const state = encodeState(statePayload); + + // Solid-OIDC requires PKCE for authorization code; always use it for this flow. + const usePKCE = true; + let code_verifier; + const authParams = { + redirect_uri: process.env.DOMAIN_SERVER + provider.callbackPath, + scope: provider.scope, + state, + prompt: 'consent', + }; + if (usePKCE) { + code_verifier = client.randomPKCECodeVerifier(); + authParams.code_challenge = await client.calculatePKCECodeChallenge(code_verifier); + authParams.code_challenge_method = 'S256'; + } + + if (!req.session) { + logger.error('[solidOpenIdDynamic] No session available for PKCE storage'); + res.status(500).send('Session required for Solid login.'); + return; + } + if (!req.session[SESSION_KEY]) { + req.session[SESSION_KEY] = {}; + } + req.session[SESSION_KEY][state] = { code_verifier, issuer: provider.issuer }; + req.session.save((err) => { + if (err) { + logger.error('[solidOpenIdDynamic] Session save failed', err); + res.status(500).send('Session error.'); + return; + } + const redirectTo = client.buildAuthorizationUrl(config, authParams); + logger.info('[solidOpenIdDynamic] Redirecting to Solid IdP', { issuer: provider.issuer }); + res.redirect(redirectTo.toString()); + }); + } catch (err) { + logger.error('[solidOpenIdDynamic] startSolidOpenIdFlow failed', err); + next(err); + } +} + +/** + * Handle Solid OpenID callback when state is our format (multi-issuer flow). + * Exchange code for tokens, verify user, set req.user and call next() to run oauthHandler. + */ +async function handleSolidOpenIdCallback(req, res, next) { + const state = req.query.state; + const decoded = decodeState(state); + if (!decoded) { + return next(); + } + + const code = req.query.code; + if (!code) { + const errParam = req.query.error; + const errDesc = req.query.error_description; + logger.warn( + '[solidOpenIdDynamic] Callback missing code - IdP likely rejected the auth request', + { + error: errParam, + error_description: errDesc, + }, + ); + res.redirect(`${process.env.DOMAIN_CLIENT}/login?redirect=false&error=auth_failed`); + return; + } + + const sessionData = req.session && req.session[SESSION_KEY] && req.session[SESSION_KEY][state]; + if (!sessionData || !sessionData.code_verifier) { + logger.warn('[solidOpenIdDynamic] No PKCE data in session for state'); + res.redirect(`${process.env.DOMAIN_CLIENT}/login?redirect=false&error=auth_failed`); + return; + } + + const provider = getSolidOpenIdProviderByIssuer(decoded.issuer); + if (!provider) { + logger.warn('[solidOpenIdDynamic] Callback issuer not configured', { issuer: decoded.issuer }); + res.redirect(`${process.env.DOMAIN_CLIENT}/login?redirect=false&error=auth_failed`); + return; + } + + try { + const clientMetadata = { + client_id: provider.clientId, + client_secret: provider.clientSecret || undefined, + }; + const clientAuth = provider.clientSecret + ? client.ClientSecretPost(provider.clientSecret) + : undefined; + + const config = await client.discovery( + new URL(provider.issuer), + provider.clientId, + clientMetadata, + clientAuth, + discoveryOptions, + ); + + const currentUrl = new URL(req.originalUrl || req.url, process.env.DOMAIN_SERVER); + + const tokenset = await client.authorizationCodeGrant(config, currentUrl, { + expectedState: state, + ...(sessionData.code_verifier && { pkceCodeVerifier: sessionData.code_verifier }), + }); + + delete req.session[SESSION_KEY][state]; + req.session.save(() => {}); + + const user = await verifySolidUser(tokenset, config); + req.user = user; + next(); + } catch (err) { + logger.error('[solidOpenIdDynamic] handleSolidOpenIdCallback failed', err); + res.redirect(`${process.env.DOMAIN_CLIENT}/login?redirect=false&error=auth_failed`); + } +} + +module.exports = { + startSolidOpenIdFlow, + handleSolidOpenIdCallback, +}; diff --git a/api/server/index.js b/api/server/index.js index f034f102367f..6e50d3179280 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); @@ -164,7 +166,27 @@ 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 + // Local CSS (and other Solid IdPs) fetch this URL to get redirect_uris; they must match the redirect_uri we send in the auth request. + app.get('/solid-client-id', (_, res) => { + const callbackPath = + process.env.SOLID_OPENID_CALLBACK_URL || + process.env.OPENID_CALLBACK_URL || + '/oauth/openid/callback'; + const baseUrl = process.env.DOMAIN_SERVER || 'http://localhost:3080'; + const clientId = `${baseUrl}/solid-client-id`; + res.set('Content-Type', 'application/ld+json'); + res.send({ + '@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'], + client_id: clientId, + client_name: process.env.SOLID_OPENID_BUTTON_LABEL || 'LibreChat Solid Client', + redirect_uris: [baseUrl + callbackPath], + scope: process.env.SOLID_OPENID_SCOPE || 'openid webid offline_access', + grant_types: ['refresh_token', 'authorization_code'], + response_types: ['code'], + }); + }); + app.use('/api', apiNotFound); /** SPA fallback - serve index.html for all unmatched routes */ diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index 64ed8e746684..362e50e28736 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -11,7 +11,7 @@ const azureAssistants = require('~/server/services/Endpoints/azureAssistants'); 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 { updateFilesUsage, getConvo } = require('~/models'); const buildFunction = { [EModelEndpoint.agents]: agents.buildOptions, @@ -89,6 +89,21 @@ async function buildEndpointOption(req, res, next) { } } + // If model is missing and we have a conversationId, load the conversation (Solid or MongoDB) to get the model + if (!parsedBody.model && req.body?.conversationId && req.body.conversationId !== 'new') { + try { + const conversation = await getConvo(req.user?.id, req.body.conversationId, req); + if (conversation?.model) { + parsedBody.model = conversation.model; + } + } catch (error) { + logger.warn('[buildEndpointOption] Could not load conversation to extract model', { + conversationId: req.body.conversationId, + error: error.message, + }); + } + } + try { const isAgents = isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]); diff --git a/api/server/middleware/openIdAuthHelpers.js b/api/server/middleware/openIdAuthHelpers.js new file mode 100644 index 000000000000..1376ab366ef1 --- /dev/null +++ b/api/server/middleware/openIdAuthHelpers.js @@ -0,0 +1,20 @@ +/** + * Shared 503 response when OpenID/Solid JWT strategy is not registered. + * Used by requireJwtAuth and optionalJwtAuth to avoid duplicating message logic. + * @param {import('express').Response} res + * @param {'solid' | 'openid'} tokenProvider + * @param {boolean} [afterLazyInit] - True when lazy init was attempted for Solid and still not registered + */ +function sendStrategyNotRegistered503(res, tokenProvider, afterLazyInit = false) { + const name = tokenProvider === 'solid' ? 'Solid' : 'OpenID'; + const message = + afterLazyInit && tokenProvider === 'solid' + ? 'You logged in with Solid, but the server could not register the Solid strategy. Ensure your Solid IdP (e.g. Local CSS) is running and SOLID_OPENID_PROVIDERS (or legacy Solid env) is set.' + : `You logged in with ${name}, but the server does not have the corresponding strategy registered. Please contact the administrator.`; + return res.status(503).json({ + error: 'Authentication not configured', + message, + }); +} + +module.exports = { sendStrategyNotRegistered503 }; diff --git a/api/server/middleware/optionalJwtAuth.js b/api/server/middleware/optionalJwtAuth.js index 2f59fdda4af0..8183216a523e 100644 --- a/api/server/middleware/optionalJwtAuth.js +++ b/api/server/middleware/optionalJwtAuth.js @@ -1,9 +1,12 @@ const cookies = require('cookie'); const passport = require('passport'); const { isEnabled } = require('@librechat/api'); +const { ensureSolidJwtRegisteredLazy } = require('../services/ensureSolidJwt'); +const { sendStrategyNotRegistered503 } = require('./openIdAuthHelpers'); // This middleware does not require authentication, // but if the user is authenticated, it will set the user object. +// When token_provider is solid/openid we always use the corresponding strategy (no fallback). const optionalJwtAuth = (req, res, next) => { const cookieHeader = req.headers.cookie; const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null; @@ -16,9 +19,31 @@ const optionalJwtAuth = (req, res, next) => { } next(); }; - if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { - return passport.authenticate('openidJwt', { session: false }, callback)(req, res, next); + + if (tokenProvider === 'solid' || tokenProvider === 'openid') { + const useOpenIdStrategy = isEnabled(process.env.OPENID_REUSE_TOKENS); + const strategy = tokenProvider === 'solid' ? 'solidJwt' : 'openidJwt'; + const strategyRegistered = + typeof passport._strategies === 'object' && passport._strategies[strategy]; + + if (useOpenIdStrategy && strategyRegistered) { + return passport.authenticate(strategy, { session: false }, callback)(req, res, next); + } + if (useOpenIdStrategy && !strategyRegistered && tokenProvider === 'solid') { + return ensureSolidJwtRegisteredLazy() + .then((registered) => { + if (registered && passport._strategies && passport._strategies.solidJwt) { + return passport.authenticate('solidJwt', { session: false }, callback)(req, res, next); + } + return sendStrategyNotRegistered503(res, tokenProvider, true); + }) + .catch(next); + } + if (useOpenIdStrategy && !strategyRegistered) { + return sendStrategyNotRegistered503(res, tokenProvider); + } } + passport.authenticate('jwt', { session: false }, callback)(req, res, next); }; diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index 16b107aefc5e..0ed13c57c391 100644 --- a/api/server/middleware/requireJwtAuth.js +++ b/api/server/middleware/requireJwtAuth.js @@ -1,20 +1,57 @@ const cookies = require('cookie'); const passport = require('passport'); const { isEnabled } = require('@librechat/api'); +const { ensureSolidJwtRegisteredLazy } = require('../services/ensureSolidJwt'); +const { sendStrategyNotRegistered503 } = require('./openIdAuthHelpers'); /** * Custom Middleware to handle JWT authentication, with support for OpenID token reuse - * Switches between JWT and OpenID authentication based on cookies and environment settings + * When token_provider is solid/openid we always use the corresponding strategy (no fallback). + * For Solid, we try lazy registration once if the strategy wasn't registered at startup (e.g. IdP was down). */ const requireJwtAuth = (req, res, next) => { const cookieHeader = req.headers.cookie; const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null; - if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) { - return passport.authenticate('openidJwt', { session: false })(req, res, next); + if (tokenProvider === 'solid' || tokenProvider === 'openid') { + const useOpenIdStrategy = isEnabled(process.env.OPENID_REUSE_TOKENS); + const strategy = tokenProvider === 'solid' ? 'solidJwt' : 'openidJwt'; + const strategyRegistered = + typeof passport._strategies === 'object' && passport._strategies[strategy]; + + if (useOpenIdStrategy && strategyRegistered) { + return passport.authenticate(strategy, { session: false })(req, res, next); + } + if (useOpenIdStrategy && !strategyRegistered && tokenProvider === 'solid') { + return ensureSolidJwtRegisteredLazy() + .then((registered) => { + if (registered && passport._strategies && passport._strategies.solidJwt) { + return passport.authenticate('solidJwt', { session: false })(req, res, next); + } + return sendStrategyNotRegistered503(res, tokenProvider, true); + }) + .catch(next); + } + if (useOpenIdStrategy && !strategyRegistered) { + return sendStrategyNotRegistered503(res, tokenProvider); + } } - return passport.authenticate('jwt', { session: false })(req, res, next); + return passport.authenticate('jwt', { session: false }, (err, user, _info) => { + if (err) { + return res.status(401).json({ error: 'Authentication failed', message: err.message }); + } + if (!user) { + 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/validate/convoAccess.js b/api/server/middleware/validate/convoAccess.js index 127bfdc53026..4f495cd1d62f 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,7 +52,7 @@ const validateConvoAccess = async (req, res, next) => { } } - const conversation = await searchConversation(conversationId); + const conversation = await searchConversation(conversationId, req); if (!conversation) { return next(); @@ -74,7 +75,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/middleware/validateMessageReq.js b/api/server/middleware/validateMessageReq.js index 430444a17278..e0a282b4cbb4 100644 --- a/api/server/middleware/validateMessageReq.js +++ b/api/server/middleware/validateMessageReq.js @@ -1,6 +1,7 @@ const { getConvo } = require('~/models'); -// Middleware to validate conversationId and user relationship +// Middleware to validate conversationId and user relationship. +// getConvo(user, conversationId, req) handles Solid vs MongoDB internally. const validateMessageReq = async (req, res, next) => { let conversationId = req.params.conversationId || req.body.conversationId; @@ -12,8 +13,7 @@ const validateMessageReq = async (req, res, next) => { conversationId = req.body.message.conversationId; } - const conversation = await getConvo(req.user.id, conversationId); - + const conversation = await getConvo(req.user.id, conversationId, req); if (!conversation) { return res.status(404).json({ error: 'Conversation not found' }); } diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index 931ef006d00e..5ea91091ffb1 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -151,7 +151,7 @@ describe('Convos Routes', () => { expect(response.body).toEqual(mockDbResponse); /** Verify deleteConvos was called with correct userId */ - expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {}); + expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {}, expect.anything()); expect(deleteConvos).toHaveBeenCalledTimes(1); /** Verify deleteToolCalls was called with correct userId */ @@ -325,15 +325,23 @@ describe('Convos Routes', () => { expect(response.body).toEqual(mockDbResponse); /** Verify deleteConvos was called with correct parameters */ - expect(deleteConvos).toHaveBeenCalledWith('test-user-123', { - conversationId: mockConversationId, - }); + expect(deleteConvos).toHaveBeenCalledWith( + 'test-user-123', + { + conversationId: mockConversationId, + }, + expect.anything(), + ); /** Verify deleteToolCalls was called */ expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123', mockConversationId); /** Verify deleteConvoSharedLink was called */ - expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId); + expect(deleteConvoSharedLink).toHaveBeenCalledWith( + 'test-user-123', + mockConversationId, + expect.anything(), + ); }); it('should not call deleteConvoSharedLink when no conversationId provided', async () => { @@ -371,7 +379,11 @@ describe('Convos Routes', () => { }); expect(response.status).toBe(201); - expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId); + expect(deleteConvoSharedLink).toHaveBeenCalledWith( + 'test-user-123', + mockConversationId, + expect.anything(), + ); }); it('should return 400 when no parameters provided', async () => { @@ -489,7 +501,11 @@ describe('Convos Routes', () => { expect(response.status).toBe(201); /** Verify shared links were deleted for the specific conversation */ - expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId); + expect(deleteConvoSharedLink).toHaveBeenCalledWith( + 'test-user-123', + mockConversationId, + expect.anything(), + ); /** Verify it was called after the conversation was deleted */ expect(deleteConvoSharedLink).toHaveBeenCalledAfter(deleteConvos); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a2dc5b79d27f..aaa0a0b18143 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -3,6 +3,12 @@ const { logger } = require('@librechat/data-schemas'); const { isEnabled, getBalanceConfig } = require('@librechat/api'); const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider'); const { getLdapConfig } = require('~/server/services/Config/ldap'); +const { + getSolidOpenIdProviders, + isSolidOpenIdEnabled, + normalizeIssuer, + DEFAULT_ISSUER_OPTIONS, +} = require('~/server/services/Config/solidOpenId'); const { getAppConfig } = require('~/server/services/Config/app'); const { getProjectByName } = require('~/models/Project'); const { getLogStores } = require('~/cache'); @@ -46,10 +52,11 @@ 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; + const isSolidEnabled = isSolidOpenIdEnabled(); + const isSamlEnabled = !!process.env.SAML_ENTRY_POINT && !!process.env.SAML_ISSUER && @@ -58,10 +65,15 @@ router.get('/', async function (req, res) { const balanceConfig = getBalanceConfig(appConfig); + let socialLogins = appConfig?.registration?.socialLogins ?? defaultSocialLogins; + if (isSolidEnabled && !socialLogins.includes('solid')) { + socialLogins = [...socialLogins, 'solid']; + } + /** @type {TStartupConfig} */ const payload = { appTitle: process.env.APP_TITLE || 'LibreChat', - socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, + socialLogins, discordLoginEnabled: !!process.env.DISCORD_CLIENT_ID && !!process.env.DISCORD_CLIENT_SECRET, facebookLoginEnabled: !!process.env.FACEBOOK_CLIENT_ID && !!process.env.FACEBOOK_CLIENT_SECRET, @@ -76,6 +88,21 @@ router.get('/', async function (req, res) { 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), + solidIdpOptions: isSolidEnabled + ? [ + ...DEFAULT_ISSUER_OPTIONS, + ...getSolidOpenIdProviders() + .filter( + (p) => !DEFAULT_ISSUER_OPTIONS.some((d) => normalizeIssuer(d.issuer) === p.issuer), + ) + .map((p) => ({ issuer: p.issuer, label: p.label })), + ] + : [], + solidCustomEnabled: !!process.env.SOLID_OPENID_CUSTOM_CLIENT_ID, samlLoginEnabled: !isOpenIdEnabled && isSamlEnabled, samlLabel: process.env.SAML_BUTTON_LABEL, samlImageUrl: process.env.SAML_IMAGE_URL, diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index bb9c4ebea95a..081206103443 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -18,6 +18,7 @@ const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const { importConversations } = require('~/server/utils/import'); const { deleteToolCalls } = require('~/models/ToolCall'); const getLogStores = require('~/cache/getLogStores'); +const { isSolidUser } = require('~/server/utils/isSolidUser'); const assistantClients = { [EModelEndpoint.azureAssistants]: require('~/server/services/Endpoints/azureAssistants'), @@ -44,6 +45,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 +54,53 @@ 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, + isSolidUser: isSolidUser(req), + }); + 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, + isSolidUser: isSolidUser(req), + }); + + 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 }); } }); @@ -128,10 +164,10 @@ 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); + await deleteConvoSharedLink(req.user.id, filter.conversationId, req); } res.status(201).json(dbResponse); } catch (error) { @@ -142,7 +178,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); @@ -288,6 +324,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/routes/messages.js b/api/server/routes/messages.js index c208e9c40673..01d7fdb871ec 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -15,6 +15,8 @@ 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 { isSolidUser } = require('~/server/utils/isSolidUser'); const router = express.Router(); router.use(requireJwtAuth); @@ -40,28 +42,82 @@ 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 }; + if (isSolidUser(req)) { + 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', error); + return res.status(503).json({ + error: 'Failed to load from Solid Pod', + message: 'Solid storage is temporarily unavailable. Please try again.', + }); + } + } 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 when user logged in via "Continue with Solid" + if (isSolidUser(req)) { + 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', error); + return res.status(503).json({ + error: 'Failed to load from Solid Pod', + message: 'Solid storage is temporarily unavailable. Please try again.', + }); + } + } else { + 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,6 +339,23 @@ router.post('/artifact/:messageId', async (req, res) => { router.get('/:conversationId', validateMessageReq, async (req, res) => { try { const { conversationId } = req.params; + + if (isSolidUser(req)) { + try { + const messages = await getMessagesFromSolid(req, conversationId); + const cleanedMessages = messages.map((msg) => { + const { _id, __v, user: _user, ...rest } = msg; + return rest; + }); + return res.status(200).json(cleanedMessages); + } catch (error) { + logger.error('Error getting messages from Solid Pod', error); + return res.status(503).json({ + error: 'Failed to load from Solid Pod', + message: 'Solid storage is temporarily unavailable. Please try again.', + }); + } + } const messages = await getMessages({ conversationId }, '-_id -__v -user'); res.status(200).json(messages); } catch (error) { diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index f4bb5b6026e6..8e00f1df8ff1 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -4,9 +4,16 @@ const passport = require('passport'); const { randomState } = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { ErrorTypes } = require('librechat-data-provider'); -const { createSetBalanceConfig } = require('@librechat/api'); -const { checkDomainAllowed, loginLimiter, logHeaders } = require('~/server/middleware'); -const { createOAuthHandler } = require('~/server/controllers/auth/oauth'); +const { createSetBalanceConfig, isEnabled } = require('@librechat/api'); +const { checkDomainAllowed, loginLimiter, logHeaders, checkBan } = require('~/server/middleware'); +const { createOAuthHandler: _createOAuthHandler } = require('~/server/controllers/auth/oauth'); +const { + startSolidOpenIdFlow, + handleSolidOpenIdCallback, +} = require('~/server/controllers/auth/solidOpenIdDynamic'); +const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService'); +const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService'); +const { startBaseStructureAfterLogin } = require('~/server/services/SolidStorage'); const { getAppConfig } = require('~/server/services/Config'); const { Balance } = require('~/db/models'); @@ -25,7 +32,59 @@ 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' || req.user.provider === 'solid') && + // 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); + } + // Ensure Solid Pod base structure in background so writes don't call it every time (baton) + if (req.user.openidId) { + startBaseStructureAfterLogin(req).catch((err) => + logger.warn('[oauthHandler] Solid base structure init after login failed', { + openidId: req.user.openidId, + error: err?.message, + }), + ); + } + } 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. */ @@ -89,21 +148,63 @@ router.get( /** * OpenID Routes + * When ?issuer= is present, use dynamic Solid multi-issuer flow; otherwise Passport (single-issuer or generic OpenID). */ -router.get('/openid', (req, res, next) => { +router.get('/openid', startSolidOpenIdFlow, (req, res, next) => { return passport.authenticate('openid', { session: false, state: randomState(), })(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, + error_description: req.query.error_description, + state: state || 'not provided', + }); + } else { + logger.warn('[OpenID Callback] No authorization code or error in callback', { + queryParams: req.query, + }); + } + + next(); +}; + router.get( '/openid/callback', - passport.authenticate('openid', { - failureRedirect: `${domains.client}/oauth/error`, - failureMessage: true, - session: false, - }), + logAuthorizationCode, + handleSolidOpenIdCallback, + (req, res, next) => { + if (req.user) { + return next(); + } + return passport.authenticate('openid', { + failureRedirect: `${domains.client}/oauth/error`, + failureMessage: true, + session: false, + })(req, res, next); + }, setBalanceConfig, checkDomainAllowed, oauthHandler, diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 6400b8b637a5..bb373443f1c3 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -100,15 +100,36 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { router.post('/:conversationId', requireJwtAuth, async (req, res) => { try { 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'); + return res.status(500).json({ message: 'Share service not available' }); + } + + const created = await createSharedLink( + req.user.id, + req.params.conversationId, + targetMessageId, + req, + ); + 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' }); + logger.error('Error creating shared link:', { + error: error?.message || String(error), + errorName: error?.name, + errorCode: error?.code, + stack: error?.stack, + userId: req.user?.id, + conversationId: req.params?.conversationId, + }); + const errorMessage = error?.message || error?.code || 'Error creating shared link'; + res.status(500).json({ message: errorMessage }); } }); @@ -128,7 +149,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/AuthService.js b/api/server/services/AuthService.js index ef50a365b9ae..0aaa8b015716 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -448,9 +448,13 @@ 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; + logger.warn('[setOpenIDAuthTokens] No refresh token available - will use access token only', { + hasAccessToken: !!tokenset.access_token, + provider: req.user?.provider, + }); } /** @@ -463,37 +467,73 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = const appAuthToken = tokenset.id_token || tokenset.access_token; /** - * Always set refresh token cookie so it survives express session expiry. - * The session cookie maxAge (SESSION_EXPIRY, default 15 min) is typically shorter - * than the OIDC token lifetime (~1 hour). Without this cookie fallback, the refresh - * token stored only in the session is lost when the session expires, causing the user - * to be signed out on the next token refresh attempt. - * The refresh token is small (opaque string) so it doesn't hit the HTTP/2 header - * size limits that motivated session storage for the larger access_token/id_token. + * Set refresh token cookie when available so it survives express session expiry. + * Some providers (e.g. Solid) may not issue refresh tokens; then we rely on session only. */ - res.cookie('refreshToken', refreshToken, { - expires: expirationDate, - httpOnly: true, - secure: shouldUseSecureCookie(), - sameSite: 'strict', - }); + if (refreshToken) { + res.cookie('refreshToken', refreshToken, { + expires: expirationDate, + httpOnly: true, + secure: shouldUseSecureCookie(), + sameSite: 'strict', + }); + } /** Store tokens server-side in session to avoid large cookies */ if (req.session) { 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(), }; + + // Set id_token cookie as well so first API request (before frontend refresh sets Authorization header) can authenticate + if (tokenset.id_token) { + res.cookie('openid_id_token', tokenset.id_token, { + expires: expirationDate, + httpOnly: true, + secure: shouldUseSecureCookie(), + sameSite: 'strict', + }); + } + + // 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: shouldUseSecureCookie(), + sameSite: 'strict', + }); + } res.cookie('openid_access_token', tokenset.access_token, { expires: expirationDate, httpOnly: true, secure: shouldUseSecureCookie(), sameSite: 'strict', }); + if (tokenset.id_token) { res.cookie('openid_id_token', tokenset.id_token, { expires: expirationDate, @@ -502,10 +542,15 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) = sameSite: 'strict', }); } + logger.info('[setOpenIDAuthTokens] Tokens stored in cookies', { + hasAccessToken: !!tokenset.access_token, + hasRefreshToken: !!refreshToken, + }); } - /** Small cookie to indicate token provider (required for auth middleware) */ - res.cookie('token_provider', 'openid', { + /** Small cookie to indicate token provider (required for auth middleware and refresh flow) */ + const tokenProvider = req.user?.provider || 'openid'; + res.cookie('token_provider', tokenProvider, { expires: expirationDate, httpOnly: true, secure: shouldUseSecureCookie(), diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index da78f8d7752e..c072a25fb53c 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -53,7 +53,15 @@ function mockResponse() { /** Helper to build a mock Express request with session */ function mockRequest(sessionData = {}) { return { - session: { openidTokens: null, ...sessionData }, + session: { + openidTokens: null, + ...sessionData, + save: jest.fn((callback) => { + if (typeof callback === 'function') { + callback(); + } + }), + }, }; } @@ -245,12 +253,15 @@ describe('setOpenIDAuthTokens', () => { expect(result).toBeUndefined(); }); - it('should return undefined when no refresh token is available', () => { + it('should return id_token when no refresh token is available (session fallback)', () => { const tokenset = { access_token: 'access', id_token: 'id' }; const req = mockRequest(); const res = mockResponse(); const result = setOpenIDAuthTokens(tokenset, req, res, 'user-123'); - expect(result).toBeUndefined(); + expect(result).toBe('id'); + expect(req.session.openidTokens.accessToken).toBe('access'); + expect(req.session.openidTokens.idToken).toBe('id'); + expect(req.session.openidTokens.refreshToken == null).toBe(true); }); it('should use existingRefreshToken when tokenset has no refresh_token', () => { diff --git a/api/server/services/Config/solidOpenId.js b/api/server/services/Config/solidOpenId.js new file mode 100644 index 000000000000..6874ec2da00d --- /dev/null +++ b/api/server/services/Config/solidOpenId.js @@ -0,0 +1,188 @@ +/** + * Solid OpenID provider configuration. + * Supports multiple issuers via SOLID_OPENID_PROVIDERS (JSON array). + */ + +const defaultScope = process.env.SOLID_OPENID_SCOPE || 'openid webid offline_access'; +const defaultCallbackPath = process.env.SOLID_OPENID_CALLBACK_URL || '/oauth/openid/callback'; + +/** + * Default display labels for known issuers (used when provider config has no label). + */ +const KNOWN_ISSUER_LABELS = { + 'http://localhost:3000/': 'Local CSS', + 'http://localhost:3000': 'Local CSS', + 'https://solidcommunity.net/': 'Solid Community', + 'https://solidcommunity.net': 'Solid Community', + 'https://login.inrupt.com/': 'Inrupt', + 'https://login.inrupt.com': 'Inrupt', +}; + +/** Issuer URLs for the 3 default options shown in the login modal. */ +const DEFAULT_ISSUER_OPTIONS = [ + { issuer: 'https://solidcommunity.net/', label: 'Solid Community' }, + { issuer: 'https://login.inrupt.com/', label: 'Inrupt' }, +]; + +/** + * Normalize issuer URL for comparison (trailing slash, no fragment). + * @param {string} issuer + * @returns {string} + */ +function normalizeIssuer(issuer) { + if (!issuer || typeof issuer !== 'string') { + return ''; + } + const u = issuer.trim(); + if (!u) { + return ''; + } + try { + const url = new URL(u.startsWith('http') ? u : `https://${u}`); + url.hash = ''; + let path = url.pathname; + if (!path.endsWith('/')) { + path += '/'; + } + return `${url.origin}${path}`; + } catch { + return u; + } +} + +/** + * Get the list of configured Solid OpenID providers. + * Each provider has: issuer, clientId, clientSecret, scope, label, callbackPath. + * Configure via SOLID_OPENID_PROVIDERS (JSON array); each entry may have + * issuer, clientId, clientSecret, scope (optional), label (optional). + * + * @returns {{ issuer: string, clientId: string, clientSecret: string, scope: string, label: string, callbackPath: string }[]} + */ +function getSolidOpenIdProviders() { + const providers = []; + + if (process.env.SOLID_OPENID_PROVIDERS) { + try { + const parsed = JSON.parse(process.env.SOLID_OPENID_PROVIDERS); + if (!Array.isArray(parsed)) { + return providers; + } + for (const p of parsed) { + const issuer = normalizeIssuer(p.issuer || p.url); + if (!issuer || !p.clientId) { + continue; + } + const label = + p.label || + KNOWN_ISSUER_LABELS[issuer] || + KNOWN_ISSUER_LABELS[issuer.replace(/\/$/, '')] || + issuer; + providers.push({ + issuer, + clientId: String(p.clientId).trim(), + clientSecret: (p.clientSecret && String(p.clientSecret).trim()) || '', + scope: (p.scope && String(p.scope).trim()) || defaultScope, + label, + callbackPath: (p.callbackPath && String(p.callbackPath).trim()) || defaultCallbackPath, + }); + } + } catch (_e) { + // Invalid JSON + } + } + + return providers; +} + +/** + * Get provider list for solidJwt registration (startup or lazy). + * When SOLID_OPENID_PROVIDERS is empty but SOLID_OPENID_CUSTOM_CLIENT_ID is set, returns one synthetic + * provider for Local CSS so we can still register solidJwt (e.g. user picked "Local CSS" from modal). + * @returns {{ issuer: string, clientId: string, clientSecret: string, scope: string, label: string, callbackPath: string }[]} + */ +function getSolidOpenIdProvidersForJwt() { + const list = getSolidOpenIdProviders(); + if (list.length > 0) { + return list; + } + if (process.env.SOLID_OPENID_CUSTOM_CLIENT_ID) { + return [ + { + issuer: 'http://localhost:3000/', + clientId: process.env.SOLID_OPENID_CUSTOM_CLIENT_ID, + clientSecret: process.env.SOLID_OPENID_CUSTOM_CLIENT_SECRET || '', + scope: process.env.SOLID_OPENID_CUSTOM_SCOPE || defaultScope, + label: 'Local CSS', + callbackPath: defaultCallbackPath, + }, + ]; + } + return []; +} + +/** + * Check if an issuer URL is allowed for "custom" (https or http localhost). + * @param {string} normalizedIssuer + * @returns {boolean} + */ +function isAllowedCustomIssuer(normalizedIssuer) { + if (!normalizedIssuer) return false; + try { + const url = new URL(normalizedIssuer); + if (url.protocol === 'https:') return true; + if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) + return true; + return false; + } catch { + return false; + } +} + +/** + * Get provider config by issuer (must match normalized issuer). + * If not in configured list, and SOLID_OPENID_CUSTOM_CLIENT_ID is set, returns a synthetic + * "custom" provider for any allowed issuer URL (https or http localhost). + * @param {string} issuer + * @returns {{ issuer: string, clientId: string, clientSecret: string, scope: string, label: string, callbackPath: string } | null} + */ +function getSolidOpenIdProviderByIssuer(issuer) { + const normalized = normalizeIssuer(issuer); + const providers = getSolidOpenIdProviders(); + const configured = providers.find((p) => p.issuer === normalized); + if (configured) return configured; + + // Custom issuer: use SOLID_OPENID_CUSTOM_* if set and URL is allowed + if (process.env.SOLID_OPENID_CUSTOM_CLIENT_ID && isAllowedCustomIssuer(normalized)) { + return { + issuer: normalized, + clientId: process.env.SOLID_OPENID_CUSTOM_CLIENT_ID, + clientSecret: process.env.SOLID_OPENID_CUSTOM_CLIENT_SECRET || '', + scope: process.env.SOLID_OPENID_CUSTOM_SCOPE || defaultScope, + label: + KNOWN_ISSUER_LABELS[normalized] || + KNOWN_ISSUER_LABELS[normalized.replace(/\/$/, '')] || + 'Custom', + callbackPath: defaultCallbackPath, + }; + } + return null; +} + +/** + * Check if Solid OpenID is enabled (at least one provider or custom credentials configured). + * @returns {boolean} + */ +function isSolidOpenIdEnabled() { + return getSolidOpenIdProviders().length > 0 || !!process.env.SOLID_OPENID_CUSTOM_CLIENT_ID; +} + +module.exports = { + getSolidOpenIdProviders, + getSolidOpenIdProvidersForJwt, + getSolidOpenIdProviderByIssuer, + isSolidOpenIdEnabled, + normalizeIssuer, + defaultScope, + defaultCallbackPath, + DEFAULT_ISSUER_OPTIONS, +}; diff --git a/api/server/services/SolidStorage.js b/api/server/services/SolidStorage.js new file mode 100644 index 000000000000..f94ffc3a6d06 --- /dev/null +++ b/api/server/services/SolidStorage.js @@ -0,0 +1,3016 @@ +const { logger } = require('@librechat/data-schemas'); +const { DataFactory, Writer, Parser } = require('n3'); +const LinkHeader = require('http-link-header'); + +// LDP (Linked Data Platform) namespace for parsing container Turtle responses +const LDP_NS = 'http://www.w3.org/ns/ldp#'; + +/** + * Parse Turtle RDF and extract all ldp:contains object URLs. + * Uses N3 parser instead of regex for correct handling of Turtle format. + * @param {string} text - Raw Turtle response body + * @param {string} baseUrl - Base IRI for resolving relative URLs + * @returns {string[]} Absolute URLs of contained resources + */ +function parseLdpContainsFromTurtle(text, baseUrl) { + const parser = new Parser({ baseIRI: baseUrl }); + const quads = parser.parse(text); + return quads + .filter((q) => q.predicate.value === `${LDP_NS}contains`) + .map((q) => q.object) + .filter((obj) => obj && obj.termType === 'NamedNode') + .map((obj) => (obj.value.startsWith('http') ? obj.value : new URL(obj.value, baseUrl).href)); +} +const { + getFile, + saveFileInContainer, + overwriteFile, + deleteFile, + getPodUrlAll, + createContainerAt, + getSolidDataset, + getThing, + getUrl, +} = 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/'; +/** PIM Space vocabulary: Storage type (Link header) and storage predicate (profile data) */ +const PIM_SPACE_STORAGE_TYPE = 'http://www.w3.org/ns/pim/space#Storage'; +const PIM_SPACE_STORAGE_PREDICATE = 'http://www.w3.org/ns/pim/space#storage'; + +/** @param {string} url - @returns {string} URL with trailing slash */ +function ensureTrailingSlash(url) { + return url.endsWith('/') ? url : `${url}/`; +} + +/** + * 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; + } +} + +/** + * Discover Pod root from a resource URL using Solid conventions (Link header, profile storage, path walk). + * Based on https://github.com/SolidLabResearch/Bashlib/blob/80de25cbb4b3ed057f95e25bc057f1be9b00cef3/src/utils/util.ts getPodRoot. + * Uses http-link-header (same as Bashlib) for Link header parsing. + * + * Real servers (e.g. solidcommunity.net): profile document has Link headers but no Storage type; + * the Pod root (e.g. https://user.solidcommunity.net/) returns Link: ; rel="type". + * The profile Turtle has space:storage on the #me subject (WebID), not on the document URL. + * + * @param {string} url - Resource URL (e.g. WebID document URL without fragment) for HEAD/path walk + * @param {Function} fetch - Authenticated fetch function + * @param {string} [webId] - Full WebID (with #me) so we can read space:storage from the profile subject + * @returns {Promise} Pod root URL with trailing slash, or null if not found + */ +async function getPodRoot(url, fetch, webId) { + if (!url || !fetch) return null; + try { + const res = await fetch(url, { method: 'HEAD' }); + if (!res.ok) return null; + + const linkHeaders = res.headers.get('Link'); + if (linkHeaders) { + const parsed = LinkHeader.parse(linkHeaders); + for (const ref of parsed.refs) { + const isStorageType = + ref.rel === 'type' && + (ref.uri === PIM_SPACE_STORAGE_TYPE || ref.type === PIM_SPACE_STORAGE_TYPE); + if (isStorageType) { + const podUrl = ensureTrailingSlash(url); + logger.debug('[SolidStorage] Pod root from Link header', { url, podUrl }); + return podUrl; + } + } + } + + try { + const ds = await getSolidDataset(url, { fetch }); + // space:storage is on the WebID subject (#me), not the document URL (see solidcommunity.net profile/card) + const thing = webId ? getThing(ds, webId) : getThing(ds, url); + const storageUrl = thing ? getUrl(thing, PIM_SPACE_STORAGE_PREDICATE) : null; + if (storageUrl) { + const podUrl = ensureTrailingSlash(storageUrl); + logger.debug('[SolidStorage] Pod root from profile storage predicate', { url, podUrl }); + return podUrl; + } + } catch (_ignored) { + // Not a Solid dataset or no storage pointer + } + + const splitUrl = url.split('/'); + const index = url.endsWith('/') ? splitUrl.length - 2 : splitUrl.length - 1; + if (index < 0) return null; + const nextUrl = splitUrl.slice(0, index).join('/') + '/'; + if (nextUrl === url) return null; // avoid infinite loop when at server root + return getPodRoot(nextUrl, fetch, webId); + } catch (err) { + logger.debug('[SolidStorage] getPodRoot failed for url', { url, error: err?.message }); + return null; + } +} + +/** + * 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, use Solid discovery (Link header + profile storage + path walk), then path heuristic + if (!podUrls || podUrls.length === 0) { + logger.info('[SolidStorage] No Pod URLs found in profile, deriving from WebID', { webId }); + + try { + const webIdUrl = new URL(webId); + // Resource URL = WebID without fragment (e.g. http://localhost:3000/bisi/profile/card) + const resourceUrl = webIdUrl.hash + ? webIdUrl.href.replace(webIdUrl.hash, '') + : webIdUrl.href; + + // 1) Solid discovery: Link header, then profile storage predicate, then path walk (Bashlib getPodRoot) + // Pass webId so we can read space:storage from the #me subject in the profile document + let derivedPodUrl = await getPodRoot(resourceUrl, fetch, webId); + if (derivedPodUrl) { + logger.info('[SolidStorage] Derived Pod URL via Solid discovery', { + webId, + derivedPodUrl, + }); + return derivedPodUrl; + } + + // 2) Fallback: path-based heuristic (WebID format: .../podId/profile/card#me -> Pod at .../podId/) + const pathParts = webIdUrl.pathname.split('/').filter((p) => p); + let podPath = '/'; + if (pathParts.length > 0) { + const podIdentifier = pathParts[0]; + if (podIdentifier && podIdentifier !== 'profile' && podIdentifier !== 'card') { + podPath = `/${podIdentifier}/`; + } + } + derivedPodUrl = `${webIdUrl.protocol}//${webIdUrl.host}${podPath}`; + logger.info('[SolidStorage] Derived Pod URL from path heuristic', { + webId, + derivedPodUrl, + pathParts, + }); + + // Verify the derived Pod URL is accessible + try { + const response = await fetch(derivedPodUrl, { method: 'HEAD' }); + if (response.ok || response.status === 401 || response.status === 403) { + 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 authenticated fetch and Pod URL for the current Solid user, with session cache. + * Pod URL is fetched once per session (e.g. at first use after login) to avoid repeated profile requests. + * + * @param {Object} req - Express request (must have req.user.openidId and session) + * @returns {Promise<{ authenticatedFetch: Function, podUrl: string }>} + */ +async function getSolidFetchAndPodUrl(req) { + const openidId = req?.user?.openidId; + if (!openidId) { + throw new Error('User not authenticated with Solid/OpenID'); + } + + const authenticatedFetch = await getSolidFetch(req); + const podUrl = req.session?.solidCachedPodUrl ?? (await getPodUrl(openidId, authenticatedFetch)); + if (req.session) { + req.session.solidCachedPodUrl = podUrl; + req.session.solidCachedPodUrlWebId = openidId; + } + return { authenticatedFetch, podUrl }; +} + +/** + * 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) { + const err = new Error('Container not found'); + err.status = 404; + err.response = { status: 404 }; + throw err; + } + } catch (error) { + // Container doesn't exist, create it (use status only) + if (error?.status === 404 || error?.response?.status === 404) { + 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?.response?.status === 409) { + 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; + } +} + +/** Per-user baton: openidId -> Promise that resolves when ensureBaseStructure has completed for that Pod */ +const baseStructureBatonMap = new Map(); + +/** + * Waits for the Pod base structure to be ready for this user (baton). + * If login already started ensureBaseStructure in the background, we wait for it. + * Otherwise we run it once (lazy) and cache the promise so other writes wait too. + * + * @param {Object} req - Express request (must have req.user.openidId) + * @returns {Promise} + */ +async function ensureBaseStructureReady(req) { + const openidId = req?.user?.openidId; + if (!openidId) { + throw new Error('User not authenticated with Solid/OpenID'); + } + let promise = baseStructureBatonMap.get(openidId); + if (!promise) { + const { authenticatedFetch, podUrl } = await getSolidFetchAndPodUrl(req); + promise = ensureBaseStructure(podUrl, authenticatedFetch).catch((err) => { + baseStructureBatonMap.delete(openidId); + throw err; + }); + baseStructureBatonMap.set(openidId, promise); + } + await promise; +} + +/** + * Starts ensureBaseStructure in the background for this user (call after Solid/OpenID login). + * Writes will wait for this to complete via ensureBaseStructureReady (baton). + * Safe to call multiple times per user; only the first run is used. + * + * @param {Object} req - Express request (must have req.user with openidId and session for fetch) + */ +function startBaseStructureAfterLogin(req) { + const openidId = req?.user?.openidId; + if (!openidId) return Promise.resolve(); + return ensureBaseStructureReady(req); +} + +/** + * Save a message to Solid Pod. + * Document content is built in the model layer (schema-aligned); this only persists it. + * + * @param {Object} req - Express request object + * @param {Object} messageDocument - Full message document (same shape as MongoDB / IMessage) + * @param {Object} [metadata] - Additional metadata + * @returns {Promise} Saved message document + */ +async function saveMessageToSolid(req, messageDocument, metadata) { + try { + logger.info('[SolidStorage] Saving message to Solid Pod', { + messageId: messageDocument.messageId, + conversationId: messageDocument.conversationId, + context: metadata?.context, + }); + + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + if (!messageDocument.messageId) { + throw new Error('messageId is required'); + } + if (!messageDocument.conversationId) { + throw new Error('conversationId is required'); + } + + await ensureBaseStructureReady(req); + const { authenticatedFetch, podUrl } = await getSolidFetchAndPodUrl(req); + + const messagesContainerPath = getMessagesContainerPath(podUrl, messageDocument.conversationId); + await ensureContainerExists(messagesContainerPath, authenticatedFetch); + + const messagePath = getMessagePath( + podUrl, + messageDocument.conversationId, + messageDocument.messageId, + ); + + const messageJson = JSON.stringify(messageDocument, null, 2); + const messageBuffer = Buffer.from(messageJson, 'utf-8'); + + logger.debug('[SolidStorage] Saving message file', { + messagePath, + messageId: messageDocument.messageId, + conversationId: messageDocument.conversationId, + bufferSize: messageBuffer.length, + }); + + let messageExists = false; + try { + await getFile(messagePath, { fetch: authenticatedFetch }); + messageExists = true; + } catch (error) { + if (error?.status === 404 || error?.response?.status === 404) { + messageExists = false; + } else { + logger.warn('[SolidStorage] Error checking if message exists, will try to save anyway', { + messagePath, + error: error.message, + }); + } + } + + if (messageExists) { + await overwriteFile(messagePath, messageBuffer, { + contentType: 'application/json', + fetch: authenticatedFetch, + }); + logger.info('[SolidStorage] Message file overwritten successfully', { + messagePath, + messageId: messageDocument.messageId, + }); + } else { + await saveFileInContainer(messagesContainerPath, messageBuffer, { + slug: `${messageDocument.messageId}.json`, + contentType: 'application/json', + fetch: authenticatedFetch, + }); + logger.info('[SolidStorage] Message file saved successfully', { + messagePath, + messageId: messageDocument.messageId, + }); + } + + if (metadata?.context) { + logger.info(`[SolidStorage] ---saveMessageToSolid context: ${metadata.context}`); + } + + return messageDocument; + } catch (error) { + logger.error('[SolidStorage] Error saving message to Solid Pod', { + messageId: messageDocument?.messageId, + conversationId: messageDocument?.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, podUrl } = await getSolidFetchAndPodUrl(req); + + // 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 with N3 (Solid containers return Turtle RDF with ldp:contains) + const text = await response.text(); + const containedUrls = parseLdpContainsFromTurtle(text, messagesContainerPath); + const allItems = containedUrls.map((url) => ({ url })); + + // 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) { + // 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 in parallel + const messageDataResults = await Promise.all( + messageFiles.map(async (fileInfo) => { + const fileUrl = fileInfo.url; + try { + 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); + return messageData; + } catch (error) { + logger.error('[SolidStorage] Error reading message file', { + fileUrl, + conversationId, + error: error.message, + }); + return null; + } + }), + ); + + const messages = []; + for (const messageData of messageDataResults) { + if (!messageData) continue; + + // 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); + } + + // 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, podUrl } = await getSolidFetchAndPodUrl(req); + + // 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(); + const containedUrls = parseLdpContainsFromTurtle(text, messagesContainerPath); + // Only include directories (ending with /) + const allItems = containedUrls.filter((url) => url.endsWith('/')); + + 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?.response?.status === 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, podUrl } = await getSolidFetchAndPodUrl(req); + + // 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?.response?.status === 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. + * Document content is built in the model layer (schema-aligned); this only adds + * messages refs + timestamps and persists it. + * + * @param {Object} req - Express request object + * @param {Object} convoDocument - Full conversation document (same shape as MongoDB / IConversation) + * @param {Object} [metadata] - Additional metadata + * @returns {Promise} Saved conversation document + */ +async function saveConvoToSolid(req, convoDocument, metadata) { + try { + const finalConversationId = convoDocument.conversationId; + logger.info('[SolidStorage] Saving conversation to Solid Pod', { + conversationId: finalConversationId, + newConversationId: convoDocument.newConversationId, + context: metadata?.context, + }); + + if (!req?.user?.id) { + throw new Error('User not authenticated'); + } + if (!finalConversationId) { + throw new Error('conversationId is required'); + } + + await ensureBaseStructureReady(req); + const { authenticatedFetch, podUrl } = await getSolidFetchAndPodUrl(req); + + const conversationPath = getConversationPath(podUrl, finalConversationId); + + 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?.response?.status === 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, + }); + } + } + + const messages = await getMessagesFromSolid(req, finalConversationId); + const messageRefs = messages.map((msg) => ({ + messageId: msg.messageId, + createdAt: msg.createdAt, + })); + + // Preserve existing Pod fields (e.g. title) when the user only sends a partial update (e.g. after sending a message) + if (existingConversation) { + convoDocument = { ...existingConversation, ...convoDocument }; + } + + // Fill missing model/endpoint from first message (schema content stays in model layer; this is a fallback) + if ((!convoDocument.model || !convoDocument.endpoint) && messages.length > 0) { + const messageWithModel = messages.find((msg) => msg.model && msg.endpoint); + if (messageWithModel) { + if (!convoDocument.model && messageWithModel.model) { + convoDocument.model = messageWithModel.model; + } + if (!convoDocument.endpoint && messageWithModel.endpoint) { + convoDocument.endpoint = messageWithModel.endpoint; + } + } + } + + convoDocument.messages = messageRefs; + convoDocument.updatedAt = new Date().toISOString(); + convoDocument.createdAt = + convoDocument.createdAt || existingConversation?.createdAt || new Date().toISOString(); + + const previousConversationId = convoDocument.previousConversationId; + delete convoDocument.previousConversationId; + + const conversationJson = JSON.stringify(convoDocument, null, 2); + const conversationBuffer = Buffer.from(conversationJson, 'utf-8'); + + logger.debug('[SolidStorage] Saving conversation file', { + conversationPath, + conversationId: finalConversationId, + messageCount: messageRefs.length, + }); + + const conversationExists = existingConversation !== null; + + if (previousConversationId) { + const oldConversationPath = getConversationPath(podUrl, previousConversationId); + try { + await deleteFile(oldConversationPath, { fetch: authenticatedFetch }); + logger.info('[SolidStorage] Old conversation file deleted after rename', { + oldConversationPath, + newConversationId: convoDocument.conversationId, + }); + } catch (error) { + if (error.status !== 404) { + logger.warn('[SolidStorage] Error deleting old conversation file', { + oldConversationPath, + error: error.message, + }); + } + } + } + + 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 convoDocument; + } catch (error) { + logger.error('[SolidStorage] Error saving conversation to Solid Pod', { + conversationId: convoDocument?.conversationId, + newConversationId: convoDocument?.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, podUrl } = await getSolidFetchAndPodUrl(req); + + // 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); + + 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 + // 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, + 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; + } + + // 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, + 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?.response?.status === 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, podUrl } = await getSolidFetchAndPodUrl(req); + // TODO: Allow user to select their storage (can happen after the initial PR). + + // 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 with N3 (Solid containers return Turtle RDF with ldp:contains) + const text = await response.text(); + const containedUrls = parseLdpContainsFromTurtle(text, conversationsContainerPath); + containerContents = containedUrls.map((url) => ({ url })); + + 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) - use status only + const isNotFound = + errorStatus === 404 || errorStatus === '404' || error?.response?.status === 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 user is logged in via "Continue with Solid" + 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, + isArchived: convo.isArchived || false, + })); + + 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, podUrl } = await getSolidFetchAndPodUrl(req); + + 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.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.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 + } + + // 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?.response?.status === 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; + } +} + +/** + * 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" (use status only) + if ( + error?.status === 404 || + error?.status === 403 || + error?.response?.status === 404 || + error?.response?.status === 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 + * + * @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, podUrl } = await getSolidFetchAndPodUrl(req); + + // 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'); + } + + // Get owner WebID for preserving owner permissions + const ownerWebId = req.user.openidId; + + // Set public read access on conversation file using manual Turtle approach + try { + 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, + }); + } catch (error) { + logger.error('[SolidStorage] Error setting public access on conversation file', { + conversationPath, + conversationId, + error: error.message, + errorName: error.name, + errorCode: error.code, + stack: error.stack, + }); + throw error; + } + + // 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 { + await grantPublicReadAccess(messagesContainerPath, authenticatedFetch, true, ownerWebId); // isContainer=true, ownerWebId + logger.info('[SolidStorage] Public read access set on messages container (with default)', { + 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 using manual Turtle approach + let messagesShared = 0; + for (const message of messages) { + try { + const messagePath = getMessagePath(podUrl, conversationId, message.messageId); + + 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 + } + } + + 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, podUrl } = await getSolidFetchAndPodUrl(req); + + // Get conversation file path + const conversationPath = getConversationPath(podUrl, conversationId); + + // Remove public read access from conversation file using manual Turtle approach + try { + 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, + }); + } catch (error) { + if (error?.status === 404 || error?.response?.status === 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 using manual Turtle approach + const messagesContainerPath = getMessagesContainerPath(podUrl, conversationId); + try { + 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, + }); + } catch (error) { + if (error?.status === 404 || error?.response?.status === 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 using manual Turtle approach + let messagesUnshared = 0; + for (const message of messages) { + try { + const messagePath = getMessagePath(podUrl, conversationId, message.messageId); + 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?.response?.status === 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(); + const containedUrls = parseLdpContainsFromTurtle(containerText, messagesContainerPath); + const allItems = containedUrls.map((url) => ({ url })); + + // 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, + getBaseStoragePath, + getConversationPath, + getMessagesContainerPath, + getMessagePath, + ensureContainerExists, + ensureBaseStructure, + startBaseStructureAfterLogin, + saveMessageToSolid, + getMessagesFromSolid, + updateMessageInSolid, + deleteMessagesFromSolid, + saveConvoToSolid, + getConvoFromSolid, + getConvosByCursorFromSolid, + deleteConvosFromSolid, + setPublicAccessForShare, + removePublicAccessForShare, + getSharedMessagesFromSolid, + // Re-export solid-client functions for convenience + getFile, + saveFileInContainer, + overwriteFile, + deleteFile, +}; diff --git a/api/server/services/ensureSolidJwt.js b/api/server/services/ensureSolidJwt.js new file mode 100644 index 000000000000..288789759c2e --- /dev/null +++ b/api/server/services/ensureSolidJwt.js @@ -0,0 +1,55 @@ +/** + * Ensures the solidJwt Passport strategy is registered when Solid is enabled. + * Used at startup (socialLogins) and on first request (lazy) if IdP was down at startup. + * @returns {Promise} true if solidJwt is now registered (or was already), false otherwise + */ +const passport = require('passport'); +const { getSolidOpenIdProvidersForJwt } = require('./Config/solidOpenId'); +const { setupSolidOpenIdFromProvider, openIdJwtLogin } = require('~/strategies'); +const { logger } = require('@librechat/data-schemas'); + +let lazyInitPromise = null; + +async function ensureSolidJwtRegistered() { + if (passport._strategies && passport._strategies.solidJwt) { + return true; + } + const providers = getSolidOpenIdProvidersForJwt(); + if (providers.length === 0) { + return false; + } + const config = await setupSolidOpenIdFromProvider(providers[0]); + if (!config) { + logger.warn('[ensureSolidJwt] Discovery for first provider failed', { + issuer: providers[0].issuer, + }); + return false; + } + if (passport._strategies && passport._strategies.solidJwt) { + return true; + } + passport.use('solidJwt', openIdJwtLogin(config)); + logger.info('[ensureSolidJwt] solidJwt registered from first provider (lazy or startup).'); + return true; +} + +/** + * Call once from auth middleware when 503 would be returned for Solid; runs at most one discovery in flight. + * @returns {Promise} + */ +async function ensureSolidJwtRegisteredLazy() { + if (passport._strategies && passport._strategies.solidJwt) { + return true; + } + if (!lazyInitPromise) { + lazyInitPromise = ensureSolidJwtRegistered().finally(() => { + lazyInitPromise = null; + }); + } + return lazyInitPromise; +} + +module.exports = { + ensureSolidJwtRegistered, + ensureSolidJwtRegisteredLazy, +}; diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index a84c33bd52de..aca9c08f0cec 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -7,6 +7,7 @@ const { openIdJwtLogin, facebookLogin, discordLogin, + setupSolidOpenIdFromProvider, setupOpenId, googleLogin, githubLogin, @@ -14,9 +15,82 @@ const { setupSaml, } = require('~/strategies'); const { getLogStores } = require('~/cache'); +const { + getSolidOpenIdProvidersForJwt, + isSolidOpenIdEnabled, +} = require('./services/Config/solidOpenId'); +const { ensureSolidJwtRegistered } = require('./services/ensureSolidJwt'); + +/** + * Registers solidJwt and sets OpenID config from the first Solid provider only (no session). + * Use when Solid providers exist but we didn't run configureSolidOpenId/configureSolidOpenIdFromProviders + * (e.g. session already added by generic OpenID). Ensures API requests with token_provider=solid succeed. + * @returns {Promise} + */ +async function registerSolidJwtFromProviders() { + const providers = getSolidOpenIdProvidersForJwt(); + if (providers.length === 0) { + logger.debug('[registerSolidJwtFromProviders] No Solid providers configured - skipping.'); + return; + } + const config = await setupSolidOpenIdFromProvider(providers[0]); + if (!config) { + logger.warn( + '[registerSolidJwtFromProviders] Discovery for first provider failed - solidJwt not registered. Ensure the Solid IdP (e.g. Local CSS) is running at startup.', + { issuer: providers[0].issuer }, + ); + return; + } + if (passport._strategies && passport._strategies.solidJwt) { + return; + } + passport.use('solidJwt', openIdJwtLogin(config)); + logger.info('Solid OpenID: solidJwt registered from first provider (post-login API auth).'); +} + +/** Wrapper for startup: use shared ensureSolidJwtRegistered when we need a final pass */ +async function ensureSolidJwtFromProvidersOnce() { + await ensureSolidJwtRegistered(); +} + +/** + * Configures Solid session and solidJwt when only SOLID_OPENID_PROVIDERS is set (no legacy single-issuer env). + * Also supports SOLID_OPENID_CUSTOM_CLIENT_ID-only: uses synthetic Local CSS provider for JWT registration. + * Ensures getSolidOpenIdConfig() and solidJwt work so post-login API requests and refresh succeed. + * @param {Express.Application} app - The Express application instance. + * @returns {Promise} + */ +async function configureSolidOpenIdFromProviders(app) { + const providers = getSolidOpenIdProvidersForJwt(); + if (providers.length === 0) { + return; + } + logger.info('Configuring Solid OpenID from providers (session + JWT for post-login)...'); + 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 setupSolidOpenIdFromProvider(providers[0]); + if (!config) { + logger.warn( + '[configureSolidOpenIdFromProviders] Discovery for first provider failed - solidJwt not registered.', + ); + return; + } + passport.use('solidJwt', openIdJwtLogin(config)); + if (isEnabled(process.env.OPENID_REUSE_TOKENS)) { + logger.info('Solid OpenID token reuse enabled (solidJwt registered from first provider).'); + } + logger.info('Solid OpenID from providers configured (session + solidJwt).'); +} /** - * Configures OpenID Connect for the application. + * Configures generic OpenID Connect for the application. * @param {Express.Application} app - The Express application instance. * @returns {Promise} */ @@ -24,7 +98,7 @@ 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), @@ -71,9 +145,20 @@ const configureSocialLogins = async (app) => { if (process.env.APPLE_CLIENT_ID && process.env.APPLE_PRIVATE_KEY_PATH) { passport.use(appleLogin()); } + // Solid: dynamic providers only (SOLID_OPENID_PROVIDERS or SOLID_OPENID_CUSTOM_CLIENT_ID). Session + solidJwt from first provider. + const solidProvidersForJwt = getSolidOpenIdProvidersForJwt(); + if (process.env.SOLID_OPENID_SESSION_SECRET && solidProvidersForJwt.length > 0) { + await configureSolidOpenIdFromProviders(app); + } else if (solidProvidersForJwt.length > 0) { + await registerSolidJwtFromProviders(); + } + // Ensure solidJwt is registered whenever Solid is enabled (e.g. discovery failed earlier or IdP was down at startup) + if (isSolidOpenIdEnabled() && (!passport._strategies || !passport._strategies.solidJwt)) { + await ensureSolidJwtFromProvidersOnce(); + } + // Configure generic OpenID if OPENID_* env vars are present if ( process.env.OPENID_CLIENT_ID && - process.env.OPENID_CLIENT_SECRET && process.env.OPENID_ISSUER && process.env.OPENID_SCOPE && process.env.OPENID_SESSION_SECRET diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index c4ce8cb5d4b0..97f56f609f7c 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -3,8 +3,10 @@ const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider'); const { createImportBatchBuilder } = require('./importBatchBuilder'); const BaseClient = require('~/app/clients/BaseClient'); -const { getConvo } = require('~/models/Conversation'); -const { getMessages } = require('~/models/Message'); +const { getConvo, saveConvo } = require('~/models/Conversation'); +const { getMessages, saveMessage } = require('~/models/Message'); +const { getMessagesFromSolid } = require('~/server/services/SolidStorage'); +const { isSolidUser } = require('~/server/utils/isSolidUser'); /** * Helper function to clone messages with proper parent-child relationships and timestamps @@ -358,26 +360,114 @@ 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 useSolidStorage = isSolidUser(req); + // 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 (useSolidStorage) { + 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 (useSolidStorage) { + 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 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 +483,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, diff --git a/api/server/utils/isSolidUser.js b/api/server/utils/isSolidUser.js new file mode 100644 index 000000000000..deaab42c16ea --- /dev/null +++ b/api/server/utils/isSolidUser.js @@ -0,0 +1,12 @@ +/** + * Determines if the request belongs to a user who logged in via "Continue with Solid". + * Only those users get Solid Pod storage; "Continue with OpenID" and others use MongoDB. + * + * @param {import('express').Request} [req] - Express request with optional user + * @returns {boolean} + */ +function isSolidUser(req) { + return !!(req && req.user?.provider === 'solid'); +} + +module.exports = { isSolidUser }; diff --git a/api/strategies/SolidOpenidStrategy.js b/api/strategies/SolidOpenidStrategy.js new file mode 100644 index 000000000000..0281bd1f9d68 --- /dev/null +++ b/api/strategies/SolidOpenidStrategy.js @@ -0,0 +1,750 @@ +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 { Parser, Store, DataFactory } = require('n3'); +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(`[SolidOpenidStrategy] Request to: ${urlStr}`); + const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS); + if (debugOpenId) { + 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) { + bodyForLogging = options.body.toString(); + } else if (typeof options.body === 'string') { + bodyForLogging = options.body; + } else { + bodyForLogging = safeStringify(options.body); + } + logger.debug(`[SolidOpenidStrategy] Request body: ${bodyForLogging}`); + } + } + + try { + /** @type {undici.RequestInit} */ + let fetchOptions = options; + if (process.env.PROXY) { + logger.info(`[SolidOpenidStrategy] 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( + `[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(`[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 */ + 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(`[SolidOpenidStrategy] 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( + `[SolidOpenidStrategy] 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('[SolidOpenidStrategy] Generated nonce for federated provider:', nonce); + } + + /** Request consent so CSS/node-oidc-provider issues a refresh_token when offline_access is in scope */ + if (!params.has('prompt')) { + params.set('prompt', 'consent'); + } + + 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('[SolidOpenidStrategy] 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( + `[SolidOpenidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`, + ); + return ''; + } +}; + +const VCARD_NS = 'http://www.w3.org/2006/vcard/ns#'; +const FOAF_NS = 'http://xmlns.com/foaf/0.1/'; + +/** + * Fetches the Solid profile document (WebID card) and extracts email and name from vCard/FOAF. + * Uses n3 for Turtle parsing (no regex). Best-effort: returns {} on any failure. + * @param {string} webIdUrl - The user's WebID (e.g. https://pod.example.com/profile/card#me) + * @param {string} accessToken - Bearer access token from Solid OIDC + * @returns {Promise<{ email?: string, name?: string }>} + */ +async function getSolidProfileFromWebId(webIdUrl, accessToken) { + if (!webIdUrl || typeof webIdUrl !== 'string' || !accessToken) { + return {}; + } + const webId = webIdUrl.trim(); + let profileDocUrl; + try { + const u = new URL(webId); + u.hash = ''; + profileDocUrl = u.href; + } catch { + return {}; + } + + const fetchOptions = { + method: 'GET', + headers: { + Accept: 'text/turtle', + Authorization: `Bearer ${accessToken}`, + }, + }; + if (process.env.PROXY) { + fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY); + } + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + fetchOptions.signal = controller.signal; + + let text; + try { + const response = await fetch(profileDocUrl, fetchOptions); + clearTimeout(timeoutId); + if (!response.ok) { + logger.debug( + `[SolidOpenidStrategy] getSolidProfileFromWebId: profile fetch failed ${response.status} ${profileDocUrl}`, + ); + return {}; + } + text = await response.text(); + } catch (err) { + clearTimeout(timeoutId); + logger.debug( + `[SolidOpenidStrategy] getSolidProfileFromWebId: fetch error ${err.message} ${profileDocUrl}`, + ); + return {}; + } + + let quads; + try { + const parser = new Parser({ baseIRI: profileDocUrl }); + quads = parser.parse(text); + } catch (err) { + logger.debug( + `[SolidOpenidStrategy] getSolidProfileFromWebId: Turtle parse error ${err.message}`, + ); + return {}; + } + + const { namedNode } = DataFactory; + const store = new Store(quads); + const subjectIri = webId; + const subjectTerm = namedNode(subjectIri); + const hasEmailPred = VCARD_NS + 'hasEmail'; + const valuePred = VCARD_NS + 'value'; + const fnPred = VCARD_NS + 'fn'; + const foafNamePred = FOAF_NS + 'name'; + + let name; + const fnQuads = store.getQuads(subjectTerm, namedNode(fnPred), null, null); + if (fnQuads.length > 0 && fnQuads[0].object.termType === 'Literal' && fnQuads[0].object.value) { + name = fnQuads[0].object.value; + } else { + const foafNameQuads = store.getQuads(subjectTerm, namedNode(foafNamePred), null, null); + if ( + foafNameQuads.length > 0 && + foafNameQuads[0].object.termType === 'Literal' && + foafNameQuads[0].object.value + ) { + name = foafNameQuads[0].object.value; + } + } + + let email; + const hasEmailQuads = store.getQuads(subjectTerm, namedNode(hasEmailPred), null, null); + for (const q of hasEmailQuads) { + const emailNode = q.object; + const valueQuads = store.getQuads(emailNode, namedNode(valuePred), null, null); + for (const vq of valueQuads) { + if (vq.object.termType === 'NamedNode' && vq.object.value.startsWith('mailto:')) { + email = vq.object.value.slice(7).trim(); + break; + } + } + if (email) break; + } + + const out = {}; + if (email) out.email = email; + if (name) out.name = name; + return out; +} + +/** + * 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 + * @param {string} [userinfo.name] - Full name (e.g. from Solid profile vcard:fn) + * @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.name) { + return userinfo.name; + } + + 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; +} + +/** + * Verify Solid OpenID tokens and find/create user. Used by both the Passport strategy and the dynamic multi-issuer callback. + * @param {import('openid-client').TokenEndpointResponse & import('openid-client').TokenEndpointResponseHelpers} tokenset + * @param {Configuration} openidConfig + * @returns {Promise} User object with tokenset and federatedTokens (same shape as Passport verify callback). + * @throws {Error} On auth failure (email not allowed, required role missing, etc.) + */ +async function verifySolidUser(tokenset, openidConfig) { + 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 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 claims = tokenset.claims(); + const userinfo = { + ...claims, + ...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)), + }; + + if (userinfo.webid && tokenset.access_token) { + const profile = await getSolidProfileFromWebId(userinfo.webid, tokenset.access_token); + if (profile.email) userinfo.email = profile.email; + if (profile.name) userinfo.name = profile.name; + } + + const appConfig = await getAppConfig(); + const email = + userinfo.email || + userinfo.preferred_username || + userinfo.upn || + `${userinfo.webid}@FAKEDOMAIN.TLD`; + if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { + logger.error( + `[SolidOpenidStrategy] Authentication blocked - email domain not allowed [Email: ${email}]`, + ); + const err = new Error('Email domain not allowed'); + err.code = 'EMAIL_DOMAIN_NOT_ALLOWED'; + throw err; + } + + const result = await findOpenIDUser({ + findUser, + email, + openidId: claims.sub, + idOnTheSource: claims.oid, + strategyName: 'SolidOpenidStrategy', + }); + let user = result.user; + const error = result.error; + + if (error) { + const err = new Error(ErrorTypes.AUTH_FAILED); + err.code = 'AUTH_FAILED'; + throw err; + } + + 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')) { + const rolesList = + requiredRoles.length === 1 + ? `"${requiredRoles[0]}"` + : `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`; + throw new Error(`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(', ')}`; + throw new Error(`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: 'solid', + 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 = 'solid'; + 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: + throw new Error(`Invalid admin role token kind: ${adminRoleTokenKind}`); + } + const adminRoles = get(adminRoleObject, adminRoleParameterPath); + if ( + adminRoles && + (adminRoles === true || + adminRoles === adminRole || + (Array.isArray(adminRoles) && adminRoles.includes(adminRole))) + ) { + user.role = 'ADMIN'; + } else if (user.role === 'ADMIN') { + user.role = 'USER'; + } + } + + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { + const imageUrl = userinfo.picture; + const crypto = require('crypto'); + const fileName = crypto ? (await hashToken(userinfo.sub)) + '.png' : 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( + `[SolidOpenidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username}`, + ); + + return { + ...user, + provider: 'solid', + tokenset, + federatedTokens: { + access_token: tokenset.access_token, + refresh_token: tokenset.refresh_token, + expires_at: tokenset.expires_at, + }, + }; +} + +/** + * 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. + */ +/** + * @deprecated Use SOLID_OPENID_PROVIDERS and setupSolidOpenIdFromProvider() instead. Not used in dynamic flow. + * Legacy single-issuer setup using SOLID_OPENID_ISSUER (no longer supported). + * @async + * @function setupSolidOpenId + * @returns {Promise} + */ +async function _setupSolidOpenId() { + try { + const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); + + /** @type {ClientMetadata} */ + const clientMetadata = { + client_id: process.env.SOLID_OPENID_CLIENT_ID, + client_secret: process.env.SOLID_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.SOLID_OPENID_ISSUER), + process.env.SOLID_OPENID_CLIENT_ID, + clientMetadata, + undefined, + { + [client.customFetch]: customFetch, + execute: [client.allowInsecureRequests], // TODO: Insecure! Remove deprecated hack used for local HTTP only. + }, + ); + + 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(`[SolidOpenidStrategy] 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.SOLID_OPENID_SCOPE, + callbackURL: process.env.DOMAIN_SERVER + process.env.SOLID_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 userObj = await verifySolidUser(tokenset, openidConfig); + done(null, userObj); + } catch (err) { + logger.error('[SolidOpenidStrategy] login failed', err); + if (err.code === 'AUTH_FAILED' || err.code === 'EMAIL_DOMAIN_NOT_ALLOWED') { + return done(null, false, { message: err.message }); + } + done(err); + } + }, + ); + passport.use('openid', openidLogin); + return openidConfig; + } catch (err) { + logger.error('[SolidOpenidStrategy]', err); + return null; + } +} + +/** + * Set OpenID config from a provider (e.g. first entry in SOLID_OPENID_PROVIDERS). + * Used when only dynamic providers are configured so getSolidOpenIdConfig() and solidJwt work. + * @param {{ issuer: string, clientId: string, clientSecret?: string }} provider + * @returns {Promise} + */ +async function setupSolidOpenIdFromProvider(provider) { + try { + const clientMetadata = { + client_id: provider.clientId, + client_secret: provider.clientSecret || undefined, + }; + openidConfig = await client.discovery( + new URL(provider.issuer), + provider.clientId, + clientMetadata, + undefined, + { + [client.customFetch]: customFetch, + execute: [client.allowInsecureRequests], + }, + ); + logger.info('[SolidOpenidStrategy] OpenID config set from provider (for JWT/refresh)', { + issuer: provider.issuer, + }); + return openidConfig; + } catch (err) { + logger.error('[SolidOpenidStrategy] setupSolidOpenIdFromProvider failed', err); + return null; + } +} + +/** + * @function getOpenIdConfig + * @description Returns the OpenID client instance. + * @throws {Error} If the OpenID client is not initialized. + * @returns {Configuration} + */ +function getSolidOpenIdConfig() { + if (!openidConfig) { + throw new Error('OpenID client is not initialized. Please call setupOpenId first.'); + } + return openidConfig; +} + +module.exports = { + setupSolidOpenIdFromProvider, + getSolidOpenIdConfig, + verifySolidUser, +}; diff --git a/api/strategies/index.js b/api/strategies/index.js index 9a1c58ad38c6..75948e74d18a 100644 --- a/api/strategies/index.js +++ b/api/strategies/index.js @@ -1,4 +1,9 @@ const { setupOpenId, getOpenIdConfig, getOpenIdEmail } = require('./openidStrategy'); +const { + setupSolidOpenIdFromProvider, + getSolidOpenIdConfig, + verifySolidUser, +} = require('./SolidOpenidStrategy'); const openIdJwtLogin = require('./openIdJwtStrategy'); const facebookLogin = require('./facebookStrategy'); const discordLogin = require('./discordStrategy'); @@ -18,9 +23,12 @@ module.exports = { discordLogin, jwtLogin, facebookLogin, + getOpenIdEmail, + setupSolidOpenIdFromProvider, + getSolidOpenIdConfig, + verifySolidUser, setupOpenId, getOpenIdConfig, - getOpenIdEmail, ldapLogin, setupSaml, openIdJwtLogin, diff --git a/api/strategies/jwtStrategy.js b/api/strategies/jwtStrategy.js index 386b7bafa1be..e9cffba05994 100644 --- a/api/strategies/jwtStrategy.js +++ b/api/strategies/jwtStrategy.js @@ -1,13 +1,38 @@ 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: jwtExtractor, secretOrKey: process.env.JWT_SECRET, }, async (payload, done) => { diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 83a40bf9487c..131e2f92576f 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -25,6 +25,22 @@ const { updateUser, findUser } = require('~/models'); * * This enables seamless migration for existing users when SharePoint integration is enabled. */ +/** + * JWT extractor: Authorization Bearer first, then cookie openid_id_token (for OpenID/Solid when header not yet set by frontend). + * @param {import('express').Request} req + * @returns {string | null} + */ +function jwtFromRequest(req) { + const fromHeader = ExtractJwt.fromAuthHeaderAsBearerToken()(req); + if (fromHeader) return fromHeader; + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const parsed = cookies.parse(cookieHeader); + if (parsed.openid_id_token) return parsed.openid_id_token; + } + return null; +} + const openIdJwtLogin = (openIdConfig) => { let jwksRsaOptions = { cache: isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED) || true, @@ -38,7 +54,7 @@ const openIdJwtLogin = (openIdConfig) => { return new JwtStrategy( { - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest, secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions), passReqToCallback: true, }, diff --git a/client/src/components/Auth/SocialLoginRender.tsx b/client/src/components/Auth/SocialLoginRender.tsx index ad76354a5360..34a873d33d93 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, @@ -9,8 +10,9 @@ import { } from '@librechat/client'; import SocialButton from './SocialButton'; +import SolidLoginButton from './SolidLoginButton'; -import { useLocalize } from '~/hooks'; +import { useLocalize, TranslationKeys } from '~/hooks'; import { TStartupConfig } from 'librechat-data-provider'; @@ -98,6 +100,20 @@ function SocialLoginRender({ id="openid" /> ), + solid: startupConfig.solidLoginEnabled && ( + + startupConfig.solidImageUrl ? ( + Solid Logo + ) : ( + + ) + } + /> + ), saml: startupConfig.samlLoginEnabled && (
- Or + {localize('com_auth_or' as TranslationKeys)}
diff --git a/client/src/components/Auth/SolidLoginButton.tsx b/client/src/components/Auth/SolidLoginButton.tsx new file mode 100644 index 000000000000..30bd458c381d --- /dev/null +++ b/client/src/components/Auth/SolidLoginButton.tsx @@ -0,0 +1,165 @@ +import { useState } from 'react'; +import { + OGDialog, + OGDialogTrigger, + OGDialogContent, + OGDialogHeader, + OGDialogTitle, + OGDialogDescription, + OGDialogFooter, + OGDialogClose, + Button, +} from '@librechat/client'; +import type { TStartupConfig } from 'librechat-data-provider'; + +import { useLocalize, TranslationKeys } from '~/hooks'; + +type SolidLoginButtonProps = { + startupConfig: TStartupConfig; + label: string; + Icon: React.ComponentType | (() => React.ReactNode); +}; + +function isValidIssuerUrl(url: string): boolean { + const t = url.trim(); + if (!t) return false; + try { + const u = new URL(t.startsWith('http') ? t : `https://${t}`); + return u.protocol === 'https:' || u.protocol === 'http:'; + } catch { + return false; + } +} + +/** + * Solid login button that opens an IdP selection modal: URL input + optional provider pills. + * Redirects to serverDomain + '/oauth/openid?issuer=' + encodeURIComponent(selectedIssuer) + */ +function SolidLoginButton({ startupConfig, label, Icon }: SolidLoginButtonProps) { + const localize = useLocalize(); + const [open, setOpen] = useState(false); + const [providerUrl, setProviderUrl] = useState(''); + const [selectedOptionIssuer, setSelectedOptionIssuer] = useState(null); + + const options = startupConfig.solidIdpOptions ?? []; + const customEnabled = startupConfig.solidCustomEnabled === true; + const serverDomain = startupConfig.serverDomain || ''; + + const trimmedUrl = providerUrl.trim(); + const effectiveIssuer = trimmedUrl; + const canContinue = + serverDomain && + !!trimmedUrl && + (customEnabled + ? isValidIssuerUrl(trimmedUrl) + : options.some((opt) => opt.issuer === trimmedUrl)); + + const handleSelectOption = (issuer: string) => { + setSelectedOptionIssuer(issuer); + setProviderUrl(issuer); + }; + + const handleContinue = () => { + if (!canContinue || !effectiveIssuer) return; + const url = `${serverDomain}/oauth/openid?issuer=${encodeURIComponent(effectiveIssuer)}`; + window.location.href = url; + }; + + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) { + setProviderUrl(''); + setSelectedOptionIssuer(null); + } + }; + + if (!startupConfig.solidLoginEnabled) { + return null; + } + + return ( +
+ + + + + + + + {localize('com_auth_solid_idp_modal_title' as TranslationKeys)} + + + {localize('com_auth_solid_idp_modal_description' as TranslationKeys)} + + +
+
+ + { + setProviderUrl(e.target.value); + if (selectedOptionIssuer && e.target.value.trim() !== selectedOptionIssuer) { + setSelectedOptionIssuer(null); + } + }} + className="w-full rounded-lg border border-border-light bg-surface-primary bg-surface-secondary px-3 py-2.5 text-sm text-text-primary placeholder:text-gray-500 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:placeholder:text-gray-400" + data-testid="solid-custom-url" + /> +
+ + {options.length > 0 && ( +
+

+ {localize('com_auth_solid_select_provider' as TranslationKeys)} +

+
+ {options.map((opt) => { + const isSelected = providerUrl.trim() === opt.issuer; + return ( + + ); + })} +
+
+ )} +
+ + + + + + +
+
+
+ ); +} + +export default SolidLoginButton; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 35d83004891b..ef69077486ae 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -152,6 +152,11 @@ "com_auth_click": "Click", "com_auth_click_here": "Click here", "com_auth_continue": "Continue", + "com_auth_solid_idp_modal_description": "Enter your provider URL or pick one of the providers below.", + "com_auth_solid_idp_modal_title": "Choose Solid Identity Provider", + "com_auth_solid_idp_placeholder": "Enter your provider URL", + "com_auth_solid_idp_label": "Solid Identity Provider", + "com_auth_solid_select_provider": "Or select a provider:", "com_auth_create_account": "Create your account", "com_auth_discord_login": "Continue with Discord", "com_auth_email": "Email", @@ -190,6 +195,7 @@ "com_auth_name_min_length": "Name must be at least 3 characters", "com_auth_name_required": "Name is required", "com_auth_no_account": "Don't have an account?", + "com_auth_or": "Or", "com_auth_password": "Password", "com_auth_password_confirm": "Confirm password", "com_auth_password_forgot": "Forgot Password?", diff --git a/config/create-user.js b/config/create-user.js index 3688d736e24c..2b7608c4a982 100644 --- a/config/create-user.js +++ b/config/create-user.js @@ -14,7 +14,9 @@ const connect = require('./connect'); console.purple('--------------------------'); if (process.argv.length < 5) { - console.orange('Usage: npm run create-user -- [--email-verified=false]'); + console.orange( + 'Usage: npm run create-user -- [--email-verified=false]', + ); console.orange('Note: if you do not pass in the arguments, you will be prompted for them.'); console.orange( 'If you really need to pass in the password, you can do so as the 4th argument (not recommended for security).', @@ -88,9 +90,9 @@ If \`n\`, and email service is configured, the user will be sent a verification If \`n\`, and email service is not configured, you must have the \`ALLOW_UNVERIFIED_EMAIL_LOGIN\` .env variable set to true, or the user will need to attempt logging in to have a verification link sent to them.`); - const normalizedEmailVerifiedInput = emailVerifiedInput.trim().toLowerCase() + const normalizedEmailVerifiedInput = emailVerifiedInput.trim().toLowerCase(); - emailVerified = true + emailVerified = true; if (normalizedEmailVerifiedInput === 'n') { emailVerified = false; diff --git a/package-lock.json b/package-lock.json index 1126527dd051..8d0510719291 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", @@ -85,6 +86,7 @@ "firebase": "^11.0.2", "form-data": "^4.0.4", "handlebars": "^4.7.7", + "http-link-header": "^1.1.3", "https-proxy-agent": "^7.0.6", "ioredis": "^5.3.2", "js-yaml": "^4.1.1", @@ -103,6 +105,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", @@ -2369,6 +2372,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -2755,6 +2759,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 +2794,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 +2812,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 +2925,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2941,6 +2949,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -5129,6 +5138,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 +5220,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 +5241,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 +5310,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", @@ -7284,6 +7297,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", @@ -7594,6 +7619,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -7617,6 +7643,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -7731,6 +7758,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8194,6 +8222,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -9027,6 +9056,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 +9101,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" }, @@ -9566,6 +9597,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", @@ -9605,6 +9645,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 +9707,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 +9722,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 +10140,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 +10375,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", @@ -10782,6 +10827,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", @@ -11675,6 +11765,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 +12279,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 +12408,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 +12790,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 +12800,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" }, @@ -13883,6 +13976,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 +14221,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" @@ -14292,7 +14385,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.211.0.tgz", "integrity": "sha512-julhCJ9dXwkOg9svuuYqqjXLhVaUgyUvO2hWbTxwjvLXX2rG3VtAaB0SzxMnGTuoCZizBT7Xqqm2V7+ggrfCXA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.211.0", "@opentelemetry/core": "2.5.0", @@ -14315,7 +14407,6 @@ "integrity": "sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==", "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", @@ -14399,7 +14490,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -14416,7 +14506,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", @@ -14434,7 +14523,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -15207,6 +15295,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 +16052,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 +16084,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 +18039,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 +18231,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 +18262,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", @@ -18397,6 +18491,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", @@ -18560,7 +18687,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 +18703,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 +18716,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 +18733,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 +18752,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 +18796,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 +18985,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19929,6 +20052,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 +20602,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" @@ -21155,6 +21280,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", @@ -21285,6 +21419,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" } @@ -21334,10 +21469,20 @@ "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", "integrity": "sha512-52IHsMDT8qATp9B9zoOyobW8W3/0QhaJQTw1HwRj0UY2yBpCAQ7+S/CqHYQ8niAm3p4ji+rWUQ9UCib0GxQ60w==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -21349,6 +21494,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": "*" } @@ -21363,6 +21509,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", @@ -21502,6 +21664,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 +21695,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", @@ -22056,6 +22220,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", @@ -22104,6 +22280,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 +22342,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", @@ -22618,6 +22796,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -22693,6 +22872,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 +23445,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -23521,6 +23702,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", @@ -23647,6 +23834,7 @@ "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz", "integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.1", "@chevrotain/gast": "11.1.1", @@ -23746,6 +23934,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 +24099,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 +24333,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" }, @@ -24523,6 +24714,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -24772,6 +24964,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 +25389,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 +26017,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" } @@ -26344,6 +26539,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 +26600,7 @@ "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "build/bin/cli.js" }, @@ -26515,6 +26712,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", @@ -26962,6 +27160,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", @@ -26981,6 +27188,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" }, @@ -27118,6 +27326,7 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", "license": "MIT", + "peer": true, "dependencies": { "ip-address": "10.0.1" }, @@ -27136,6 +27345,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 +27864,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 +28003,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 +28079,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 +28106,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 +28114,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", @@ -28758,6 +28967,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -28766,8 +28976,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", @@ -28863,6 +29072,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", @@ -28894,6 +29112,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 +29175,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" }, @@ -28973,6 +29193,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 +29419,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 +29459,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 +31535,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" }, @@ -31472,6 +31696,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", @@ -31677,6 +31969,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" } @@ -31686,6 +31979,7 @@ "resolved": "https://registry.npmjs.org/keyv-file/-/keyv-file-5.2.0.tgz", "integrity": "sha512-5JEBqQiDzjGCQHtf7KLReJdHKchaJyUZW+9TvBu+4dc+uuTqUG9KcdA3ICMXlwky3qjKc0ecNCNefbgjyDtlAg==", "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.0.1", "tslib": "^1.14.1" @@ -32746,6 +33040,7 @@ "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.1.0.tgz", "integrity": "sha512-HfnAcScQm9drGryodlDqeS3WAl4gUTYGDcOtcqL/8s23MZ28Ib1i8XnYK3ZdjNuaW/L4BAp9lIp8vxAMrcuu1w==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@babel/runtime": "^7.26.10", "complex.js": "^2.2.5", @@ -34497,6 +34792,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", @@ -34594,6 +34927,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 +36158,7 @@ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -35889,6 +36224,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -36893,6 +37229,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -37263,6 +37600,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -37503,6 +37841,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -37879,6 +38218,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 +38274,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", @@ -37958,11 +38299,40 @@ "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", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -38035,6 +38405,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 +38703,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 +38775,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", @@ -38756,6 +39129,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", @@ -39614,6 +39993,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -41243,6 +41623,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 +41633,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", @@ -41367,6 +41749,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", "dev": true, + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -41470,7 +41853,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 +41925,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -41761,6 +42146,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", @@ -42225,6 +42611,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 +43270,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 +43357,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", @@ -43189,6 +43701,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", @@ -43543,6 +44056,7 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -44125,6 +44639,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 +44649,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 +46833,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 +46864,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 +47166,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 +47182,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 +47210,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 +47231,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 +47248,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", diff --git a/packages/api/src/auth/openid.ts b/packages/api/src/auth/openid.ts index a7079ccd1653..61e22378a4f1 100644 --- a/packages/api/src/auth/openid.ts +++ b/packages/api/src/auth/openid.ts @@ -39,8 +39,10 @@ export async function findOpenIDUser({ `[${strategyName}] user ${user ? 'found' : 'not found'} with email: ${email} for openidId: ${openidId}`, ); - // If user found by email, check if they're allowed to use OpenID provider - if (user && user.provider && user.provider !== 'openid') { + // If user found by email, check if they're allowed to use this provider (OpenID or Solid) + const allowedProviders = + strategyName === 'SolidOpenidStrategy' ? ['openid', 'solid'] : ['openid']; + if (user?.provider && !allowedProviders.includes(user.provider)) { logger.warn( `[${strategyName}] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`, ); @@ -52,7 +54,7 @@ export async function findOpenIDUser({ logger.info( `[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`, ); - user.provider = 'openid'; + user.provider = strategyName === 'SolidOpenidStrategy' ? 'solid' : 'openid'; user.openidId = openidId; return { user, error: null, migration: true }; } diff --git a/packages/api/src/utils/graph.spec.ts b/packages/api/src/utils/graph.spec.ts index 4f1fa14983ef..71d9464e67c2 100644 --- a/packages/api/src/utils/graph.spec.ts +++ b/packages/api/src/utils/graph.spec.ts @@ -94,9 +94,9 @@ describe('Graph Token Utilities', () => { }); it('should return false for non-object values', () => { - expect(recordContainsGraphTokenPlaceholder('string' as unknown as Record)).toBe( - false, - ); + expect( + recordContainsGraphTokenPlaceholder('string' as unknown as Record), + ).toBe(false); }); }); @@ -242,7 +242,9 @@ describe('Graph Token Utilities', () => { it('should return original value when graph token exchange fails', async () => { mockExtractOpenIDTokenInfo.mockReturnValue({ accessToken: 'access-token' }); mockIsOpenIDTokenValid.mockReturnValue(true); - const failingResolver: GraphTokenResolver = jest.fn().mockRejectedValue(new Error('Exchange failed')); + const failingResolver: GraphTokenResolver = jest + .fn() + .mockRejectedValue(new Error('Exchange failed')); const value = 'Bearer {{LIBRECHAT_GRAPH_ACCESS_TOKEN}}'; const result = await resolveGraphTokenPlaceholder(value, { diff --git a/packages/api/src/utils/graph.ts b/packages/api/src/utils/graph.ts index 0ff3fc3583e8..3bb3d2bf4c36 100644 --- a/packages/api/src/utils/graph.ts +++ b/packages/api/src/utils/graph.ts @@ -11,10 +11,7 @@ import { * Pre-computed regex for matching the Graph token placeholder. * Escapes curly braces in the placeholder string for safe regex use. */ -const GRAPH_TOKEN_REGEX = new RegExp( - GRAPH_TOKEN_PLACEHOLDER.replace(/[{}]/g, '\\$&'), - 'g', -); +const GRAPH_TOKEN_REGEX = new RegExp(GRAPH_TOKEN_PLACEHOLDER.replace(/[{}]/g, '\\$&'), 'g'); /** * Response from a Graph API token exchange. @@ -143,7 +140,9 @@ export async function resolveGraphTokenPlaceholder( return value.replace(GRAPH_TOKEN_REGEX, graphTokenResponse.access_token); } - logger.warn('[resolveGraphTokenPlaceholder] Graph token exchange did not return an access token'); + logger.warn( + '[resolveGraphTokenPlaceholder] Graph token exchange did not return an access token', + ); return value; } catch (error) { logger.error('[resolveGraphTokenPlaceholder] Failed to exchange token for Graph API:', error); @@ -185,14 +184,13 @@ export async function resolveGraphTokensInRecord( * @param graphOptions - Options for Graph token resolution * @returns The options with Graph token placeholders resolved */ -export async function preProcessGraphTokens; - env?: Record; - url?: string; -}>( - options: T, - graphOptions: GraphTokenOptions, -): Promise { +export async function preProcessGraphTokens< + T extends { + headers?: Record; + env?: Record; + url?: string; + }, +>(options: T, graphOptions: GraphTokenOptions): Promise { if (!mcpOptionsContainGraphTokenPlaceholder(options)) { return options; } diff --git a/packages/client/src/svgs/SolidIcon.tsx b/packages/client/src/svgs/SolidIcon.tsx new file mode 100644 index 000000000000..4a5801ac5fde --- /dev/null +++ b/packages/client/src/svgs/SolidIcon.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +export default function SolidIcon() { + return ( + + ); +} diff --git a/packages/client/src/svgs/index.ts b/packages/client/src/svgs/index.ts index 1d67d4455263..85c6fbb0dc99 100644 --- a/packages/client/src/svgs/index.ts +++ b/packages/client/src/svgs/index.ts @@ -21,6 +21,7 @@ export { default as ContinueIcon } from './ContinueIcon'; export { default as GoogleIcon } from './GoogleIcon'; export { default as FacebookIcon } from './FacebookIcon'; export { default as OpenIDIcon } from './OpenIDIcon'; +export { default as SolidIcon } from './SolidIcon'; export { default as GithubIcon } from './GithubIcon'; export { default as DiscordIcon } from './DiscordIcon'; export { default as AppleIcon } from './AppleIcon'; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 48d54099627b..11cc2d04bd52 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -8,7 +8,15 @@ import { apiBaseUrl } from './api-endpoints'; import { FileSources } from './types/files'; import { MCPServersSchema } from './mcp'; -export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml']; +export const defaultSocialLogins = [ + 'google', + 'facebook', + 'openid', + 'solid', + 'github', + 'discord', + 'saml', +]; export const defaultRetrievalModels = [ 'gpt-4o', @@ -758,6 +766,14 @@ export type TStartupConfig = { openidLabel: string; openidImageUrl: string; openidAutoRedirect: boolean; + solidLoginEnabled: boolean; + solidLabel: string; + solidImageUrl: string; + solidAutoRedirect: boolean; + /** Solid IdP options for the selection modal (issuer URL + display label). Includes the 3 defaults (Local CSS, Solid Community, Inrupt) plus any from config. */ + solidIdpOptions?: Array<{ issuer: string; label: string }>; + /** When true, user can enter a custom Solid OIDC provider URL in the modal. */ + solidCustomEnabled?: boolean; samlLabel: string; samlImageUrl: string; /** LDAP Auth Configuration */ diff --git a/packages/data-provider/src/createPayload.ts b/packages/data-provider/src/createPayload.ts index 3056a7021b99..ca3bb16fc847 100644 --- a/packages/data-provider/src/createPayload.ts +++ b/packages/data-provider/src/createPayload.ts @@ -2,6 +2,17 @@ import type * as t from './types'; import { EndpointURLs } from './config'; import * as s from './schemas'; +/** + * Builds the request payload for the chat/agents API. + * + * When using Solid storage, the conversation object can come back with a different shape than + * the rest of the app expects (e.g. after loading from the Pod ). + * Without normalizing here, the first message in a conversation works, but continuing the + * conversation (second message) fails because createPayload receives invalid or mismatched + * data (messages as objects, null instead of undefined, model on conversation, etc.). + * The normalization below ensures we always produce a valid payload for both the initial + * and follow-up requests. + */ export default function createPayload(submission: t.TSubmission) { const { isEdited, @@ -15,7 +26,274 @@ export default function createPayload(submission: t.TSubmission) { ephemeralAgent, endpointOption, } = submission; - const { conversationId } = s.tConvoUpdateSchema.parse(conversation); + + /** + * Normalize conversation for Solid storage compatibility. + * Solid can give us: messages as full objects with + * messageId instead of an array of IDs; null instead of undefined for optional fields. + * We convert to the shape tConvoUpdateSchema and downstream code expect. + */ + let normalizedConversation: Record; + try { + normalizedConversation = { ...conversation }; + + // Messages: ensure we have an array of message IDs (schema expects string[]), not objects + 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 { + normalizedConversation = conversation as Record; + } + + /** + * Zod .optional() means "undefined is valid", not null. JSON/Storage often use null for + * missing optional fields. Convert null → undefined so schema parse succeeds. + */ + 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 so we don't throw on invalid/conversation shapes (e.g. from Solid). + * If parse fails, we still try to build a valid payload from conversation + endpointOption + * (fallback below) so follow-up messages can succeed. + */ + const parseResult = s.tConvoUpdateSchema.safeParse(normalizedConversation); + + if (!parseResult.success) { + // Fallback: build payload from raw conversationId and conversation/endpointOption fields + // (e.g. new convo: conversationId null/'new'; existing: we need conversationId + model etc.) + 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; + }; + + 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' : ''); + } + + 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; + + // Solid often stores model (and other options) on the conversation; pull from conversation + // when not in endpointOption so the payload has a model for the backend. + 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; @@ -29,6 +307,25 @@ export default function createPayload(submission: t.TSubmission) { (isEdited ? '/modify' : ''); } + // Model can live on the conversation when loaded from Solid; include it in payload if present + 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 +337,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 }; diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 2a0d2bc3bd88..5bb13961c0f8 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -5,6 +5,10 @@ import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; import type * as t from '~/types'; import logger from '~/config/winston'; +// Solid storage and auth helpers – required at runtime when share methods run in api server context +const getSolidStorage = () => require('~/server/services/SolidStorage'); +const getIsSolidUser = () => require('~/server/utils/isSolidUser').isSolidUser; + class ShareServiceError extends Error { code: string; constructor(message: string, code: string) { @@ -162,6 +166,55 @@ 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 } = getSolidStorage(); + 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, + }); + return null; + } + } + + // MongoDB share - use existing logic (separate query with populate to get messages) + const shareWithMessages = (await SharedLink.findOne({ shareId, isPublic: true }) .populate({ path: 'messages', select: '-_id -__v -user', @@ -169,23 +222,26 @@ 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 +372,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 +380,36 @@ 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 (user logged in via "Continue with Solid") + if (req) { + if (getIsSolidUser()(req)) { + const solidShares = shares.filter((share) => share.podUrl); + for (const share of solidShares) { + try { + const { removePublicAccessForShare } = getSolidStorage(); + 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,6 +432,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { user: string, conversationId: string, targetMessageId?: string, + req?: any, // Optional Express request object for Solid storage support ): Promise { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -354,61 +442,228 @@ 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([ - SharedLink.findOne({ + // Check if this is a Solid user (logged in via "Continue with Solid") + let conversation; + let conversationMessages; + let podUrl: string | undefined; + + if (req && getIsSolidUser()(req)) { + 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 Promise, - Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(), - ]); + .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 }), - }); - } + 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 conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { - title?: string; - } | null; + const { getConvoFromSolid, getMessagesFromSolid, getPodUrl, getSolidFetch } = + getSolidStorage(); + const authenticatedFetch = await getSolidFetch(req); - // Check if user owns the conversation - if (!conversation) { - throw new ShareServiceError( - 'Conversation not found or access denied', - 'CONVERSATION_NOT_FOUND', - ); - } + // Get Pod URL + podUrl = await getPodUrl(req.user.openidId, authenticatedFetch); + logger.info('[createSharedLink] Pod URL retrieved', { + podUrl, + conversationId, + }); - // Check if there are any messages to share - if (!conversationMessages || conversationMessages.length === 0) { - throw new ShareServiceError('No messages to share', 'NO_MESSAGES'); + // 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, + isPublic: true, + ...(targetMessageId && { targetMessageId }), + }) + .select('-_id -__v -user') + .lean() as Promise, + Message.find({ conversationId, user }).sort({ createdAt: 1 }).lean(), + ]); + + 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 }), + }); + } + + conversation = (await Conversation.findOne({ conversationId, user }).lean()) as { + title?: string; + } | null; + + // Check if user owns the conversation + if (!conversation) { + throw new ShareServiceError( + 'Conversation not found or access denied', + 'CONVERSATION_NOT_FOUND', + ); + } + + // Check if there are any messages to share + 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 (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 (req && podUrl) { + try { + const { setPublicAccessForShare } = getSolidStorage(); + 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 +774,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 +782,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 } = getSolidStorage(); + 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 }, ); 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; }