Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
# - /var/run/docker.sock:/var/run/docker.sock
environment:
- HOST=0.0.0.0
- MONGO_URI=mongodb://mongodb:27017/LibreChat
- MONGO_URI=mongodb://mongodb:27017/LibreChat?replicaSet=rs0
# - OPENAI_REVERSE_PROXY=http://host.docker.internal:8070/v1
- MEILI_HOST=http://meilisearch:7700

Expand All @@ -45,7 +45,13 @@ services:
# restart: always
volumes:
- ./data-node:/data/db
command: mongod --noauth
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --quiet
interval: 5s
timeout: 30s
retries: 30
start_period: 10s
meilisearch:
container_name: chat-meilisearch
image: getmeili/meilisearch:v1.5
Expand Down
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ PORT=3080
# HTTP_REQUEST_TIMEOUT_MS=300000

MONGO_URI=mongodb://127.0.0.1:27017/LibreChat
# Config rollback uses Mongo transactions. Use a replica set or Atlas
# (`mongodb+srv://...`). Docker Compose initializes `rs0` automatically.
#The maximum number of connections in the connection pool. */
MONGO_MAX_POOL_SIZE=
#The minimum number of connections in the connection pool. */
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/docker-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,18 @@ jobs:
run: |
set -u
docker network create lc-smoke
docker run -d --name lc-mongo --network lc-smoke mongo:8.0.20
docker run -d --name lc-mongo --network lc-smoke mongo:8.0.20 --replSet rs0 --bind_ip_all
for i in $(seq 1 30); do
docker exec lc-mongo mongosh --quiet --eval 'try { rs.status() } catch (e) { rs.initiate({_id:"rs0",members:[{_id:0,host:"lc-mongo:27017"}]}) }' >/dev/null 2>&1 || true
if docker exec lc-mongo mongosh --quiet --eval 'quit(rs.status().ok ? 0 : 1)' >/dev/null 2>&1; then
break
fi
sleep 1
done
docker run -d --name lc-api --network lc-smoke -p 3080:3080 \
-e HOST=0.0.0.0 -e PORT=3080 \
-e NODE_ENV=production \
-e MONGO_URI=mongodb://lc-mongo:27017/LibreChat \
-e MONGO_URI=mongodb://lc-mongo:27017/LibreChat?replicaSet=rs0 \
-e CREDS_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \
-e CREDS_IV=0123456789abcdef0123456789abcdef \
-e JWT_SECRET=docker-smoke-jwt-secret \
Expand Down
17 changes: 7 additions & 10 deletions api/db/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,13 @@ const maxConnecting = parseInt(process.env.MONGO_MAX_CONNECTING) || undefined;
const maxIdleTimeMS = parseInt(process.env.MONGO_MAX_IDLE_TIME_MS) || undefined;
/** The maximum time in milliseconds that a thread can wait for a connection to become available. */
const waitQueueTimeoutMS = parseInt(process.env.MONGO_WAIT_QUEUE_TIMEOUT_MS) || undefined;
/** Set to false to disable automatic index creation for all models associated with this connection. */
/** Explicit `true`/`false` controls Mongoose autoIndex; absent variable preserves the Mongoose default (`true`). */
const autoIndex =
process.env.MONGO_AUTO_INDEX != undefined
? isEnabled(process.env.MONGO_AUTO_INDEX) || false
: undefined;

/** Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
process.env.MONGO_AUTO_INDEX !== undefined ? isEnabled(process.env.MONGO_AUTO_INDEX) : undefined;
/** Explicit `true`/`false` controls Mongoose autoCreate; absent variable preserves the Mongoose default (`true`). */
const autoCreate =
process.env.MONGO_AUTO_CREATE != undefined
? isEnabled(process.env.MONGO_AUTO_CREATE) || false
process.env.MONGO_AUTO_CREATE !== undefined
? isEnabled(process.env.MONGO_AUTO_CREATE)
: undefined;
/**
* Global is used here to maintain a cached connection across hot reloads
Expand Down Expand Up @@ -60,8 +57,8 @@ async function connectDb() {
...(maxConnecting ? { maxConnecting } : {}),
...(maxIdleTimeMS ? { maxIdleTimeMS } : {}),
...(waitQueueTimeoutMS ? { waitQueueTimeoutMS } : {}),
...(autoIndex != undefined ? { autoIndex } : {}),
...(autoCreate != undefined ? { autoCreate } : {}),
...(autoIndex !== undefined ? { autoIndex } : {}),
...(autoCreate !== undefined ? { autoCreate } : {}),
// useNewUrlParser: true,
// useUnifiedTopology: true,
// bufferMaxEntries: 0,
Expand Down
8 changes: 7 additions & 1 deletion api/server/experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const mongoose = require('mongoose');
const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { logger, runAsSystem, ensureConfigIndexes } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const {
isEnabled,
Expand Down Expand Up @@ -470,6 +470,11 @@ if (cluster.isMaster) {
logger.info(`Worker ${process.pid}: Connected to MongoDB`);
startCodeEnvironmentLifecycleReconciler({ mongoose });

/** Mirrors `server/index.js`; must run before workers accept traffic so the
* epoch collection's unique index and config uniqueness index exist before
* concurrent upserts from multiple workers can race. */
await ensureConfigIndexes(mongoose);

/** Background index sync (non-blocking) */
indexSync().catch((err) => {
logger.error(`[Worker ${process.pid}][indexSync] Background sync failed:`, err);
Expand Down Expand Up @@ -634,6 +639,7 @@ if (cluster.isMaster) {
/** Routes */
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
app.use('/api/admin', preAuthTenantMiddleware);
app.use('/api/insights', routes.insights);
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/skills', routes.adminSkills);
Expand Down
50 changes: 50 additions & 0 deletions api/server/experimental.spec.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
const fs = require('fs');
const vm = require('vm');
const path = require('path');

describe('Experimental server configuration', () => {
const source = fs.readFileSync(path.join(__dirname, 'experimental.js'), 'utf8');
const standardSource = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8');

it.each([
['standard', standardSource],
['experimental', source],
])('parses the %s server without duplicate declarations', (_name, serverSource) => {
expect(() => new vm.Script(serverSource)).not.toThrow();
});

it('configures HTTP timeouts for each cluster worker server', () => {
const listenIndex = source.indexOf('const server = app.listen');
Expand Down Expand Up @@ -95,12 +104,53 @@ describe('Experimental server configuration', () => {
expect(listenIndex).toBeGreaterThan(eventRuntimeIndex);
});

it('initializes config indexes right after connecting to Mongo, before workers accept traffic', () => {
const connectIndex = source.indexOf('await connectDb();');
const ensureIndexesIndex = source.indexOf('await ensureConfigIndexes(mongoose);');
const listenIndex = source.indexOf('const server = app.listen');

expect(connectIndex).toBeGreaterThan(-1);
expect(ensureIndexesIndex).toBeGreaterThan(-1);
expect(listenIndex).toBeGreaterThan(-1);
// Without the epoch collection's unique index and the config uniqueness
// index in place first, concurrent upserts from multiple cluster workers
// can create duplicate epochs and weaken CAS/ABA protection.
expect(ensureIndexesIndex).toBeGreaterThan(connectIndex);
expect(listenIndex).toBeGreaterThan(ensureIndexesIndex);
});

it('matches the standard server pre-authentication tenant routes', () => {
const standardAdminTenantIndex = standardSource.indexOf(
"app.use('/api/admin', preAuthTenantMiddleware);",
);
const standardFirstAdminRouteIndex = standardSource.indexOf(
"app.use('/api/admin', routes.adminAuth);",
);
const experimentalAdminTenantIndex = source.indexOf(
"app.use('/api/admin', preAuthTenantMiddleware);",
);
const experimentalFirstAdminRouteIndex = source.indexOf(
"app.use('/api/admin', routes.adminAuth);",
);

expect(standardAdminTenantIndex).toBeGreaterThan(-1);
expect(standardFirstAdminRouteIndex).toBeGreaterThan(standardAdminTenantIndex);
expect(experimentalAdminTenantIndex).toBeGreaterThan(-1);
expect(experimentalFirstAdminRouteIndex).toBeGreaterThan(experimentalAdminTenantIndex);
expect(source).toContain("app.use('/oauth', preAuthTenantMiddleware, routes.oauth);");
expect(source).toContain("app.use('/api/auth', preAuthTenantMiddleware, routes.auth);");
expect(source).toContain("app.use('/api/admin', preAuthTenantMiddleware);");
expect(source).toContain(
"app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);",
);
expect(source).toContain("app.use('/api/share', preAuthTenantMiddleware, routes.share);");
});

it.each([
['standard', standardSource],
['experimental', source],
])('keeps Insights on the non-admin route in the %s server', (_name, serverSource) => {
expect(serverSource).toContain("app.use('/api/insights', routes.insights);");
expect(serverSource).not.toContain("app.use('/api/admin/insights'");
});
});
6 changes: 4 additions & 2 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const passport = require('passport');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const mongoSanitize = require('express-mongo-sanitize');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const { logger, runAsSystem, ensureConfigIndexes } = require('@librechat/data-schemas');
const {
isEnabled,
issueCsp,
Expand Down Expand Up @@ -190,6 +190,7 @@ const startServer = async () => {
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
}
await connectDb();
await ensureConfigIndexes(mongoose);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Initialize config indexes in the experimental server

The supported backend:experimental entry point connects in api/server/experimental.js without calling this new initializer. When that deployment runs with MONGO_AUTO_INDEX=false, the config, revision, and epoch uniqueness indexes and the deduplication migration are never installed before the atomic route accepts traffic; two concurrent first saves of an absent base config can therefore both commit separate config and epoch documents instead of one losing with a version conflict. Mirror this blocking initialization in the experimental startup path as well.

Useful? React with 👍 / 👎.


logger.info('Connected to MongoDB');
startCodeEnvironmentLifecycleReconciler({ mongoose });
Expand Down Expand Up @@ -375,11 +376,12 @@ const startServer = async () => {
/* Per-request capability cache — must be registered before any route that calls hasCapability */
app.use(capabilityContextMiddleware);

/* Pre-auth tenant context for unauthenticated routes that need tenant scoping.
/* Pre-auth tenant context for routes that need request-selected tenant scoping.
* The reverse proxy / auth gateway sets `X-Tenant-Id` header for multi-tenant deployments. */
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
/* API Endpoints */
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
app.use('/api/admin', preAuthTenantMiddleware);
app.use('/api/insights', routes.insights);
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/config', routes.adminConfig);
Expand Down
15 changes: 14 additions & 1 deletion api/server/middleware/__tests__/requireJwtAuth.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,12 @@ jest.mock('@librechat/api', () => {
},
maybeRefreshCloudFrontAuthCookiesMiddleware: jest.fn((req, res, next) => next()),
tenantContextMiddleware: (req, res, next) => {
const trustedTenantId = tenantStorage.getStore()?.tenantId;
const context = {
tenantId: normalizeContextValue(req.user?.tenantId),
tenantId:
normalizeContextValue(req.tenantId) ??
normalizeContextValue(trustedTenantId) ??
normalizeContextValue(req.user?.tenantId),
userId: getUserId(req.user),
requestId: getRequestId(req),
};
Expand Down Expand Up @@ -185,6 +189,15 @@ describe('requireJwtAuth tenant context chaining', () => {
expect(tenantId).toBe('tenant-abc');
});

it('preserves a trusted pre-auth tenant when the JWT user belongs to another tenant', async () => {
const { tenantStorage } = require('@librechat/data-schemas');
const tenantId = await tenantStorage.run({ tenantId: 'tenant-from-header' }, () =>
runAuth({ tenantId: 'tenant-from-jwt', role: 'user' }),
);

expect(tenantId).toBe('tenant-from-header');
});

it('refreshes CloudFront auth cookies after passport auth succeeds', () => {
const req = mockReq({ tenantId: 'tenant-abc', role: 'user' });
const res = mockRes();
Expand Down
8 changes: 5 additions & 3 deletions api/server/middleware/config/app.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
const { logger } = require('@librechat/data-schemas');
const { getAppConfigOptionsFromUser } = require('@librechat/api');
const { getAppConfigOptionsFromUser, getEffectiveTenantId } = require('@librechat/api');
const { getAppConfig } = require('~/server/services/Config');

const configMiddleware = async (req, res, next) => {
try {
req.config = await getAppConfig(getAppConfigOptionsFromUser(req.user));
req.config = await getAppConfig(
getAppConfigOptionsFromUser(req.user, getEffectiveTenantId(req)),
);

next();
} catch (error) {
Expand All @@ -15,7 +17,7 @@ const configMiddleware = async (req, res, next) => {
});

try {
req.config = await getAppConfig({ tenantId: req.user?.tenantId });
req.config = await getAppConfig({ tenantId: getEffectiveTenantId(req) });
next();
} catch (fallbackError) {
logger.error('Fallback config middleware error:', fallbackError);
Expand Down
60 changes: 60 additions & 0 deletions api/server/middleware/config/app.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const { tenantStorage } = require('@librechat/data-schemas');

const mockGetAppConfig = jest.fn().mockResolvedValue({ ok: true });
jest.mock('~/server/services/Config', () => ({
getAppConfig: (...args) => mockGetAppConfig(...args),
}));

const configMiddleware = require('./app');

describe('configMiddleware — tenant resolution', () => {
afterEach(() => {
jest.clearAllMocks();
});

function runInTenantContext(tenantId, fn) {
return tenantStorage.run({ tenantId }, fn);
}

/**
* Reproduces the exact mismatch the review flagged: a deployment that
* resolves the authoritative tenant server-side (ALS, seeded from
* `req.tenantId` by `tenantContextMiddleware`) must not have `req.config`
* loaded — or cached — under a *different*, stale `req.user.tenantId` JWT
* claim. Every other admin capability check and write already trusts ALS
* via `getEffectiveTenantId`; `configMiddleware` must match.
*/
it('loads config for the ALS-resolved tenant, not a mismatched user.tenantId claim', async () => {
const req = { user: { tenantId: 'tenant-A-from-jwt', role: 'admin' }, path: '/api/config' };
const next = jest.fn();

await runInTenantContext('tenant-B-from-als', () => configMiddleware(req, {}, next));

expect(mockGetAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'tenant-B-from-als' }),
);
expect(next).toHaveBeenCalledWith();
});

it('falls back to the ALS-resolved tenant (not the user claim) on the error-recovery path too', async () => {
mockGetAppConfig.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({ ok: true });
const req = { user: { tenantId: 'tenant-A-from-jwt', role: 'admin' }, path: '/api/config' };
const next = jest.fn();

await runInTenantContext('tenant-B-from-als', () => configMiddleware(req, {}, next));

expect(mockGetAppConfig).toHaveBeenLastCalledWith({ tenantId: 'tenant-B-from-als' });
expect(next).toHaveBeenCalledWith();
});

it('falls back to the user claim when no ALS tenant is active (no divergence to resolve)', async () => {
const req = { user: { tenantId: 'tenant-A-from-jwt', role: 'admin' }, path: '/api/config' };
const next = jest.fn();

await configMiddleware(req, {}, next);

expect(mockGetAppConfig).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: 'tenant-A-from-jwt' }),
);
});
});
17 changes: 16 additions & 1 deletion api/server/routes/__tests__/config.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,13 +324,28 @@ describe('GET /api/config', () => {
});
});

it('should prefer user tenantId over getTenantId fallback', async () => {
it('should prefer the effective request tenant over the user tenantId', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockGetTenantId.mockReturnValue('fallback-tenant');
const app = createApp({ ...mockUser, tenantId: 'user-tenant' });

await request(app).get('/api/config');

expect(mockGetAppConfig).toHaveBeenCalledWith({
role: 'USER',
userId: 'user123',
idOnTheSource: undefined,
tenantId: 'fallback-tenant',
});
});

it('should use the user tenantId when no effective request tenant exists', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockGetTenantId.mockReturnValue(undefined);
const app = createApp({ ...mockUser, tenantId: 'user-tenant' });

await request(app).get('/api/config');

expect(mockGetAppConfig).toHaveBeenCalledWith({
role: 'USER',
userId: 'user123',
Expand Down
4 changes: 4 additions & 0 deletions api/server/routes/admin/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const handlers = createAdminConfigHandlers({
toggleConfigActive: db.toggleConfigActive,
hasAnyConfigReadAccess,
getReadableConfigSections,
mutateConfigWithRevision: db.mutateConfigWithRevision,
listConfigRevisions: db.listConfigRevisions,
hasConfigCapability,
hasCapability,
getAppConfig,
Expand All @@ -37,12 +39,14 @@ router.use(requireJwtAuth, requireAdminAccess);

router.get('/', handlers.listConfigs);
router.get('/base', handlers.getBaseConfig);
router.get('/:principalType/:principalId/revisions', handlers.listConfigRevisions);
router.get('/:principalType/:principalId', handlers.getConfig);
router.put('/:principalType/:principalId', handlers.upsertConfigOverrides);
router.patch('/:principalType/:principalId/fields', handlers.patchConfigField);
router.post('/:principalType/:principalId/fields/tombstone', handlers.tombstoneConfigField);
router.delete('/:principalType/:principalId/fields', handlers.deleteConfigField);
router.delete('/:principalType/:principalId', handlers.deleteConfigOverrides);
router.patch('/:principalType/:principalId/active', handlers.toggleConfig);
router.post('/:principalType/:principalId/atomic', handlers.mutateConfigAtomic);

module.exports = router;
Loading