@lobehub/lobehub 2.0.0-next.354 → 2.0.0-next.356

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.
Files changed (176) hide show
  1. package/.env.desktop +0 -1
  2. package/.env.example +16 -20
  3. package/.env.example.development +1 -4
  4. package/.github/workflows/e2e.yml +10 -11
  5. package/CHANGELOG.md +60 -0
  6. package/Dockerfile +28 -4
  7. package/changelog/v1.json +18 -0
  8. package/docker-compose/local/docker-compose.yml +2 -2
  9. package/docker-compose/local/grafana/docker-compose.yml +2 -2
  10. package/docker-compose/local/logto/docker-compose.yml +2 -2
  11. package/docker-compose/local/zitadel/.env.example +2 -2
  12. package/docker-compose/local/zitadel/.env.zh-CN.example +2 -2
  13. package/docker-compose/production/grafana/docker-compose.yml +2 -2
  14. package/docker-compose/production/logto/.env.example +2 -2
  15. package/docker-compose/production/logto/.env.zh-CN.example +2 -2
  16. package/docker-compose/production/zitadel/.env.example +2 -2
  17. package/docker-compose/production/zitadel/.env.zh-CN.example +2 -2
  18. package/docs/development/basic/add-new-authentication-providers.mdx +144 -136
  19. package/docs/development/basic/add-new-authentication-providers.zh-CN.mdx +146 -136
  20. package/docs/self-hosting/advanced/auth/legacy.mdx +4 -0
  21. package/docs/self-hosting/advanced/auth/legacy.zh-CN.mdx +4 -0
  22. package/docs/self-hosting/advanced/auth/nextauth-to-betterauth.mdx +326 -0
  23. package/docs/self-hosting/advanced/auth/nextauth-to-betterauth.zh-CN.mdx +323 -0
  24. package/docs/self-hosting/advanced/auth.mdx +43 -16
  25. package/docs/self-hosting/advanced/auth.zh-CN.mdx +44 -16
  26. package/docs/self-hosting/advanced/redis/upstash.mdx +69 -0
  27. package/docs/self-hosting/advanced/redis/upstash.zh-CN.mdx +69 -0
  28. package/docs/self-hosting/advanced/redis.mdx +128 -0
  29. package/docs/self-hosting/advanced/redis.zh-CN.mdx +126 -0
  30. package/docs/self-hosting/environment-variables/auth.mdx +15 -1
  31. package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +15 -1
  32. package/docs/self-hosting/environment-variables/basic.mdx +13 -0
  33. package/docs/self-hosting/environment-variables/basic.zh-CN.mdx +13 -0
  34. package/docs/self-hosting/environment-variables/redis.mdx +68 -0
  35. package/docs/self-hosting/environment-variables/redis.zh-CN.mdx +67 -0
  36. package/docs/self-hosting/migration/v2/breaking-changes.mdx +23 -23
  37. package/docs/self-hosting/migration/v2/breaking-changes.zh-CN.mdx +23 -23
  38. package/docs/self-hosting/server-database/docker-compose.mdx +4 -4
  39. package/docs/self-hosting/server-database/docker-compose.zh-CN.mdx +4 -4
  40. package/e2e/CLAUDE.md +5 -6
  41. package/e2e/docs/local-setup.md +9 -12
  42. package/e2e/scripts/setup.ts +9 -15
  43. package/e2e/src/support/webServer.ts +6 -5
  44. package/locales/en-US/plugin.json +3 -0
  45. package/locales/zh-CN/plugin.json +3 -0
  46. package/package.json +4 -6
  47. package/packages/builtin-tool-memory/src/client/Render/SearchUserMemory/index.tsx +3 -11
  48. package/packages/context-engine/src/engine/messages/MessagesEngine.ts +0 -13
  49. package/packages/context-engine/src/engine/messages/__tests__/MessagesEngine.test.ts +0 -25
  50. package/packages/database/src/models/__tests__/topics/topic.create.test.ts +3 -3
  51. package/packages/database/src/schemas/nextauth.ts +7 -2
  52. package/packages/utils/src/server/__tests__/auth.test.ts +1 -63
  53. package/packages/utils/src/server/auth.ts +8 -24
  54. package/scripts/_shared/checkDeprecatedAuth.js +99 -0
  55. package/scripts/clerk-to-betterauth/index.ts +8 -3
  56. package/scripts/nextauth-to-betterauth/_internal/config.ts +41 -0
  57. package/scripts/nextauth-to-betterauth/_internal/db.ts +32 -0
  58. package/scripts/nextauth-to-betterauth/_internal/env.ts +6 -0
  59. package/scripts/nextauth-to-betterauth/index.ts +226 -0
  60. package/scripts/nextauth-to-betterauth/verify.ts +188 -0
  61. package/scripts/prebuild.mts +66 -13
  62. package/scripts/serverLauncher/startServer.js +5 -5
  63. package/src/app/(backend)/api/auth/[...all]/route.ts +5 -23
  64. package/src/app/(backend)/api/webhooks/casdoor/route.ts +5 -5
  65. package/src/app/(backend)/api/webhooks/logto/route.ts +8 -8
  66. package/src/app/(backend)/middleware/auth/index.test.ts +8 -1
  67. package/src/app/(backend)/middleware/auth/index.ts +6 -15
  68. package/src/app/(backend)/middleware/auth/utils.test.ts +0 -32
  69. package/src/app/(backend)/middleware/auth/utils.ts +3 -8
  70. package/src/app/(backend)/webapi/chat/[provider]/route.test.ts +8 -1
  71. package/src/app/(backend)/webapi/create-image/comfyui/route.ts +0 -1
  72. package/src/app/(backend)/webapi/models/[provider]/route.test.ts +8 -1
  73. package/src/app/[variants]/(auth)/signin/SignInEmailStep.tsx +1 -1
  74. package/src/app/[variants]/(auth)/signup/[[...signup]]/page.tsx +4 -17
  75. package/src/app/[variants]/(main)/agent/_layout/Sidebar/Topic/List/Item/index.tsx +1 -0
  76. package/src/app/[variants]/(main)/agent/cron/[cronId]/features/CronJobContentEditor.tsx +34 -21
  77. package/src/app/[variants]/(main)/agent/features/Conversation/ConversationArea.tsx +4 -0
  78. package/src/app/[variants]/(main)/group/_layout/Sidebar/Topic/List/Item/index.tsx +1 -0
  79. package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx +1 -1
  80. package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/InboxItem.tsx +19 -29
  81. package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/List.tsx +1 -1
  82. package/src/app/[variants]/(main)/home/_layout/Body/Agent/ModalProvider.tsx +1 -1
  83. package/src/app/[variants]/(main)/settings/profile/features/SSOProvidersList/index.tsx +12 -19
  84. package/src/app/[variants]/(main)/settings/profile/index.tsx +8 -14
  85. package/src/components/{NextAuth/AuthIcons.tsx → AuthIcons.tsx} +8 -10
  86. package/src/envs/auth.ts +12 -51
  87. package/src/envs/email.ts +3 -0
  88. package/src/envs/redis.ts +12 -54
  89. package/src/features/ChatInput/ChatInputProvider.tsx +22 -2
  90. package/src/features/ChatInput/InputEditor/index.tsx +14 -3
  91. package/src/features/ChatInput/store/initialState.ts +2 -0
  92. package/src/features/User/__tests__/PanelContent.test.tsx +0 -11
  93. package/src/features/User/__tests__/UserAvatar.test.tsx +1 -16
  94. package/src/layout/AuthProvider/index.tsx +1 -6
  95. package/src/layout/GlobalProvider/StoreInitialization.tsx +2 -4
  96. package/src/libs/better-auth/define-config.ts +2 -0
  97. package/src/libs/better-auth/plugins/email-whitelist.test.ts +120 -0
  98. package/src/libs/better-auth/plugins/email-whitelist.ts +62 -0
  99. package/src/libs/next/config/define-config.ts +13 -1
  100. package/src/libs/next/proxy/define-config.ts +2 -75
  101. package/src/libs/oidc-provider/provider.test.ts +0 -4
  102. package/src/libs/redis/index.ts +0 -1
  103. package/src/libs/redis/manager.test.ts +9 -45
  104. package/src/libs/redis/manager.ts +2 -16
  105. package/src/libs/redis/redis.test.ts +2 -4
  106. package/src/libs/redis/redis.ts +2 -4
  107. package/src/libs/redis/types.ts +2 -24
  108. package/src/libs/redis/utils.test.ts +0 -10
  109. package/src/libs/redis/utils.ts +0 -19
  110. package/src/libs/trpc/lambda/context.test.ts +0 -13
  111. package/src/libs/trpc/lambda/context.ts +21 -59
  112. package/src/libs/trpc/middleware/userAuth.ts +1 -7
  113. package/src/libs/trusted-client/getSessionUser.ts +15 -35
  114. package/src/locales/default/plugin.ts +3 -0
  115. package/src/server/globalConfig/index.ts +1 -3
  116. package/src/server/modules/Mecha/ContextEngineering/__tests__/serverMessagesEngine.test.ts +0 -25
  117. package/src/server/routers/lambda/__tests__/user.test.ts +0 -48
  118. package/src/server/routers/lambda/user.ts +1 -12
  119. package/src/server/services/email/impls/nodemailer/index.ts +2 -2
  120. package/src/server/services/webhookUser/index.ts +88 -0
  121. package/src/services/chat/chat.test.ts +19 -19
  122. package/src/services/chat/index.ts +8 -3
  123. package/src/services/chat/mecha/agentConfigResolver.test.ts +72 -55
  124. package/src/services/chat/mecha/agentConfigResolver.ts +28 -4
  125. package/src/services/chat/mecha/contextEngineering.test.ts +21 -14
  126. package/src/services/chat/mecha/contextEngineering.ts +12 -0
  127. package/src/services/chat/types.ts +7 -1
  128. package/src/services/user/index.test.ts +0 -14
  129. package/src/services/user/index.ts +0 -4
  130. package/src/store/chat/agents/createAgentExecutors.ts +15 -4
  131. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +1 -0
  132. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +6 -2
  133. package/src/store/user/slices/auth/action.test.ts +22 -126
  134. package/src/store/user/slices/auth/action.ts +32 -65
  135. package/src/store/user/slices/auth/initialState.ts +0 -3
  136. package/src/store/user/slices/auth/selectors.ts +0 -3
  137. package/tests/setup.ts +10 -0
  138. package/scripts/_shared/checkDeprecatedClerkEnv.js +0 -42
  139. package/src/app/(backend)/api/auth/adapter/route.ts +0 -137
  140. package/src/app/[variants]/(auth)/next-auth/error/AuthErrorPage.tsx +0 -40
  141. package/src/app/[variants]/(auth)/next-auth/error/page.tsx +0 -11
  142. package/src/app/[variants]/(auth)/next-auth/signin/AuthSignInBox.tsx +0 -167
  143. package/src/app/[variants]/(auth)/next-auth/signin/page.tsx +0 -11
  144. package/src/app/[variants]/(auth)/reset-password/layout.tsx +0 -12
  145. package/src/app/[variants]/(auth)/signin/layout.tsx +0 -12
  146. package/src/app/[variants]/(auth)/verify-email/layout.tsx +0 -12
  147. package/src/envs/auth.test.ts +0 -47
  148. package/src/layout/AuthProvider/NextAuth/UserUpdater.tsx +0 -44
  149. package/src/layout/AuthProvider/NextAuth/index.tsx +0 -17
  150. package/src/libs/next-auth/adapter/index.ts +0 -177
  151. package/src/libs/next-auth/auth.config.ts +0 -64
  152. package/src/libs/next-auth/index.ts +0 -20
  153. package/src/libs/next-auth/sso-providers/auth0.ts +0 -24
  154. package/src/libs/next-auth/sso-providers/authelia.ts +0 -39
  155. package/src/libs/next-auth/sso-providers/authentik.ts +0 -25
  156. package/src/libs/next-auth/sso-providers/casdoor.ts +0 -50
  157. package/src/libs/next-auth/sso-providers/cloudflare-zero-trust.ts +0 -34
  158. package/src/libs/next-auth/sso-providers/cognito.ts +0 -8
  159. package/src/libs/next-auth/sso-providers/feishu.ts +0 -83
  160. package/src/libs/next-auth/sso-providers/generic-oidc.ts +0 -38
  161. package/src/libs/next-auth/sso-providers/github.ts +0 -23
  162. package/src/libs/next-auth/sso-providers/google.ts +0 -18
  163. package/src/libs/next-auth/sso-providers/index.ts +0 -35
  164. package/src/libs/next-auth/sso-providers/keycloak.ts +0 -22
  165. package/src/libs/next-auth/sso-providers/logto.ts +0 -48
  166. package/src/libs/next-auth/sso-providers/microsoft-entra-id-helper.ts +0 -29
  167. package/src/libs/next-auth/sso-providers/microsoft-entra-id.ts +0 -19
  168. package/src/libs/next-auth/sso-providers/okta.ts +0 -22
  169. package/src/libs/next-auth/sso-providers/sso.config.ts +0 -8
  170. package/src/libs/next-auth/sso-providers/wechat.ts +0 -36
  171. package/src/libs/next-auth/sso-providers/zitadel.ts +0 -21
  172. package/src/libs/redis/upstash.test.ts +0 -158
  173. package/src/libs/redis/upstash.ts +0 -136
  174. package/src/server/services/nextAuthUser/index.ts +0 -318
  175. package/src/server/services/nextAuthUser/utils.ts +0 -62
  176. package/src/types/next-auth.d.ts +0 -26
@@ -1,5 +1,8 @@
1
1
  import { useEditor } from '@lobehub/editor/react';
2
- import { type ReactNode, memo, useRef } from 'react';
2
+ import { type MutableRefObject, type ReactNode, memo, useRef } from 'react';
3
+
4
+ import { useUserStore } from '@/store/user';
5
+ import { labPreferSelectors } from '@/store/user/selectors';
3
6
 
4
7
  import StoreUpdater, { type StoreUpdaterProps } from './StoreUpdater';
5
8
  import { Provider, createStore } from './store';
@@ -8,10 +11,16 @@ interface ChatInputProviderProps extends StoreUpdaterProps {
8
11
  children: ReactNode;
9
12
  }
10
13
 
11
- export const ChatInputProvider = memo<ChatInputProviderProps>(
14
+ interface ChatInputProviderInnerProps extends StoreUpdaterProps {
15
+ children: ReactNode;
16
+ contentRef: MutableRefObject<string>;
17
+ }
18
+
19
+ const ChatInputProviderInner = memo<ChatInputProviderInnerProps>(
12
20
  ({
13
21
  agentId,
14
22
  children,
23
+ contentRef,
15
24
  leftActions,
16
25
  rightActions,
17
26
  mobile,
@@ -31,6 +40,7 @@ export const ChatInputProvider = memo<ChatInputProviderProps>(
31
40
  createStore={() =>
32
41
  createStore({
33
42
  allowExpand,
43
+ contentRef,
34
44
  editor,
35
45
  leftActions,
36
46
  mentionItems,
@@ -60,3 +70,13 @@ export const ChatInputProvider = memo<ChatInputProviderProps>(
60
70
  );
61
71
  },
62
72
  );
73
+
74
+ export const ChatInputProvider = (props: ChatInputProviderProps) => {
75
+ const enableRichRender = useUserStore(labPreferSelectors.enableInputMarkdown);
76
+ // Ref to persist content across re-mounts when enableRichRender changes
77
+ const contentRef = useRef<string>('');
78
+
79
+ return (
80
+ <ChatInputProviderInner contentRef={contentRef} key={`editor-${enableRichRender}`} {...props} />
81
+ );
82
+ };
@@ -37,7 +37,7 @@ const className = cx(css`
37
37
  `);
38
38
 
39
39
  const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
40
- const [editor, slashMenuRef, send, updateMarkdownContent, expand, mentionItems] =
40
+ const [editor, slashMenuRef, send, updateMarkdownContent, expand, mentionItems, contentRef] =
41
41
  useChatInputStore((s) => [
42
42
  s.editor,
43
43
  s.slashMenuRef,
@@ -45,6 +45,7 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
45
45
  s.updateMarkdownContent,
46
46
  s.expand,
47
47
  s.mentionItems,
48
+ s.contentRef,
48
49
  ]);
49
50
 
50
51
  const storeApi = useStoreApi();
@@ -151,7 +152,11 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
151
152
  onBlur={() => {
152
153
  disableScope(HotkeyEnum.AddUserMessage);
153
154
  }}
154
- onChange={() => {
155
+ onChange={(e) => {
156
+ // Save content to parent ref for restoration when enableRichRender changes
157
+ if (contentRef) {
158
+ contentRef.current = e.getDocument('markdown') as unknown as string;
159
+ }
155
160
  updateMarkdownContent();
156
161
  }}
157
162
  onCompositionEnd={() => {
@@ -177,7 +182,13 @@ const InputEditor = memo<{ defaultRows?: number }>(({ defaultRows = 2 }) => {
177
182
  onFocus={() => {
178
183
  enableScope(HotkeyEnum.AddUserMessage);
179
184
  }}
180
- onInit={(editor) => storeApi.setState({ editor })}
185
+ onInit={(editor) => {
186
+ storeApi.setState({ editor });
187
+ // Restore content from parent ref when editor re-initializes
188
+ if (contentRef?.current) {
189
+ editor.setDocument('markdown', contentRef.current);
190
+ }
191
+ }}
181
192
  onPressEnter={({ event: e }) => {
182
193
  if (e.shiftKey || isChineseInput.current) return;
183
194
  // when user like alt + enter to add ai message
@@ -1,6 +1,7 @@
1
1
  import { type IEditor, type SlashOptions } from '@lobehub/editor';
2
2
  import type { ChatInputProps } from '@lobehub/editor/react';
3
3
  import type { MenuProps } from '@lobehub/ui';
4
+ import type { MutableRefObject } from 'react';
4
5
 
5
6
  import { type ActionKeys } from '@/features/ChatInput';
6
7
 
@@ -39,6 +40,7 @@ export interface PublicState {
39
40
  }
40
41
 
41
42
  export interface State extends PublicState {
43
+ contentRef?: MutableRefObject<string>;
42
44
  editor?: IEditor;
43
45
  isContentEmpty: boolean;
44
46
  markdownContent: string;
@@ -67,17 +67,6 @@ vi.mock('@/const/version', () => ({
67
67
  isDesktop: false,
68
68
  }));
69
69
 
70
- // Use vi.hoisted to ensure variables exist before vi.mock factory executes
71
- const { enableNextAuth } = vi.hoisted(() => ({
72
- enableNextAuth: { value: false },
73
- }));
74
-
75
- vi.mock('@/envs/auth', () => ({
76
- get enableNextAuth() {
77
- return enableNextAuth.value;
78
- },
79
- }));
80
-
81
70
  describe('PanelContent', () => {
82
71
  const closePopover = vi.fn();
83
72
 
@@ -1,6 +1,6 @@
1
1
  import { BRANDING_NAME } from '@lobechat/business-const';
2
2
  import { act, render, screen } from '@testing-library/react';
3
- import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { describe, expect, it, vi } from 'vitest';
4
4
 
5
5
  import { DEFAULT_USER_AVATAR_URL } from '@/const/meta';
6
6
  import { useUserStore } from '@/store/user';
@@ -9,21 +9,6 @@ import UserAvatar from '../UserAvatar';
9
9
 
10
10
  vi.mock('zustand/traditional');
11
11
 
12
- // Use vi.hoisted to ensure variables exist before vi.mock factory executes
13
- const { enableNextAuth } = vi.hoisted(() => ({
14
- enableNextAuth: { value: false },
15
- }));
16
-
17
- vi.mock('@/envs/auth', () => ({
18
- get enableNextAuth() {
19
- return enableNextAuth.value;
20
- },
21
- }));
22
-
23
- afterEach(() => {
24
- enableNextAuth.value = false;
25
- });
26
-
27
12
  describe('UserAvatar', () => {
28
13
  it('should show the username and avatar are displayed when the user is logged in', async () => {
29
14
  const mockAvatar = 'https://example.com/avatar.png';
@@ -5,7 +5,6 @@ import { authEnv } from '@/envs/auth';
5
5
 
6
6
  import BetterAuth from './BetterAuth';
7
7
  import Desktop from './Desktop';
8
- import NextAuth from './NextAuth';
9
8
  import NoAuth from './NoAuth';
10
9
 
11
10
  const AuthProvider = ({ children }: PropsWithChildren) => {
@@ -13,14 +12,10 @@ const AuthProvider = ({ children }: PropsWithChildren) => {
13
12
  return <Desktop>{children}</Desktop>;
14
13
  }
15
14
 
16
- if (authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH) {
15
+ if (authEnv.AUTH_SECRET) {
17
16
  return <BetterAuth>{children}</BetterAuth>;
18
17
  }
19
18
 
20
- if (authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH) {
21
- return <NextAuth>{children}</NextAuth>;
22
- }
23
-
24
19
  return <NoAuth>{children}</NoAuth>;
25
20
  };
26
21
 
@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
6
6
  import { createStoreUpdater } from 'zustand-utils';
7
7
 
8
8
  import { isDesktop } from '@/const/version';
9
- import { enableNextAuth } from '@/envs/auth';
10
9
  import { useIsMobile } from '@/hooks/useIsMobile';
11
10
  import { useAgentStore } from '@/store/agent';
12
11
  import { useAiInfraStore } from '@/store/aiInfra';
@@ -25,9 +24,8 @@ const StoreInitialization = memo(() => {
25
24
  // prefetch error ns to avoid don't show error content correctly
26
25
  useTranslation('error');
27
26
 
28
- const [isLogin, isSignedIn, useInitUserState] = useUserStore((s) => [
27
+ const [isLogin, useInitUserState] = useUserStore((s) => [
29
28
  authSelectors.isLogin(s),
30
- s.isSignedIn,
31
29
  s.useInitUserState,
32
30
  ]);
33
31
 
@@ -65,7 +63,7 @@ const StoreInitialization = memo(() => {
65
63
  * IMPORTANT: Explicitly convert to boolean to avoid passing null/undefined downstream,
66
64
  * which would cause unnecessary API requests with invalid login state.
67
65
  */
68
- const isLoginOnInit = Boolean(enableNextAuth ? isSignedIn : isLogin);
66
+ const isLoginOnInit = Boolean(isLogin);
69
67
 
70
68
  // init inbox agent via builtin agent mechanism
71
69
  useInitBuiltinAgent(INBOX_SESSION_ID, { isLogin: isLoginOnInit });
@@ -23,6 +23,7 @@ import {
23
23
  getVerificationOTPEmailTemplate,
24
24
  } from '@/libs/better-auth/email-templates';
25
25
  import { initBetterAuthSSOProviders } from '@/libs/better-auth/sso';
26
+ import { emailWhitelist } from '@/libs/better-auth/plugins/email-whitelist';
26
27
  import { createSecondaryStorage, getTrustedOrigins } from '@/libs/better-auth/utils/config';
27
28
  import { parseSSOProviders } from '@/libs/better-auth/utils/server';
28
29
  import { EmailService } from '@/server/services/email';
@@ -222,6 +223,7 @@ export function defineConfig(customOptions: CustomBetterAuthOptions) {
222
223
  },
223
224
  plugins: [
224
225
  ...customOptions.plugins,
226
+ emailWhitelist(),
225
227
  expo(),
226
228
  emailHarmony({ allowNormalizedSignin: false, validator: customEmailValidator }),
227
229
  admin(),
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ // Get mocked module
4
+ import { authEnv } from '@/envs/auth';
5
+
6
+ import { isEmailAllowed } from './email-whitelist';
7
+
8
+ // Mock authEnv
9
+ vi.mock('@/envs/auth', () => ({
10
+ authEnv: {
11
+ AUTH_ALLOWED_EMAILS: undefined as string | undefined,
12
+ },
13
+ }));
14
+
15
+ describe('isEmailAllowed', () => {
16
+ beforeEach(() => {
17
+ // Reset to undefined before each test
18
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS = undefined;
19
+ });
20
+
21
+ describe('when whitelist is empty', () => {
22
+ it('should allow all emails when AUTH_ALLOWED_EMAILS is undefined', () => {
23
+ expect(isEmailAllowed('anyone@example.com')).toBe(true);
24
+ });
25
+
26
+ it('should allow all emails when AUTH_ALLOWED_EMAILS is empty string', () => {
27
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS = '';
28
+ expect(isEmailAllowed('anyone@example.com')).toBe(true);
29
+ });
30
+ });
31
+
32
+ describe('domain matching', () => {
33
+ beforeEach(() => {
34
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
35
+ 'example.com,company.org';
36
+ });
37
+
38
+ it('should allow email from whitelisted domain', () => {
39
+ expect(isEmailAllowed('user@example.com')).toBe(true);
40
+ expect(isEmailAllowed('admin@company.org')).toBe(true);
41
+ });
42
+
43
+ it('should reject email from non-whitelisted domain', () => {
44
+ expect(isEmailAllowed('user@other.com')).toBe(false);
45
+ });
46
+
47
+ it('should be case-sensitive for domain', () => {
48
+ expect(isEmailAllowed('user@Example.com')).toBe(false);
49
+ expect(isEmailAllowed('user@EXAMPLE.COM')).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe('exact email matching', () => {
54
+ beforeEach(() => {
55
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
56
+ 'admin@special.com,vip@other.com';
57
+ });
58
+
59
+ it('should allow exact email match', () => {
60
+ expect(isEmailAllowed('admin@special.com')).toBe(true);
61
+ expect(isEmailAllowed('vip@other.com')).toBe(true);
62
+ });
63
+
64
+ it('should reject different email at same domain', () => {
65
+ expect(isEmailAllowed('user@special.com')).toBe(false);
66
+ });
67
+
68
+ it('should be case-sensitive for email', () => {
69
+ expect(isEmailAllowed('Admin@special.com')).toBe(false);
70
+ });
71
+ });
72
+
73
+ describe('mixed domain and email matching', () => {
74
+ beforeEach(() => {
75
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
76
+ 'example.com,admin@other.com';
77
+ });
78
+
79
+ it('should allow any email from whitelisted domain', () => {
80
+ expect(isEmailAllowed('anyone@example.com')).toBe(true);
81
+ });
82
+
83
+ it('should allow specific whitelisted email', () => {
84
+ expect(isEmailAllowed('admin@other.com')).toBe(true);
85
+ });
86
+
87
+ it('should reject non-whitelisted email from non-whitelisted domain', () => {
88
+ expect(isEmailAllowed('user@other.com')).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('whitespace handling', () => {
93
+ it('should trim whitespace from whitelist entries', () => {
94
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
95
+ ' example.com , admin@other.com ';
96
+ expect(isEmailAllowed('user@example.com')).toBe(true);
97
+ expect(isEmailAllowed('admin@other.com')).toBe(true);
98
+ });
99
+
100
+ it('should filter empty entries', () => {
101
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS =
102
+ 'example.com,,other.com';
103
+ expect(isEmailAllowed('user@example.com')).toBe(true);
104
+ expect(isEmailAllowed('user@other.com')).toBe(true);
105
+ });
106
+ });
107
+
108
+ describe('edge cases', () => {
109
+ it('should reject malformed email without @', () => {
110
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS = 'example.com';
111
+ expect(isEmailAllowed('invalid-email')).toBe(false);
112
+ });
113
+
114
+ it('should handle email with multiple @ symbols', () => {
115
+ (authEnv as { AUTH_ALLOWED_EMAILS: string | undefined }).AUTH_ALLOWED_EMAILS = 'example.com';
116
+ // split('@')[1] returns 'middle@example.com', which won't match 'example.com'
117
+ expect(isEmailAllowed('user@middle@example.com')).toBe(false);
118
+ });
119
+ });
120
+ });
@@ -0,0 +1,62 @@
1
+ import { APIError } from 'better-auth/api';
2
+ import { type BetterAuthPlugin } from 'better-auth/types';
3
+
4
+ import { authEnv } from '@/envs/auth';
5
+
6
+ /**
7
+ * Parse comma-separated email whitelist string into array.
8
+ */
9
+ function parseAllowedEmails(value: string | undefined): string[] {
10
+ if (!value) return [];
11
+ return value
12
+ .split(',')
13
+ .map((s) => s.trim())
14
+ .filter(Boolean);
15
+ }
16
+
17
+ /**
18
+ * Check if email is allowed based on whitelist.
19
+ * Supports full email (user@example.com) or domain (example.com).
20
+ */
21
+ export function isEmailAllowed(email: string): boolean {
22
+ const allowedList = parseAllowedEmails(authEnv.AUTH_ALLOWED_EMAILS);
23
+ if (allowedList.length === 0) return true;
24
+
25
+ const domain = email.split('@')[1];
26
+
27
+ return allowedList.some((item) => {
28
+ // Full email match
29
+ if (item.includes('@')) return item === email;
30
+ // Domain match
31
+ return item === domain;
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Better Auth plugin to restrict registration to whitelisted emails/domains.
37
+ * Intercepts user creation (both email signup and SSO) via databaseHooks.
38
+ */
39
+ export const emailWhitelist = (): BetterAuthPlugin => ({
40
+ id: 'email-whitelist',
41
+ init() {
42
+ return {
43
+ options: {
44
+ databaseHooks: {
45
+ user: {
46
+ create: {
47
+ before: async (user) => {
48
+ if (!user.email) return { data: user };
49
+
50
+ if (!isEmailAllowed(user.email)) {
51
+ throw new APIError('FORBIDDEN', { message: 'Email not allowed for registration' });
52
+ }
53
+
54
+ return { data: user };
55
+ },
56
+ },
57
+ },
58
+ },
59
+ },
60
+ };
61
+ },
62
+ });
@@ -31,10 +31,22 @@ export function defineConfig(config: CustomNextConfig) {
31
31
  outputFileTracingIncludes: { '*': ['public/**/*', '.next/static/**/*'] },
32
32
  };
33
33
 
34
+ // Vercel serverless optimization: exclude musl binaries
35
+ // Vercel uses Amazon Linux (glibc), not Alpine Linux (musl)
36
+ // This saves ~45MB (29MB canvas-musl + 16MB sharp-musl)
37
+ const vercelConfig: NextConfig = {
38
+ outputFileTracingExcludes: {
39
+ '*': [
40
+ 'node_modules/.pnpm/@napi-rs+canvas-*-musl*',
41
+ 'node_modules/.pnpm/@img+sharp-libvips-*musl*',
42
+ ],
43
+ },
44
+ };
45
+
34
46
  const assetPrefix = process.env.NEXT_PUBLIC_ASSET_PREFIX;
35
47
 
36
48
  const nextConfig: NextConfig = {
37
- ...(isStandaloneMode ? standaloneConfig : {}),
49
+ ...(isStandaloneMode ? standaloneConfig : vercelConfig),
38
50
  assetPrefix,
39
51
 
40
52
  compiler: {
@@ -7,8 +7,7 @@ import { auth } from '@/auth';
7
7
  import { LOBE_LOCALE_COOKIE } from '@/const/locale';
8
8
  import { isDesktop } from '@/const/version';
9
9
  import { appEnv } from '@/envs/app';
10
- import { OAUTH_AUTHORIZED, authEnv } from '@/envs/auth';
11
- import NextAuth from '@/libs/next-auth';
10
+ import { authEnv } from '@/envs/auth';
12
11
  import { type Locales } from '@/locales/resources';
13
12
  import { parseBrowserLanguage } from '@/utils/locale';
14
13
  import { RouteVariants } from '@/utils/server/routeVariants';
@@ -17,12 +16,8 @@ import { createRouteMatcher } from './createRouteMatcher';
17
16
 
18
17
  // Create debug logger instances
19
18
  const logDefault = debug('middleware:default');
20
- const logNextAuth = debug('middleware:next-auth');
21
19
  const logBetterAuth = debug('middleware:better-auth');
22
20
 
23
- // OIDC session pre-sync constant
24
- const OIDC_SESSION_HEADER = 'x-oidc-session-sync';
25
-
26
21
  export function defineConfig() {
27
22
  const backendApiEndpoints = ['/api', '/trpc', '/webapi', '/oidc'];
28
23
 
@@ -169,8 +164,6 @@ export function defineConfig() {
169
164
  '/api/agent(.*)',
170
165
  '/webapi(.*)',
171
166
  '/trpc(.*)',
172
- // next auth
173
- '/next-auth/(.*)',
174
167
  // better auth
175
168
  '/signin',
176
169
  '/signup',
@@ -187,70 +180,6 @@ export function defineConfig() {
187
180
  '/share(.*)',
188
181
  ]);
189
182
 
190
- const isProtectedRoute = createRouteMatcher([
191
- '/settings(.*)',
192
- '/knowledge(.*)',
193
- '/onboard(.*)',
194
- '/oauth(.*)',
195
- // ↓ cloud ↓
196
- ]);
197
-
198
- // Initialize an Edge compatible NextAuth middleware
199
- const nextAuthMiddleware = NextAuth.auth((req) => {
200
- logNextAuth('NextAuth middleware processing request: %s %s', req.method, req.url);
201
-
202
- const response = defaultMiddleware(req);
203
-
204
- // when enable auth protection, only public route is not protected, others are all protected
205
- const isProtected = appEnv.ENABLE_AUTH_PROTECTION ? !isPublicRoute(req) : isProtectedRoute(req);
206
-
207
- logNextAuth('Route protection status: %s, %s', req.url, isProtected ? 'protected' : 'public');
208
-
209
- // Just check if session exists
210
- const session = req.auth;
211
-
212
- // Check if next-auth throws errors
213
- // refs: https://github.com/lobehub/lobe-chat/pull/1323
214
- const isLoggedIn = !!session?.expires;
215
-
216
- logNextAuth('NextAuth session status: %O', {
217
- expires: session?.expires,
218
- isLoggedIn,
219
- userId: session?.user?.id,
220
- });
221
-
222
- // Remove & amend OAuth authorized header
223
- response.headers.delete(OAUTH_AUTHORIZED);
224
- if (isLoggedIn) {
225
- logNextAuth('Setting auth header: %s = %s', OAUTH_AUTHORIZED, 'true');
226
- response.headers.set(OAUTH_AUTHORIZED, 'true');
227
-
228
- // If OIDC is enabled and user is logged in, add OIDC session pre-sync header
229
- if (authEnv.ENABLE_OIDC && session?.user?.id) {
230
- logNextAuth('OIDC session pre-sync: Setting %s = %s', OIDC_SESSION_HEADER, session.user.id);
231
- response.headers.set(OIDC_SESSION_HEADER, session.user.id);
232
- }
233
- } else {
234
- // If request a protected route, redirect to sign-in page
235
- // ref: https://authjs.dev/getting-started/session-management/protecting
236
- if (isProtected) {
237
- logNextAuth('Request a protected route, redirecting to sign-in page');
238
- const callbackUrl = `${appEnv.APP_URL}${req.nextUrl.pathname}${req.nextUrl.search}`;
239
- const nextLoginUrl = new URL('/next-auth/signin', appEnv.APP_URL);
240
- nextLoginUrl.searchParams.set('callbackUrl', callbackUrl);
241
- const hl = req.nextUrl.searchParams.get('hl');
242
- if (hl) {
243
- nextLoginUrl.searchParams.set('hl', hl);
244
- logNextAuth('Preserving locale to sign-in: hl=%s', hl);
245
- }
246
- return Response.redirect(nextLoginUrl);
247
- }
248
- logNextAuth('Request a free route but not login, allow visit without auth header');
249
- }
250
-
251
- return response;
252
- });
253
-
254
183
  const betterAuthMiddleware = async (req: NextRequest) => {
255
184
  logBetterAuth('BetterAuth middleware processing request: %s %s', req.method, req.url);
256
185
 
@@ -298,12 +227,10 @@ export function defineConfig() {
298
227
 
299
228
  logDefault('Middleware configuration: %O', {
300
229
  enableAuthProtection: appEnv.ENABLE_AUTH_PROTECTION,
301
- enableBetterAuth: authEnv.NEXT_PUBLIC_ENABLE_BETTER_AUTH,
302
- enableNextAuth: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH,
303
230
  enableOIDC: authEnv.ENABLE_OIDC,
304
231
  });
305
232
 
306
233
  return {
307
- middleware: authEnv.NEXT_PUBLIC_ENABLE_NEXT_AUTH ? nextAuthMiddleware : betterAuthMiddleware,
234
+ middleware: betterAuthMiddleware,
308
235
  };
309
236
  }
@@ -4,10 +4,6 @@
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
 
6
6
  // Mock dependencies
7
- vi.mock('@/envs/auth', () => ({
8
- enableBetterAuth: false,
9
- enableNextAuth: false,
10
- }));
11
7
 
12
8
  vi.mock('@/envs/app', () => ({
13
9
  appEnv: {
@@ -2,5 +2,4 @@ export * from './keys';
2
2
  export * from './manager';
3
3
  export * from './redis';
4
4
  export * from './types';
5
- export * from './upstash';
6
5
  export * from './utils';
@@ -1,23 +1,15 @@
1
1
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { RedisManager, initializeRedis, resetRedisClient } from './manager';
4
- import { DisabledRedisConfig } from './types';
4
+ import { RedisConfig } from './types';
5
5
 
6
- const {
7
- mockIoRedisInitialize,
8
- mockIoRedisDisconnect,
9
- mockUpstashInitialize,
10
- mockUpstashDisconnect,
11
- } = vi.hoisted(() => ({
6
+ const { mockIoRedisInitialize, mockIoRedisDisconnect } = vi.hoisted(() => ({
12
7
  mockIoRedisInitialize: vi.fn().mockResolvedValue(undefined),
13
8
  mockIoRedisDisconnect: vi.fn().mockResolvedValue(undefined),
14
- mockUpstashInitialize: vi.fn().mockResolvedValue(undefined),
15
- mockUpstashDisconnect: vi.fn().mockResolvedValue(undefined),
16
9
  }));
17
10
 
18
11
  vi.mock('./redis', () => {
19
12
  const IoRedisRedisProvider = vi.fn().mockImplementation((config) => ({
20
- provider: 'redis' as const,
21
13
  config,
22
14
  initialize: mockIoRedisInitialize,
23
15
  disconnect: mockIoRedisDisconnect,
@@ -26,20 +18,9 @@ vi.mock('./redis', () => {
26
18
  return { IoRedisRedisProvider };
27
19
  });
28
20
 
29
- vi.mock('./upstash', () => {
30
- const UpstashRedisProvider = vi.fn().mockImplementation((config) => ({
31
- provider: 'upstash' as const,
32
- config,
33
- initialize: mockUpstashInitialize,
34
- disconnect: mockUpstashDisconnect,
35
- }));
36
-
37
- return { UpstashRedisProvider };
38
- });
39
-
40
21
  afterEach(async () => {
41
- vi.clearAllMocks();
42
22
  await RedisManager.reset();
23
+ vi.clearAllMocks();
43
24
  });
44
25
 
45
26
  describe('RedisManager', () => {
@@ -47,14 +28,14 @@ describe('RedisManager', () => {
47
28
  const config = {
48
29
  enabled: false,
49
30
  prefix: 'test',
50
- provider: false,
51
- } satisfies DisabledRedisConfig;
31
+ tls: false,
32
+ url: '',
33
+ } satisfies RedisConfig;
52
34
 
53
35
  const instance = await initializeRedis(config);
54
36
 
55
37
  expect(instance).toBeNull();
56
38
  expect(mockIoRedisInitialize).not.toHaveBeenCalled();
57
- expect(mockUpstashInitialize).not.toHaveBeenCalled();
58
39
  });
59
40
 
60
41
  it('initializes ioredis provider once and memoizes the instance', async () => {
@@ -63,41 +44,24 @@ describe('RedisManager', () => {
63
44
  enabled: true,
64
45
  password: 'pwd',
65
46
  prefix: 'test',
66
- provider: 'redis' as const,
67
47
  tls: false,
68
48
  url: 'redis://localhost:6379',
69
49
  username: 'user',
70
- };
50
+ } satisfies RedisConfig;
51
+
71
52
  const [first, second] = await Promise.all([initializeRedis(config), initializeRedis(config)]);
72
53
 
73
54
  expect(first).toBe(second);
74
55
  expect(mockIoRedisInitialize).toHaveBeenCalledTimes(1);
75
- expect(mockUpstashInitialize).not.toHaveBeenCalled();
76
- });
77
-
78
- it('initializes upstash provider when configured', async () => {
79
- const config = {
80
- enabled: true,
81
- prefix: 'test',
82
- provider: 'upstash' as const,
83
- token: 'token',
84
- url: 'https://example.upstash.io',
85
- };
86
- const instance = await initializeRedis(config);
87
-
88
- expect(instance?.provider).toBe('upstash');
89
- expect(mockUpstashInitialize).toHaveBeenCalledTimes(1);
90
- expect(mockIoRedisInitialize).not.toHaveBeenCalled();
91
56
  });
92
57
 
93
58
  it('disconnects existing provider on reset', async () => {
94
59
  const config = {
95
60
  enabled: true,
96
61
  prefix: 'test',
97
- provider: 'redis' as const,
98
62
  tls: false,
99
63
  url: 'redis://localhost:6379',
100
- };
64
+ } satisfies RedisConfig;
101
65
 
102
66
  await initializeRedis(config);
103
67
  await resetRedisClient();