librechat-data-provider 0.8.301 → 0.8.400

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jest.config.js CHANGED
@@ -14,5 +14,6 @@ module.exports = {
14
14
  // lines: 57,
15
15
  // },
16
16
  // },
17
+ maxWorkers: '50%',
17
18
  restoreMocks: true,
18
19
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "librechat-data-provider",
3
- "version": "0.8.301",
3
+ "version": "0.8.400",
4
4
  "description": "data services for librechat apps",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.es.js",
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ /**
6
+ * Tests for buildLoginRedirectUrl and apiBaseUrl under subdirectory deployments.
7
+ *
8
+ * Uses jest.isolateModules to re-import api-endpoints with a <base href="/chat/">
9
+ * element present, simulating a subdirectory deployment where BASE_URL = '/chat'.
10
+ *
11
+ * Tests that need to override window.location use explicit function arguments
12
+ * instead of mocking the global, since jsdom 26+ does not allow redefining it.
13
+ */
14
+
15
+ function loadModuleWithBase(baseHref: string) {
16
+ const base = document.createElement('base');
17
+ base.setAttribute('href', baseHref);
18
+ document.head.appendChild(base);
19
+
20
+ const proc = process as typeof process & { browser?: boolean };
21
+ const originalBrowser = proc.browser;
22
+
23
+ let mod: typeof import('../src/api-endpoints');
24
+ try {
25
+ proc.browser = true;
26
+ jest.isolateModules(() => {
27
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- static import not usable inside isolateModules
28
+ mod = require('../src/api-endpoints');
29
+ });
30
+ return mod!;
31
+ } finally {
32
+ proc.browser = originalBrowser;
33
+ document.head.removeChild(base);
34
+ }
35
+ }
36
+
37
+ describe('buildLoginRedirectUrl — subdirectory deployment (BASE_URL = /chat)', () => {
38
+ let buildLoginRedirectUrl: typeof import('../src/api-endpoints').buildLoginRedirectUrl;
39
+ let apiBaseUrl: typeof import('../src/api-endpoints').apiBaseUrl;
40
+
41
+ beforeAll(() => {
42
+ const mod = loadModuleWithBase('/chat/');
43
+ buildLoginRedirectUrl = mod.buildLoginRedirectUrl;
44
+ apiBaseUrl = mod.apiBaseUrl;
45
+ });
46
+
47
+ it('sets BASE_URL to "/chat" (trailing slash stripped)', () => {
48
+ expect(apiBaseUrl()).toBe('/chat');
49
+ });
50
+
51
+ it('returns "/login" without base prefix (compatible with React Router navigate)', () => {
52
+ const result = buildLoginRedirectUrl('/chat/c/new', '', '');
53
+ expect(result).toMatch(/^\/login/);
54
+ expect(result).not.toMatch(/^\/chat/);
55
+ });
56
+
57
+ it('strips base prefix from redirect_to when pathname includes base', () => {
58
+ const result = buildLoginRedirectUrl('/chat/c/abc123', '?model=gpt-4', '');
59
+ const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
60
+ expect(redirectTo).toBe('/c/abc123?model=gpt-4');
61
+ expect(redirectTo).not.toContain('/chat/');
62
+ });
63
+
64
+ it('works with pathnames that do not include the base prefix', () => {
65
+ const result = buildLoginRedirectUrl('/c/new', '', '');
66
+ const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
67
+ expect(redirectTo).toBe('/c/new');
68
+ });
69
+
70
+ it('returns plain /login for base-prefixed login path', () => {
71
+ expect(buildLoginRedirectUrl('/chat/login', '', '')).toBe('/login');
72
+ });
73
+
74
+ it('returns plain /login for base-prefixed login sub-path', () => {
75
+ expect(buildLoginRedirectUrl('/chat/login/2fa', '', '')).toBe('/login');
76
+ });
77
+
78
+ it('returns plain /login when stripped path is root (no pointless redirect_to=/)', () => {
79
+ const result = buildLoginRedirectUrl('/chat', '', '');
80
+ expect(result).toBe('/login');
81
+ expect(result).not.toContain('redirect_to');
82
+ });
83
+
84
+ it('composes correct full URL for window.location.href (apiBaseUrl + buildLoginRedirectUrl)', () => {
85
+ const fullUrl = apiBaseUrl() + buildLoginRedirectUrl('/chat/c/abc123', '', '');
86
+ expect(fullUrl).toBe('/chat/login?redirect_to=%2Fc%2Fabc123');
87
+ expect(fullUrl).not.toContain('/chat/chat/');
88
+ });
89
+
90
+ it('encodes query params and hash correctly after stripping base', () => {
91
+ const result = buildLoginRedirectUrl('/chat/c/deep', '?q=hello&submit=true', '#section');
92
+ const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
93
+ expect(redirectTo).toBe('/c/deep?q=hello&submit=true#section');
94
+ });
95
+
96
+ it('does not strip base when path shares a prefix but is not a segment match', () => {
97
+ const result = buildLoginRedirectUrl('/chatroom/c/abc123', '', '');
98
+ const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
99
+ expect(redirectTo).toBe('/chatroom/c/abc123');
100
+ });
101
+
102
+ it('does not strip base from /chatbot path', () => {
103
+ const result = buildLoginRedirectUrl('/chatbot', '', '');
104
+ const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
105
+ expect(redirectTo).toBe('/chatbot');
106
+ });
107
+ });
108
+
109
+ describe('buildLoginRedirectUrl — deep subdirectory (BASE_URL = /app/chat)', () => {
110
+ let buildLoginRedirectUrl: typeof import('../src/api-endpoints').buildLoginRedirectUrl;
111
+ let apiBaseUrl: typeof import('../src/api-endpoints').apiBaseUrl;
112
+
113
+ beforeAll(() => {
114
+ const mod = loadModuleWithBase('/app/chat/');
115
+ buildLoginRedirectUrl = mod.buildLoginRedirectUrl;
116
+ apiBaseUrl = mod.apiBaseUrl;
117
+ });
118
+
119
+ it('sets BASE_URL to "/app/chat"', () => {
120
+ expect(apiBaseUrl()).toBe('/app/chat');
121
+ });
122
+
123
+ it('strips deep base prefix from redirect_to', () => {
124
+ const result = buildLoginRedirectUrl('/app/chat/c/abc123', '', '');
125
+ const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
126
+ expect(redirectTo).toBe('/c/abc123');
127
+ });
128
+
129
+ it('full URL does not double the base prefix', () => {
130
+ const fullUrl = apiBaseUrl() + buildLoginRedirectUrl('/app/chat/c/abc123', '', '');
131
+ expect(fullUrl).toBe('/app/chat/login?redirect_to=%2Fc%2Fabc123');
132
+ expect(fullUrl).not.toContain('/app/chat/app/chat/');
133
+ });
134
+
135
+ it('does not strip from /app/chatroom (segment boundary check)', () => {
136
+ const result = buildLoginRedirectUrl('/app/chatroom/page', '', '');
137
+ const redirectTo = decodeURIComponent(result.split('redirect_to=')[1]);
138
+ expect(redirectTo).toBe('/app/chatroom/page');
139
+ });
140
+ });
@@ -4,18 +4,8 @@
4
4
  import { buildLoginRedirectUrl } from '../src/api-endpoints';
5
5
 
6
6
  describe('buildLoginRedirectUrl', () => {
7
- let savedLocation: Location;
8
-
9
- beforeEach(() => {
10
- savedLocation = window.location;
11
- Object.defineProperty(window, 'location', {
12
- value: { pathname: '/c/abc123', search: '?model=gpt-4', hash: '#msg-5' },
13
- writable: true,
14
- });
15
- });
16
-
17
7
  afterEach(() => {
18
- Object.defineProperty(window, 'location', { value: savedLocation, writable: true });
8
+ window.history.replaceState({}, '', '/');
19
9
  });
20
10
 
21
11
  it('builds a login URL from explicit args', () => {
@@ -31,18 +21,16 @@ describe('buildLoginRedirectUrl', () => {
31
21
  });
32
22
 
33
23
  it('falls back to window.location when no args provided', () => {
24
+ window.history.replaceState({}, '', '/c/abc123?model=gpt-4#msg-5');
34
25
  const result = buildLoginRedirectUrl();
35
26
  const encoded = result.split('redirect_to=')[1];
36
27
  expect(decodeURIComponent(encoded)).toBe('/c/abc123?model=gpt-4#msg-5');
37
28
  });
38
29
 
39
- it('falls back to "/" when all location parts are empty', () => {
40
- Object.defineProperty(window, 'location', {
41
- value: { pathname: '', search: '', hash: '' },
42
- writable: true,
43
- });
30
+ it('returns plain /login when all location parts are empty (root)', () => {
31
+ window.history.replaceState({}, '', '/');
44
32
  const result = buildLoginRedirectUrl();
45
- expect(result).toBe('/login?redirect_to=%2F');
33
+ expect(result).toBe('/login');
46
34
  });
47
35
 
48
36
  it('returns plain /login when pathname is /login (prevents recursive redirect)', () => {
@@ -51,10 +39,7 @@ describe('buildLoginRedirectUrl', () => {
51
39
  });
52
40
 
53
41
  it('returns plain /login when window.location is already /login', () => {
54
- Object.defineProperty(window, 'location', {
55
- value: { pathname: '/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
56
- writable: true,
57
- });
42
+ window.history.replaceState({}, '', '/login?redirect_to=%2Fc%2Fabc');
58
43
  const result = buildLoginRedirectUrl();
59
44
  expect(result).toBe('/login');
60
45
  });
@@ -65,10 +50,7 @@ describe('buildLoginRedirectUrl', () => {
65
50
  });
66
51
 
67
52
  it('returns plain /login for basename-prefixed /login (e.g. /librechat/login)', () => {
68
- Object.defineProperty(window, 'location', {
69
- value: { pathname: '/librechat/login', search: '?redirect_to=%2Fc%2Fabc', hash: '' },
70
- writable: true,
71
- });
53
+ window.history.replaceState({}, '', '/librechat/login?redirect_to=%2Fc%2Fabc');
72
54
  const result = buildLoginRedirectUrl();
73
55
  expect(result).toBe('/login');
74
56
  });
@@ -78,6 +60,12 @@ describe('buildLoginRedirectUrl', () => {
78
60
  expect(result).toBe('/login');
79
61
  });
80
62
 
63
+ it('returns plain /login for root path (no pointless redirect_to=/)', () => {
64
+ const result = buildLoginRedirectUrl('/', '', '');
65
+ expect(result).toBe('/login');
66
+ expect(result).not.toContain('redirect_to');
67
+ });
68
+
81
69
  it('does NOT match paths where "login" is a substring of a segment', () => {
82
70
  const result = buildLoginRedirectUrl('/c/loginhistory', '', '');
83
71
  expect(result).toContain('redirect_to=');
@@ -0,0 +1,147 @@
1
+ import { SSEOptionsSchema, MCPServerUserInputSchema } from '../src/mcp';
2
+
3
+ describe('MCPServerUserInputSchema', () => {
4
+ describe('env variable exfiltration prevention', () => {
5
+ it('should confirm admin schema resolves env vars (attack vector baseline)', () => {
6
+ process.env.FAKE_SECRET = 'leaked-secret-value';
7
+ const adminResult = SSEOptionsSchema.safeParse({
8
+ type: 'sse',
9
+ url: 'http://attacker.com/?secret=${FAKE_SECRET}',
10
+ });
11
+ expect(adminResult.success).toBe(true);
12
+ if (adminResult.success) {
13
+ expect(adminResult.data.url).toContain('leaked-secret-value');
14
+ }
15
+ delete process.env.FAKE_SECRET;
16
+ });
17
+
18
+ it('should reject the same URL through user input schema', () => {
19
+ process.env.FAKE_SECRET = 'leaked-secret-value';
20
+ const userResult = MCPServerUserInputSchema.safeParse({
21
+ type: 'sse',
22
+ url: 'http://attacker.com/?secret=${FAKE_SECRET}',
23
+ });
24
+ expect(userResult.success).toBe(false);
25
+ delete process.env.FAKE_SECRET;
26
+ });
27
+ });
28
+
29
+ describe('env variable rejection', () => {
30
+ it('should reject SSE URLs containing env variable patterns', () => {
31
+ const result = MCPServerUserInputSchema.safeParse({
32
+ type: 'sse',
33
+ url: 'http://attacker.com/?secret=${FAKE_SECRET}',
34
+ });
35
+ expect(result.success).toBe(false);
36
+ });
37
+
38
+ it('should reject streamable-http URLs containing env variable patterns', () => {
39
+ const result = MCPServerUserInputSchema.safeParse({
40
+ type: 'streamable-http',
41
+ url: 'http://attacker.com/?jwt=${JWT_SECRET}',
42
+ });
43
+ expect(result.success).toBe(false);
44
+ });
45
+
46
+ it('should reject WebSocket URLs containing env variable patterns', () => {
47
+ const result = MCPServerUserInputSchema.safeParse({
48
+ type: 'websocket',
49
+ url: 'ws://attacker.com/?secret=${FAKE_SECRET}',
50
+ });
51
+ expect(result.success).toBe(false);
52
+ });
53
+ });
54
+
55
+ describe('protocol allowlisting', () => {
56
+ it('should reject file:// URLs for SSE', () => {
57
+ const result = MCPServerUserInputSchema.safeParse({
58
+ type: 'sse',
59
+ url: 'file:///etc/passwd',
60
+ });
61
+ expect(result.success).toBe(false);
62
+ });
63
+
64
+ it('should reject ftp:// URLs for streamable-http', () => {
65
+ const result = MCPServerUserInputSchema.safeParse({
66
+ type: 'streamable-http',
67
+ url: 'ftp://internal-server/data',
68
+ });
69
+ expect(result.success).toBe(false);
70
+ });
71
+
72
+ it('should reject http:// URLs for WebSocket', () => {
73
+ const result = MCPServerUserInputSchema.safeParse({
74
+ type: 'websocket',
75
+ url: 'http://example.com/ws',
76
+ });
77
+ expect(result.success).toBe(false);
78
+ });
79
+
80
+ it('should reject ws:// URLs for SSE', () => {
81
+ const result = MCPServerUserInputSchema.safeParse({
82
+ type: 'sse',
83
+ url: 'ws://example.com/sse',
84
+ });
85
+ expect(result.success).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe('valid URL acceptance', () => {
90
+ it('should accept valid https:// SSE URLs', () => {
91
+ const result = MCPServerUserInputSchema.safeParse({
92
+ type: 'sse',
93
+ url: 'https://mcp-server.com/sse',
94
+ });
95
+ expect(result.success).toBe(true);
96
+ if (result.success) {
97
+ expect(result.data.url).toBe('https://mcp-server.com/sse');
98
+ }
99
+ });
100
+
101
+ it('should accept valid http:// SSE URLs', () => {
102
+ const result = MCPServerUserInputSchema.safeParse({
103
+ type: 'sse',
104
+ url: 'http://mcp-server.com/sse',
105
+ });
106
+ expect(result.success).toBe(true);
107
+ });
108
+
109
+ it('should accept valid wss:// WebSocket URLs', () => {
110
+ const result = MCPServerUserInputSchema.safeParse({
111
+ type: 'websocket',
112
+ url: 'wss://mcp-server.com/ws',
113
+ });
114
+ expect(result.success).toBe(true);
115
+ if (result.success) {
116
+ expect(result.data.url).toBe('wss://mcp-server.com/ws');
117
+ }
118
+ });
119
+
120
+ it('should accept valid ws:// WebSocket URLs', () => {
121
+ const result = MCPServerUserInputSchema.safeParse({
122
+ type: 'websocket',
123
+ url: 'ws://mcp-server.com/ws',
124
+ });
125
+ expect(result.success).toBe(true);
126
+ });
127
+
128
+ it('should accept valid https:// streamable-http URLs', () => {
129
+ const result = MCPServerUserInputSchema.safeParse({
130
+ type: 'streamable-http',
131
+ url: 'https://mcp-server.com/http',
132
+ });
133
+ expect(result.success).toBe(true);
134
+ if (result.success) {
135
+ expect(result.data.url).toBe('https://mcp-server.com/http');
136
+ }
137
+ });
138
+
139
+ it('should accept valid http:// streamable-http URLs with "http" alias', () => {
140
+ const result = MCPServerUserInputSchema.safeParse({
141
+ type: 'http',
142
+ url: 'http://mcp-server.com/mcp',
143
+ });
144
+ expect(result.success).toBe(true);
145
+ });
146
+ });
147
+ });
@@ -1,16 +1,19 @@
1
1
  /**
2
- * @jest-environment jsdom
2
+ * @jest-environment @happy-dom/jest-environment
3
3
  */
4
4
  import axios from 'axios';
5
5
  import { setTokenHeader } from '../src/headers-helpers';
6
6
 
7
7
  /**
8
8
  * The response interceptor in request.ts registers at import time when
9
- * `typeof window !== 'undefined'` (jsdom provides window).
9
+ * `typeof window !== 'undefined'` (happy-dom provides window).
10
10
  *
11
11
  * We use axios's built-in request adapter mock to avoid real HTTP calls,
12
12
  * and verify the interceptor's behavior by observing whether a 401 triggers
13
13
  * a refresh POST or is immediately rejected.
14
+ *
15
+ * happy-dom is used instead of jsdom because it allows overriding
16
+ * window.location via Object.defineProperty, which jsdom 26+ blocks.
14
17
  */
15
18
 
16
19
  const mockAdapter = jest.fn();
@@ -38,6 +41,7 @@ afterEach(() => {
38
41
  Object.defineProperty(window, 'location', {
39
42
  value: savedLocation,
40
43
  writable: true,
44
+ configurable: true,
41
45
  });
42
46
  });
43
47
 
@@ -45,6 +49,7 @@ function setWindowLocation(overrides: Partial<Location>) {
45
49
  Object.defineProperty(window, 'location', {
46
50
  value: { ...window.location, ...overrides },
47
51
  writable: true,
52
+ configurable: true,
48
53
  });
49
54
  }
50
55
 
@@ -1,4 +1,4 @@
1
- import { extractEnvVariable } from '../src/utils';
1
+ import { extractEnvVariable, isSensitiveEnvVar } from '../src/utils';
2
2
 
3
3
  describe('Environment Variable Extraction', () => {
4
4
  const originalEnv = process.env;
@@ -7,7 +7,7 @@ describe('Environment Variable Extraction', () => {
7
7
  process.env = {
8
8
  ...originalEnv,
9
9
  TEST_API_KEY: 'test-api-key-value',
10
- ANOTHER_SECRET: 'another-secret-value',
10
+ ANOTHER_VALUE: 'another-value',
11
11
  };
12
12
  });
13
13
 
@@ -55,7 +55,7 @@ describe('Environment Variable Extraction', () => {
55
55
  describe('extractEnvVariable function', () => {
56
56
  it('should extract environment variables from exact matches', () => {
57
57
  expect(extractEnvVariable('${TEST_API_KEY}')).toBe('test-api-key-value');
58
- expect(extractEnvVariable('${ANOTHER_SECRET}')).toBe('another-secret-value');
58
+ expect(extractEnvVariable('${ANOTHER_VALUE}')).toBe('another-value');
59
59
  });
60
60
 
61
61
  it('should extract environment variables from strings with prefixes', () => {
@@ -82,7 +82,7 @@ describe('Environment Variable Extraction', () => {
82
82
  describe('extractEnvVariable', () => {
83
83
  it('should extract environment variable values', () => {
84
84
  expect(extractEnvVariable('${TEST_API_KEY}')).toBe('test-api-key-value');
85
- expect(extractEnvVariable('${ANOTHER_SECRET}')).toBe('another-secret-value');
85
+ expect(extractEnvVariable('${ANOTHER_VALUE}')).toBe('another-value');
86
86
  });
87
87
 
88
88
  it('should return the original string if environment variable is not found', () => {
@@ -126,4 +126,71 @@ describe('Environment Variable Extraction', () => {
126
126
  );
127
127
  });
128
128
  });
129
+
130
+ describe('isSensitiveEnvVar', () => {
131
+ it('should flag infrastructure secrets', () => {
132
+ expect(isSensitiveEnvVar('JWT_SECRET')).toBe(true);
133
+ expect(isSensitiveEnvVar('JWT_REFRESH_SECRET')).toBe(true);
134
+ expect(isSensitiveEnvVar('CREDS_KEY')).toBe(true);
135
+ expect(isSensitiveEnvVar('CREDS_IV')).toBe(true);
136
+ expect(isSensitiveEnvVar('MEILI_MASTER_KEY')).toBe(true);
137
+ expect(isSensitiveEnvVar('MONGO_URI')).toBe(true);
138
+ expect(isSensitiveEnvVar('REDIS_URI')).toBe(true);
139
+ expect(isSensitiveEnvVar('REDIS_PASSWORD')).toBe(true);
140
+ });
141
+
142
+ it('should allow non-infrastructure vars through (including operator-configured secrets)', () => {
143
+ expect(isSensitiveEnvVar('OPENAI_API_KEY')).toBe(false);
144
+ expect(isSensitiveEnvVar('ANTHROPIC_API_KEY')).toBe(false);
145
+ expect(isSensitiveEnvVar('GOOGLE_KEY')).toBe(false);
146
+ expect(isSensitiveEnvVar('PROXY')).toBe(false);
147
+ expect(isSensitiveEnvVar('DEBUG_LOGGING')).toBe(false);
148
+ expect(isSensitiveEnvVar('DOMAIN_CLIENT')).toBe(false);
149
+ expect(isSensitiveEnvVar('APP_TITLE')).toBe(false);
150
+ expect(isSensitiveEnvVar('OPENID_CLIENT_SECRET')).toBe(false);
151
+ expect(isSensitiveEnvVar('DISCORD_CLIENT_SECRET')).toBe(false);
152
+ expect(isSensitiveEnvVar('MY_CUSTOM_SECRET')).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('extractEnvVariable sensitive var blocklist', () => {
157
+ beforeEach(() => {
158
+ process.env.JWT_SECRET = 'super-secret-jwt';
159
+ process.env.JWT_REFRESH_SECRET = 'super-secret-refresh';
160
+ process.env.CREDS_KEY = 'encryption-key';
161
+ process.env.CREDS_IV = 'encryption-iv';
162
+ process.env.MEILI_MASTER_KEY = 'meili-key';
163
+ process.env.MONGO_URI = 'mongodb://user:pass@host/db';
164
+ process.env.REDIS_URI = 'redis://:pass@host:6379';
165
+ process.env.REDIS_PASSWORD = 'redis-pass';
166
+ process.env.OPENAI_API_KEY = 'sk-legit-key';
167
+ });
168
+
169
+ it('should refuse to resolve sensitive vars (single-match path)', () => {
170
+ expect(extractEnvVariable('${JWT_SECRET}')).toBe('${JWT_SECRET}');
171
+ expect(extractEnvVariable('${JWT_REFRESH_SECRET}')).toBe('${JWT_REFRESH_SECRET}');
172
+ expect(extractEnvVariable('${CREDS_KEY}')).toBe('${CREDS_KEY}');
173
+ expect(extractEnvVariable('${CREDS_IV}')).toBe('${CREDS_IV}');
174
+ expect(extractEnvVariable('${MEILI_MASTER_KEY}')).toBe('${MEILI_MASTER_KEY}');
175
+ expect(extractEnvVariable('${MONGO_URI}')).toBe('${MONGO_URI}');
176
+ expect(extractEnvVariable('${REDIS_URI}')).toBe('${REDIS_URI}');
177
+ expect(extractEnvVariable('${REDIS_PASSWORD}')).toBe('${REDIS_PASSWORD}');
178
+ });
179
+
180
+ it('should refuse to resolve sensitive vars in composite strings (multi-match path)', () => {
181
+ expect(extractEnvVariable('key=${JWT_SECRET}&more')).toBe('key=${JWT_SECRET}&more');
182
+ expect(extractEnvVariable('db=${MONGO_URI}/extra')).toBe('db=${MONGO_URI}/extra');
183
+ });
184
+
185
+ it('should still resolve non-sensitive vars normally', () => {
186
+ expect(extractEnvVariable('${OPENAI_API_KEY}')).toBe('sk-legit-key');
187
+ expect(extractEnvVariable('Bearer ${OPENAI_API_KEY}')).toBe('Bearer sk-legit-key');
188
+ });
189
+
190
+ it('should resolve non-sensitive vars while blocking sensitive ones in the same string', () => {
191
+ expect(extractEnvVariable('key=${OPENAI_API_KEY}&secret=${JWT_SECRET}')).toBe(
192
+ 'key=sk-legit-key&secret=${JWT_SECRET}',
193
+ );
194
+ });
195
+ });
129
196
  });
@@ -200,9 +200,9 @@ export type TUpdateResourcePermissionsResponse = z.infer<
200
200
  * Principal search request parameters
201
201
  */
202
202
  export type TPrincipalSearchParams = {
203
- q: string; // search query (required)
204
- limit?: number; // max results (1-50, default 10)
205
- type?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE; // filter by type (optional)
203
+ q: string;
204
+ limit?: number;
205
+ types?: Array<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE>;
206
206
  };
207
207
 
208
208
  /**
@@ -228,7 +228,7 @@ export type TPrincipalSearchResult = {
228
228
  export type TPrincipalSearchResponse = {
229
229
  query: string;
230
230
  limit: number;
231
- type?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE;
231
+ types?: Array<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE> | null;
232
232
  results: TPrincipalSearchResult[];
233
233
  count: number;
234
234
  sources: {
@@ -174,13 +174,20 @@ const LOGIN_PATH_RE = /(?:^|\/)login(?:\/|$)/;
174
174
  export function buildLoginRedirectUrl(pathname?: string, search?: string, hash?: string): string {
175
175
  const p = pathname ?? window.location.pathname;
176
176
  if (LOGIN_PATH_RE.test(p)) {
177
- return `${BASE_URL}/login`;
177
+ return '/login';
178
178
  }
179
179
  const s = search ?? window.location.search;
180
180
  const h = hash ?? window.location.hash;
181
- const currentPath = `${p}${s}${h}`;
182
- const encoded = encodeURIComponent(currentPath || '/');
183
- return `${BASE_URL}/login?${REDIRECT_PARAM}=${encoded}`;
181
+
182
+ const stripped =
183
+ BASE_URL && (p === BASE_URL || p.startsWith(BASE_URL + '/'))
184
+ ? p.slice(BASE_URL.length) || '/'
185
+ : p;
186
+ const currentPath = `${stripped}${s}${h}`;
187
+ if (!currentPath || currentPath === '/') {
188
+ return '/login';
189
+ }
190
+ return `/login?${REDIRECT_PARAM}=${encodeURIComponent(currentPath)}`;
184
191
  }
185
192
 
186
193
  export const resendVerificationEmail = () => `${BASE_URL}/api/user/verify/resend`;