mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
Merge branch 'main' into aron/data-retention-upstream
This commit is contained in:
commit
fe46564010
10 changed files with 365 additions and 83 deletions
|
|
@ -17,7 +17,7 @@ const configMiddleware = async (req, res, next) => {
|
|||
});
|
||||
|
||||
try {
|
||||
req.config = await getAppConfig();
|
||||
req.config = await getAppConfig({ tenantId: req.user?.tenantId });
|
||||
next();
|
||||
} catch (fallbackError) {
|
||||
logger.error('Fallback config middleware error:', fallbackError);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ jest.mock('~/server/services/Config/ldap', () => ({
|
|||
getLdapConfig: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const mockHasCapability = jest.fn();
|
||||
jest.mock('~/server/middleware/roles/capabilities', () => ({
|
||||
hasCapability: (...args) => mockHasCapability(...args),
|
||||
}));
|
||||
|
||||
const mockGetTenantId = jest.fn(() => undefined);
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
|
|
@ -76,6 +81,7 @@ afterEach(() => {
|
|||
delete process.env.SAML_ISSUER;
|
||||
delete process.env.SAML_CERT;
|
||||
delete process.env.SAML_SESSION_SECRET;
|
||||
delete process.env.ALLOW_ACCOUNT_DELETION;
|
||||
});
|
||||
|
||||
describe('GET /api/config', () => {
|
||||
|
|
@ -181,6 +187,35 @@ describe('GET /api/config', () => {
|
|||
expect(response.body).toHaveProperty('serverDomain');
|
||||
});
|
||||
|
||||
it('should default allowAccountDeletion to true when env var is unset', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.allowAccountDeletion).toBe(true);
|
||||
});
|
||||
|
||||
it('should set allowAccountDeletion to false when ALLOW_ACCOUNT_DELETION=false', async () => {
|
||||
process.env.ALLOW_ACCOUNT_DELETION = 'false';
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.allowAccountDeletion).toBe(false);
|
||||
});
|
||||
|
||||
it('should set allowAccountDeletion to true when ALLOW_ACCOUNT_DELETION=true', async () => {
|
||||
process.env.ALLOW_ACCOUNT_DELETION = 'true';
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.allowAccountDeletion).toBe(true);
|
||||
});
|
||||
|
||||
it('should return 500 when getAppConfig throws', async () => {
|
||||
mockGetAppConfig.mockRejectedValue(new Error('Config service failure'));
|
||||
const app = createApp(null);
|
||||
|
|
@ -277,6 +312,40 @@ describe('GET /api/config', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should set allowAccountDeletion to false for authenticated users without ACCESS_ADMIN', async () => {
|
||||
process.env.ALLOW_ACCOUNT_DELETION = 'false';
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
mockHasCapability.mockResolvedValue(false);
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.allowAccountDeletion).toBe(false);
|
||||
expect(mockHasCapability).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should override allowAccountDeletion to true for users with ACCESS_ADMIN capability', async () => {
|
||||
process.env.ALLOW_ACCOUNT_DELETION = 'false';
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
mockHasCapability.mockResolvedValue(true);
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.allowAccountDeletion).toBe(true);
|
||||
expect(mockHasCapability).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call hasCapability when allowAccountDeletion is already true', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.allowAccountDeletion).toBe(true);
|
||||
expect(mockHasCapability).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 when getAppConfig throws', async () => {
|
||||
mockGetAppConfig.mockRejectedValue(new Error('Config service failure'));
|
||||
const app = createApp(mockUser);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
const express = require('express');
|
||||
const { isEnabled, getBalanceConfig } = require('@librechat/api');
|
||||
const { defaultSocialLogins } = require('librechat-data-provider');
|
||||
const { logger, getTenantId } = require('@librechat/data-schemas');
|
||||
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const { hasCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
|
||||
|
|
@ -77,6 +78,10 @@ function buildSharedPayload() {
|
|||
publicSharedLinksEnabled,
|
||||
analyticsGtmId: process.env.ANALYTICS_GTM_ID,
|
||||
openidReuseTokens,
|
||||
/** Read inline (not module-level) for per-request evaluation and test isolation */
|
||||
allowAccountDeletion:
|
||||
process.env.ALLOW_ACCOUNT_DELETION === undefined ||
|
||||
isEnabled(process.env.ALLOW_ACCOUNT_DELETION),
|
||||
};
|
||||
|
||||
const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10);
|
||||
|
|
@ -172,6 +177,23 @@ router.get('/', async function (req, res) {
|
|||
payload.webSearch = webSearch;
|
||||
}
|
||||
|
||||
if (!payload.allowAccountDeletion) {
|
||||
try {
|
||||
const userId = req.user.id ?? req.user._id?.toString();
|
||||
if (userId) {
|
||||
const canDelete = await hasCapability(
|
||||
{ id: userId, role: req.user.role ?? '', tenantId: req.user.tenantId },
|
||||
SystemCapabilities.ACCESS_ADMIN,
|
||||
);
|
||||
if (canDelete) {
|
||||
payload.allowAccountDeletion = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[config] ACCESS_ADMIN capability check failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send(payload);
|
||||
} catch (err) {
|
||||
logger.error('Error in startup config', err);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import { SystemRoles } from 'librechat-data-provider';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { TUser } from 'librechat-data-provider';
|
||||
import Account from './Account';
|
||||
|
||||
jest.mock('./DisplayUsernameMessages', () => () => <div data-testid="display-username" />);
|
||||
jest.mock('./Avatar', () => () => <div data-testid="avatar" />);
|
||||
jest.mock('./TwoFactorAuthentication', () => () => <div data-testid="two-factor" />);
|
||||
jest.mock('./BackupCodesItem', () => () => <div data-testid="backup-codes" />);
|
||||
jest.mock('./DeleteAccount', () => () => <div data-testid="delete-account" />);
|
||||
|
||||
const mockUseAuthContext = jest.fn();
|
||||
const mockUseGetStartupConfig = jest.fn();
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useAuthContext: () => mockUseAuthContext(),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: () => mockUseGetStartupConfig(),
|
||||
}));
|
||||
|
||||
const baseUser: TUser = {
|
||||
id: 'user-123',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: '',
|
||||
role: SystemRoles.USER,
|
||||
provider: 'local',
|
||||
createdAt: '2023-01-01T00:00:00.000Z',
|
||||
updatedAt: '2023-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuthContext.mockReturnValue({ user: baseUser });
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { allowAccountDeletion: true } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('Account', () => {
|
||||
describe('DeleteAccount visibility', () => {
|
||||
it('renders DeleteAccount when allowAccountDeletion is true', () => {
|
||||
render(<Account />);
|
||||
expect(screen.getByTestId('delete-account')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides DeleteAccount when allowAccountDeletion is false', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { allowAccountDeletion: false } });
|
||||
render(<Account />);
|
||||
expect(screen.queryByTestId('delete-account')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows DeleteAccount when startup config is still loading', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
|
||||
render(<Account />);
|
||||
expect(screen.getByTestId('delete-account')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,10 +4,12 @@ import DeleteAccount from './DeleteAccount';
|
|||
import Avatar from './Avatar';
|
||||
import EnableTwoFactorItem from './TwoFactorAuthentication';
|
||||
import BackupCodesItem from './BackupCodesItem';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
function Account() {
|
||||
const { user } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
|
|
@ -29,9 +31,11 @@ function Account() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="pb-3">
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
{startupConfig?.allowAccountDeletion !== false && (
|
||||
<div className="pb-3">
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
113
package-lock.json
generated
113
package-lock.json
generated
|
|
@ -2586,9 +2586,9 @@
|
|||
}
|
||||
},
|
||||
"client/node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -6885,54 +6885,40 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
|
||||
"integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "11.1.2",
|
||||
"@chevrotain/types": "11.1.2",
|
||||
"lodash-es": "4.17.23"
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen/node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz",
|
||||
"integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
|
||||
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "11.1.2",
|
||||
"lodash-es": "4.17.23"
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast/node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz",
|
||||
"integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
|
||||
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz",
|
||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
|
||||
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
|
|
@ -9980,9 +9966,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.10",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
|
||||
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
|
||||
"version": "1.19.13",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz",
|
||||
"integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
|
|
@ -23524,37 +23510,33 @@
|
|||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
|
||||
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "11.1.2",
|
||||
"@chevrotain/gast": "11.1.2",
|
||||
"@chevrotain/regexp-to-ast": "11.1.2",
|
||||
"@chevrotain/types": "11.1.2",
|
||||
"@chevrotain/utils": "11.1.2",
|
||||
"lodash-es": "4.17.23"
|
||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/regexp-to-ast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0",
|
||||
"@chevrotain/utils": "12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain-allstar": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz",
|
||||
"integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==",
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz",
|
||||
"integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chevrotain": "^11.0.0"
|
||||
"chevrotain": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain/node_modules/lodash-es": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
|
|
@ -28641,9 +28623,9 @@
|
|||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"version": "4.12.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
|
@ -31608,13 +31590,14 @@
|
|||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz",
|
||||
"integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==",
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz",
|
||||
"integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chevrotain": "~11.1.1",
|
||||
"chevrotain-allstar": "~0.3.1",
|
||||
"@chevrotain/regexp-to-ast": "~12.0.0",
|
||||
"chevrotain": "~12.0.0",
|
||||
"chevrotain-allstar": "~0.4.1",
|
||||
"vscode-languageserver": "~9.0.1",
|
||||
"vscode-languageserver-textdocument": "~1.0.11",
|
||||
"vscode-uri": "~3.1.0"
|
||||
|
|
|
|||
|
|
@ -174,7 +174,6 @@
|
|||
"monaco-editor": {
|
||||
"dompurify": "3.3.2"
|
||||
},
|
||||
"serialize-javascript": "^7.0.3",
|
||||
"svgo": "^2.8.2"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { createAppConfigService } from './service';
|
||||
import { createAppConfigService, _resetOverrideStrictCache } from './service';
|
||||
|
||||
/** Extends AppConfig with mock fields used by merge behavior tests. */
|
||||
interface TestConfig extends AppConfig {
|
||||
|
|
@ -229,6 +229,124 @@ describe('createAppConfigService', () => {
|
|||
expect((config as TestConfig).x).toBe('admin-only');
|
||||
});
|
||||
|
||||
it('passes empty principals to getApplicableConfigs when buildPrincipals returns empty', async () => {
|
||||
const deps = createDeps({
|
||||
getUserPrincipals: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
||||
const config = await getAppConfig({ userId: 'uid1', role: 'USER' });
|
||||
|
||||
expect(deps.getUserPrincipals).toHaveBeenCalledWith({ userId: 'uid1', role: 'USER' });
|
||||
expect(deps.getApplicableConfigs).toHaveBeenCalledWith([]);
|
||||
expect(config).toEqual(deps._baseConfig);
|
||||
});
|
||||
|
||||
describe('strict mode (TENANT_ISOLATION_STRICT=true)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.TENANT_ISOLATION_STRICT = 'true';
|
||||
_resetOverrideStrictCache();
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.TENANT_ISOLATION_STRICT;
|
||||
_resetOverrideStrictCache();
|
||||
});
|
||||
|
||||
it('skips DB query for empty principals without tenantId and does not cache', async () => {
|
||||
const deps = createDeps();
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
||||
const config = await getAppConfig();
|
||||
|
||||
expect(deps.getApplicableConfigs).not.toHaveBeenCalled();
|
||||
expect(config).toEqual(deps._baseConfig);
|
||||
|
||||
const setCalls = deps._cache.set.mock.calls.filter(
|
||||
([key]: [string, unknown]) => key !== '_BASE_',
|
||||
);
|
||||
expect(setCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('queries DB when tenantId is present', async () => {
|
||||
const deps = createDeps();
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
||||
await getAppConfig({ tenantId: 'tenant-a' });
|
||||
|
||||
expect(deps.getApplicableConfigs).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('warns once when non-empty principals proceed without tenantId', async () => {
|
||||
const { logger } = jest.requireActual('@librechat/data-schemas');
|
||||
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
const deps = createDeps();
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
||||
await getAppConfig({ role: 'USER' });
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No tenantId in strict mode'));
|
||||
const warnCount = warnSpy.mock.calls.length;
|
||||
|
||||
await getAppConfig({ role: 'ADMIN' });
|
||||
expect(warnSpy).toHaveBeenCalledTimes(warnCount);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('falls through to getApplicableConfigs when ALS has tenant context despite no tenantId param', async () => {
|
||||
const { tenantStorage } = jest.requireActual('@librechat/data-schemas');
|
||||
const deps = createDeps({
|
||||
getApplicableConfigs: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ priority: 5, overrides: { restricted: true }, isActive: true }]),
|
||||
});
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
||||
const config = await tenantStorage.run({ tenantId: 'tenant-a' }, async () =>
|
||||
getAppConfig(),
|
||||
);
|
||||
|
||||
expect(deps.getApplicableConfigs).toHaveBeenCalledWith([]);
|
||||
expect((config as TestConfig).restricted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-strict mode (TENANT_ISOLATION_STRICT unset)', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.TENANT_ISOLATION_STRICT;
|
||||
_resetOverrideStrictCache();
|
||||
});
|
||||
afterEach(() => {
|
||||
_resetOverrideStrictCache();
|
||||
});
|
||||
|
||||
it('passes empty principals through to getApplicableConfigs', async () => {
|
||||
const deps = createDeps();
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
||||
await getAppConfig();
|
||||
|
||||
expect(deps.getApplicableConfigs).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not cache on buildPrincipals error — retries on next request', async () => {
|
||||
const deps = createDeps({
|
||||
getUserPrincipals: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('transient'))
|
||||
.mockResolvedValue([{ principalType: 'role', principalId: 'USER' }]),
|
||||
});
|
||||
const { getAppConfig } = createAppConfigService(deps);
|
||||
|
||||
const first = await getAppConfig({ userId: 'uid1', role: 'USER' });
|
||||
expect(first).toEqual(deps._baseConfig);
|
||||
expect(deps.getApplicableConfigs).not.toHaveBeenCalled();
|
||||
|
||||
await getAppConfig({ userId: 'uid1', role: 'USER' });
|
||||
expect(deps.getUserPrincipals).toHaveBeenCalledTimes(2);
|
||||
expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to base config on getApplicableConfigs error', async () => {
|
||||
const deps = createDeps({
|
||||
getApplicableConfigs: jest.fn().mockRejectedValue(new Error('DB down')),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { PrincipalType } from 'librechat-data-provider';
|
||||
import { logger, mergeConfigOverrides, BASE_CONFIG_PRINCIPAL_ID } from '@librechat/data-schemas';
|
||||
import {
|
||||
logger,
|
||||
getTenantId,
|
||||
mergeConfigOverrides,
|
||||
BASE_CONFIG_PRINCIPAL_ID,
|
||||
} from '@librechat/data-schemas';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { AppConfig, IConfig } from '@librechat/data-schemas';
|
||||
|
||||
const BASE_CONFIG_KEY = '_BASE_';
|
||||
|
||||
const DEFAULT_OVERRIDE_CACHE_TTL = 60_000;
|
||||
export const DEFAULT_OVERRIDE_CACHE_TTL = 60_000;
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -50,9 +55,9 @@ function isStrictOverrideMode(): boolean {
|
|||
return (_strictOverride ??= process.env.TENANT_ISOLATION_STRICT === 'true');
|
||||
}
|
||||
|
||||
/** @internal Resets the cached strict-override flag. Exposed for test teardown only. */
|
||||
let _warnedNoTenantInStrictMode = false;
|
||||
|
||||
/** @internal Resets the memoized strict-override flag and one-time no-tenantId warning gate. Exposed for test teardown only. */
|
||||
export function _resetOverrideStrictCache(): void {
|
||||
_strictOverride = undefined;
|
||||
_warnedNoTenantInStrictMode = false;
|
||||
|
|
@ -60,13 +65,6 @@ export function _resetOverrideStrictCache(): void {
|
|||
|
||||
function overrideCacheKey(role?: string, userId?: string, tenantId?: string): string {
|
||||
const tenant = tenantId || '__default__';
|
||||
if (!tenantId && isStrictOverrideMode() && !_warnedNoTenantInStrictMode) {
|
||||
_warnedNoTenantInStrictMode = true;
|
||||
logger.warn(
|
||||
'[overrideCacheKey] No tenantId in strict mode — falling back to __default__. ' +
|
||||
'This likely indicates a code path that bypasses the tenant context middleware.',
|
||||
);
|
||||
}
|
||||
if (userId && role) {
|
||||
return `_OVERRIDE_:${tenant}:${role}:${userId}`;
|
||||
}
|
||||
|
|
@ -168,8 +166,32 @@ export function createAppConfigService(deps: AppConfigServiceDeps) {
|
|||
}
|
||||
}
|
||||
|
||||
const principals = await buildPrincipals(role, userId).catch((error: unknown) => {
|
||||
logger.error('[getAppConfig] Error building principals, falling back to base:', error);
|
||||
return null;
|
||||
});
|
||||
if (principals === null) {
|
||||
return baseConfig;
|
||||
}
|
||||
|
||||
// Strict-isolation + no tenant (param or ALS) = pathological path (middleware bypass or
|
||||
// unauthenticated startup). Pre-tenant calls use baseOnly:true; admin calls carry tenantId.
|
||||
// If ALS has a tenant, Mongoose scopes queries to that tenant's overrides — must fall through.
|
||||
// Not cached: the cache key doesn't include ALS context, so a cached __default__ entry would
|
||||
// be served to later ALS-scoped calls that share the same param-derived key.
|
||||
if (principals.length === 0 && !tenantId && !getTenantId() && isStrictOverrideMode()) {
|
||||
return baseConfig;
|
||||
}
|
||||
|
||||
if (!tenantId && isStrictOverrideMode() && !_warnedNoTenantInStrictMode) {
|
||||
_warnedNoTenantInStrictMode = true;
|
||||
logger.warn(
|
||||
'[getAppConfig] No tenantId in strict mode — falling back to __default__. ' +
|
||||
'This likely indicates a code path that bypasses the tenant context middleware.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const principals = await buildPrincipals(role, userId);
|
||||
const configs = await getApplicableConfigs(principals);
|
||||
|
||||
if (configs.length === 0) {
|
||||
|
|
|
|||
|
|
@ -866,6 +866,7 @@ export type TStartupConfig = {
|
|||
sharePointPickerGraphScope?: string;
|
||||
sharePointPickerSharePointScope?: string;
|
||||
openidReuseTokens?: boolean;
|
||||
allowAccountDeletion: boolean;
|
||||
minPasswordLength?: number;
|
||||
webSearch?: {
|
||||
searchProvider?: SearchProviders;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue