@allbluecn/web-app 0.23.6 → 0.23.7

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.
@@ -19,6 +19,8 @@ import { createFileRoute, redirect } from '@tanstack/react-router'
19
19
  import { getAccountTeamFn } from '@/server/serverFns/settings/account-team'
20
20
  import type { AvatarConfig } from '@allbluecn/kernel'
21
21
  import { toAvatarShape } from '@/components/shared-kit/avatars/avatar-shape'
22
+ import { isDesktopServer } from '@/server/desktop-guest'
23
+ import { isDesktopClient } from '@/lib/desktop-env'
22
24
 
23
25
  export const Route = createFileRoute('/_nav/settings/account')({
24
26
  beforeLoad: async () => {
@@ -32,6 +34,9 @@ export const Route = createFileRoute('/_nav/settings/account')({
32
34
  }
33
35
  // 未登录走框架跳转(beforeLoad + redirect),不再下发错误状态由组件二次判断
34
36
  if (result.error === 'unauthorized') {
37
+ // 桌面端免登录:不自动跳 /login(本地 guest 已就绪时这里是异常态),
38
+ // 降级为页面错误态,登录与否由用户自主决定
39
+ if (isDesktopServer() || isDesktopClient()) return { error: 'error' as const }
35
40
  throw redirect({ to: '/login' })
36
41
  }
37
42
  return {
@@ -17,6 +17,8 @@
17
17
 
18
18
  import { createFileRoute, redirect } from '@tanstack/react-router'
19
19
  import { getAccountTeamFn } from '@/server/serverFns/settings/account-team'
20
+ import { isDesktopServer } from '@/server/desktop-guest'
21
+ import { isDesktopClient } from '@/lib/desktop-env'
20
22
 
21
23
  export const Route = createFileRoute('/_nav/settings/team')({
22
24
  beforeLoad: async () => {
@@ -29,6 +31,9 @@ export const Route = createFileRoute('/_nav/settings/team')({
29
31
  }
30
32
  // 未登录走框架跳转(beforeLoad + redirect),不再下发错误状态由组件二次判断
31
33
  if (result.error === 'unauthorized') {
34
+ // 桌面端免登录:不自动跳 /login(本地 guest 已就绪时这里是异常态),
35
+ // 降级为页面错误态,登录与否由用户自主决定
36
+ if (isDesktopServer() || isDesktopClient()) return { error: 'error' as const }
32
37
  throw redirect({ to: '/login' })
33
38
  }
34
39
  return {
@@ -16,13 +16,98 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { createFileRoute } from '@tanstack/react-router'
19
- import { getAuthConfig } from '@/lib/auth/auth.config'
20
19
  import { StartAuthJS } from 'start-authjs'
20
+ import { getAuthConfig } from '@/lib/auth/auth.config'
21
+ import { getCurrentSession } from '@/lib/session'
22
+ import { GUEST_USER_ID, isDesktopServer } from '@/server/desktop-guest'
23
+ import { ensureLocalShadowUser } from '@/server/desktop-bootstrap'
24
+
25
+ /**
26
+ * 桌面端「我是谁」端点回落(本地优先 P0 修复):
27
+ *
28
+ * 客户端 useSession()(components/shared-kit/auth/auth-client.ts)打的就是
29
+ * /api/auth/session。authjs 只认自己的 JWT cookie —— 桌面壳里没有(本地免登录
30
+ * + 云端身份在 remote-auth.json),于是恒返回 null,所有以 session 判登录态的
31
+ * 组件(user-menu / collab gate / 评论署名 / AI 面板等)都把本地用户当匿名。
32
+ *
33
+ * 修法(服务端单点,覆盖面最大):仅拦截 GET /api/auth/session,authjs 无会话
34
+ * 且为桌面部署时回落 getCurrentSession(鉴权单点:authjs → remote-auth 云身份
35
+ * → guest 影子用户)。其它路径(csrf/providers/signin/callback/...)与 POST
36
+ * 原样透传,登录/登出语义零改动。
37
+ *
38
+ * 安全:/api/auth/session 本就是会话自述端点,返回当前(可能 guest/云)身份
39
+ * 不扩大暴露面;Session 结构里没有 token/密钥。非桌面部署行为完全不变。
40
+ *
41
+ * guest 分支附带「懒 seed 影子用户」(幂等 upsert,失败仅 warn):端点可能在
42
+ * root beforeLoad 的 getSessionFn 之前/失败时被调用,不 seed 就会下发一个 DB
43
+ * 里不存在的身份,随后写入撞 FK(P2003)。
44
+ */
45
+ function isDesktopSessionEndpoint(request: Request): boolean {
46
+ if (!isDesktopServer()) return false
47
+ const pathname = new URL(request.url).pathname.replace(/\/+$/, '')
48
+ return pathname === '/api/auth/session'
49
+ }
50
+
51
+ /** 透传 authjs 的 Set-Cookie(坏 cookie 清理头),只替换 body */
52
+ function rebuildSessionResponse(response: Response, payload: unknown): Response {
53
+ const headers = new Headers()
54
+ // undici 实现才有 getSetCookie;类型上做可选收窄,其它运行时退化为无 cookie 透传
55
+ const setCookies = (response.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie?.() ?? []
56
+ for (const cookie of setCookies) headers.append('set-cookie', cookie)
57
+ headers.set('content-type', 'application/json')
58
+ headers.set('cache-control', 'no-store')
59
+ return new Response(JSON.stringify(payload), {
60
+ status: response.status,
61
+ statusText: response.statusText,
62
+ headers,
63
+ })
64
+ }
21
65
 
22
66
  export const Route = createFileRoute('/api/auth/$')({
23
67
  server: {
24
68
  handlers: {
25
- GET: async ({ request }) => (await StartAuthJS(await getAuthConfig())).GET({ request }),
69
+ GET: async ({ request }) => {
70
+ const response = await (await StartAuthJS(await getAuthConfig())).GET({ request })
71
+ // 非桌面 / 非 session 端点 / 非 2xx:原样透传
72
+ if (!isDesktopSessionEndpoint(request) || !response.ok) return response
73
+
74
+ // authjs session 响应是 JSON:有会话 → { user, expires };无会话 → null。
75
+ // Response body 只能读一次,故读文本后统一重建。
76
+ const text = await response.text()
77
+ let payload: unknown
78
+ try {
79
+ payload = JSON.parse(text)
80
+ } catch {
81
+ return rebuildSessionResponse(response, text)
82
+ }
83
+
84
+ const authUser = (payload as { user?: { id?: string } | null } | null)?.user
85
+ // 桌面本地凭据登录(有 authjs cookie):authjs 结果优先,原样返回
86
+ if (authUser?.id) return rebuildSessionResponse(response, payload)
87
+
88
+ const session = await getCurrentSession(request)
89
+ if (!session?.user?.id) return rebuildSessionResponse(response, payload)
90
+
91
+ // guest 回落:懒 seed 本地影子用户(与 getSessionFn 同一幂等落库)。
92
+ // 只在 getSessionFn 里 seed 不够——root beforeLoad 的 RPC 失败/慢于端点时,
93
+ // 这里会下发一个 DB 里不存在的 guest 身份,随后任何写入都撞 FK(P2003)。
94
+ // 幂等 upsert,失败仅 warn:不阻断「我是谁」响应(首启体验优先)。
95
+ if (session.user.id === GUEST_USER_ID) {
96
+ try {
97
+ await ensureLocalShadowUser()
98
+ } catch (err) {
99
+ console.warn('[api/auth/session] 本地影子用户落库失败(不阻断 guest 身份响应):', err)
100
+ }
101
+ }
102
+
103
+ return rebuildSessionResponse(response, {
104
+ user: session.user,
105
+ expires: session.expires,
106
+ // 会话来源标记:user-menu 的云态登出编排需区分「云身份」与本地
107
+ // authjs 凭据登录(二者 user.isGuest 均为 false),旧后端无此字段
108
+ desktopSession: session.user.isGuest ? 'guest' : 'cloud',
109
+ })
110
+ },
26
111
  POST: async ({ request }) => (await StartAuthJS(await getAuthConfig())).POST({ request }),
27
112
  },
28
113
  },
@@ -0,0 +1,194 @@
1
+ // /api/auth/$ 的 GET 桌面回落(useSession 依赖的「我是谁」端点)。
2
+ // 根因:authjs 只认自己的 JWT cookie,桌面壳内(本地免登录 / 云端身份在
3
+ // remote-auth.json)恒返回 null → useSession() 恒 null → 各组件把本地用户当匿名。
4
+ // 修法:仅 GET /api/auth/session 在 desktop 且 authjs 无会话时回落
5
+ // getCurrentSession(authjs → remote-auth 云身份 → guest 影子身份)。
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7
+
8
+ const H = vi.hoisted(() => ({
9
+ getSession: vi.fn(),
10
+ readRemoteAuth: vi.fn(),
11
+ authJsGet: vi.fn(),
12
+ authJsPost: vi.fn(),
13
+ ensureLocalShadowUser: vi.fn(async () => 'local-guest'),
14
+ desktopServer: true,
15
+ }))
16
+
17
+ vi.mock('start-authjs', () => ({
18
+ StartAuthJS: async () => ({ GET: H.authJsGet, POST: H.authJsPost }),
19
+ getSession: H.getSession,
20
+ }))
21
+ vi.mock('@/lib/auth/auth.config', () => ({
22
+ getAuthConfig: async () => ({}),
23
+ resetProfileCache: vi.fn(),
24
+ }))
25
+ vi.mock('@/server/remote-auth', () => ({
26
+ readRemoteAuth: H.readRemoteAuth,
27
+ }))
28
+ // seed 走真模块会拉起 @allbluecn/database 的模块级 prisma 单例(测试环境无 DB)
29
+ vi.mock('@/server/desktop-bootstrap', () => ({
30
+ ensureLocalShadowUser: H.ensureLocalShadowUser,
31
+ }))
32
+
33
+ type AuthRoute = typeof import('../$')['Route']
34
+
35
+ async function importAuthRoute(desktop: boolean): Promise<AuthRoute> {
36
+ vi.resetModules()
37
+ H.desktopServer = desktop
38
+ vi.doMock('@/server/desktop-guest', async (importOriginal) => {
39
+ const actual = await importOriginal<typeof import('@/server/desktop-guest')>()
40
+ return { ...actual, isDesktopServer: () => H.desktopServer }
41
+ })
42
+ return (await import('../$')).Route
43
+ }
44
+
45
+ /** 模拟 authjs 的 session 端点响应(无会话 → JSON null) */
46
+ function authJsResponse(body: unknown, init?: ResponseInit): Response {
47
+ return new Response(JSON.stringify(body), {
48
+ status: 200,
49
+ headers: { 'content-type': 'application/json' },
50
+ ...init,
51
+ })
52
+ }
53
+
54
+ function callGet(Route: AuthRoute, path = '/api/auth/session'): Promise<Response> {
55
+ const handlers = (Route.options as unknown as {
56
+ server: { handlers: Record<string, (ctx: { request: Request }) => Promise<Response>> }
57
+ }).server.handlers
58
+ return handlers.GET!({ request: new Request(`http://localhost${path}`, { method: 'GET' }) })
59
+ }
60
+
61
+ describe('/api/auth/session 桌面回落', () => {
62
+ beforeEach(() => {
63
+ // 真实桌面后端由 Electron 主进程注入该 env(fail-open 自检的正常路径):
64
+ // 测试里显式给出,避免每条用例都打安全告警
65
+ vi.stubEnv('DESKTOP_USER_DATA_DIR', '/tmp/allblue-desktop-test')
66
+ })
67
+
68
+ afterEach(() => {
69
+ vi.unstubAllEnvs()
70
+ vi.clearAllMocks()
71
+ })
72
+
73
+ it('desktop + 无 authjs cookie → 返回 guest 影子身份(user.id=local-guest)且懒 seed 影子用户', async () => {
74
+ H.getSession.mockResolvedValue(null)
75
+ H.readRemoteAuth.mockResolvedValue(null)
76
+ H.authJsGet.mockResolvedValue(authJsResponse(null))
77
+
78
+ const Route = await importAuthRoute(true)
79
+ const res = await callGet(Route)
80
+ const data = (await res.json()) as { user?: { id?: string; isGuest?: boolean }; desktopSession?: string }
81
+
82
+ expect(res.status).toBe(200)
83
+ expect(data.user?.id).toBe('local-guest')
84
+ expect(data.user?.isGuest).toBe(true)
85
+ expect(data.desktopSession).toBe('guest')
86
+ // I3:端点命中 guest 时补 seed(不能只靠 root beforeLoad 的 getSessionFn——
87
+ // 那一步 RPC 失败时这里会下发未落库身份,后续写入撞 FK P2003)
88
+ expect(H.ensureLocalShadowUser).toHaveBeenCalledTimes(1)
89
+ })
90
+
91
+ it('desktop guest seed 抛错:仅 warn,不阻断「我是谁」响应', async () => {
92
+ H.getSession.mockResolvedValue(null)
93
+ H.readRemoteAuth.mockResolvedValue(null)
94
+ H.authJsGet.mockResolvedValue(authJsResponse(null))
95
+ H.ensureLocalShadowUser.mockRejectedValueOnce(new Error('db gone'))
96
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
97
+
98
+ const Route = await importAuthRoute(true)
99
+ const res = await callGet(Route)
100
+ const data = (await res.json()) as { user?: { id?: string } }
101
+
102
+ expect(res.status).toBe(200)
103
+ expect(data.user?.id).toBe('local-guest')
104
+ expect(warnSpy).toHaveBeenCalled()
105
+ })
106
+
107
+ it('desktop + remoteAuth 云身份 → 返回云 userId(不是 guest),且不 seed guest 影子行', async () => {
108
+ H.getSession.mockResolvedValue(null)
109
+ H.readRemoteAuth.mockResolvedValue({
110
+ userId: 'cloud-user-abc',
111
+ planName: 'pro',
112
+ features: {},
113
+ sessionToken: 'cloud-session-token',
114
+ username: '云端用户',
115
+ })
116
+ H.authJsGet.mockResolvedValue(authJsResponse(null))
117
+
118
+ const Route = await importAuthRoute(true)
119
+ const data = (await (await callGet(Route)).json()) as {
120
+ user?: { id?: string; name?: string; isGuest?: boolean }
121
+ desktopSession?: string
122
+ }
123
+
124
+ expect(data.user?.id).toBe('cloud-user-abc')
125
+ expect(data.user?.name).toBe('云端用户')
126
+ expect(data.user?.isGuest).toBe(false)
127
+ expect(data.desktopSession).toBe('cloud')
128
+ expect(H.ensureLocalShadowUser).not.toHaveBeenCalled()
129
+ })
130
+
131
+ it('非 desktop:authjs 的 null 原样透传(不扩大暴露面)', async () => {
132
+ H.getSession.mockResolvedValue(null)
133
+ H.readRemoteAuth.mockResolvedValue(null)
134
+ H.authJsGet.mockResolvedValue(authJsResponse(null))
135
+
136
+ const Route = await importAuthRoute(false)
137
+ const data = await (await callGet(Route)).json()
138
+
139
+ expect(data).toBeNull()
140
+ })
141
+
142
+ it('desktop + 有效 authjs 会话:authjs 结果优先(不追加 desktopSession 标记)', async () => {
143
+ const authJsSession = { user: { id: 'authjs-user-1', name: '本地账号' }, expires: '2026-12-31' }
144
+ H.getSession.mockResolvedValue(authJsSession)
145
+ H.authJsGet.mockResolvedValue(authJsResponse(authJsSession))
146
+
147
+ const Route = await importAuthRoute(true)
148
+ const data = (await (await callGet(Route)).json()) as Record<string, unknown>
149
+
150
+ expect((data.user as { id?: string }).id).toBe('authjs-user-1')
151
+ expect(data.desktopSession).toBeUndefined()
152
+ })
153
+
154
+ it('desktop + 非 session 路径(csrf):原样透传,不做身份回落', async () => {
155
+ H.authJsGet.mockResolvedValue(
156
+ new Response(JSON.stringify({ csrfToken: 't' }), { status: 200, headers: { 'content-type': 'application/json' } }),
157
+ )
158
+
159
+ const Route = await importAuthRoute(true)
160
+ const data = (await (await callGet(Route, '/api/auth/csrf')).json()) as Record<string, unknown>
161
+
162
+ expect(data.csrfToken).toBe('t')
163
+ expect(H.getSession).not.toHaveBeenCalled()
164
+ })
165
+
166
+ it('POST 原样交给 authjs(登录/登出流程零改动)', async () => {
167
+ H.authJsPost.mockResolvedValue(new Response('{}', { status: 200 }))
168
+
169
+ const Route = await importAuthRoute(true)
170
+ const handlers = (Route.options as unknown as {
171
+ server: { handlers: Record<string, (ctx: { request: Request }) => Promise<Response>> }
172
+ }).server.handlers
173
+ const res = await handlers.POST!({
174
+ request: new Request('http://localhost/api/auth/callback/credentials', { method: 'POST' }),
175
+ })
176
+
177
+ expect(H.authJsPost).toHaveBeenCalledTimes(1)
178
+ expect(res.status).toBe(200)
179
+ })
180
+
181
+ it('透传 authjs 的 Set-Cookie(坏 cookie 清理头不丢)', async () => {
182
+ H.getSession.mockResolvedValue(null)
183
+ H.readRemoteAuth.mockResolvedValue(null)
184
+ H.authJsGet.mockResolvedValue(
185
+ authJsResponse(null, { headers: { 'content-type': 'application/json', 'set-cookie': 'auth.session-token=; Max-Age=0' } }),
186
+ )
187
+
188
+ const Route = await importAuthRoute(true)
189
+ const res = await callGet(Route)
190
+
191
+ expect(res.headers.getSetCookie().some((c) => c.startsWith('auth.session-token='))).toBe(true)
192
+ expect(res.headers.get('cache-control')).toBe('no-store')
193
+ })
194
+ })
@@ -38,5 +38,9 @@ export function buildGuestSession(): Session {
38
38
  }
39
39
 
40
40
  export function isDesktopServer(): boolean {
41
- return process.env.DEPLOY_TARGET === 'desktop'
41
+ // 本模块被路由模块(如 _nav/route.tsx 的 beforeLoad)在客户端一并加载:
42
+ // 浏览器 bundle 无 Node process(或 env 被静态替换为空对象)时必须安全
43
+ // 返回 false——客户端侧的桌面判定走 lib/desktop-env.ts 的 isDesktopClient()
44
+ // (desktopBridge 运行时事实),两者在 beforeLoad 里合并使用。
45
+ return typeof process !== 'undefined' && process.env?.DEPLOY_TARGET === 'desktop'
42
46
  }
@@ -22,7 +22,7 @@ import type { FeatureKey } from '@allbluecn/kernel'
22
22
  import { getPrisma } from '@allbluecn/database/get-prisma'
23
23
  import { getCurrentSession } from '@/lib/session'
24
24
  import { resetProfileCache } from '@/lib/auth/auth.config'
25
- import { buildGuestSession, isDesktopServer } from '@/server/desktop-guest'
25
+ import { buildGuestSession, isDesktopServer, isGuestSession } from '@/server/desktop-guest'
26
26
  import { ensureLocalShadowUser } from '@/server/desktop-bootstrap'
27
27
  import { ensureCloudIdentity } from '@/server/desktop-cloud-identity'
28
28
  import { readRemoteAuth, writeRemoteAuth, writeRemoteSession, clearRemoteAuth } from '@/server/remote-auth'
@@ -53,22 +53,31 @@ const saasProfileSchema = z.object({
53
53
  // getCurrentSession 桌面回落需复用同一组装,且 lib/session 不得 import
54
54
  // serverFns/**(循环依赖),组装点移至 server 域纯模块。
55
55
 
56
+ /** 桌面 guest 懒 seed 影子用户:失败仅 warn 不阻断会话发放(首启体验优先,规格 §3.1) */
57
+ async function ensureGuestSeeded(): Promise<void> {
58
+ try {
59
+ await ensureLocalShadowUser()
60
+ } catch (err) {
61
+ console.warn('[getSessionFn] 本地影子用户落库失败(不阻断 guest 会话):', err)
62
+ }
63
+ }
64
+
56
65
  export const getSessionFn = createServerFn({ method: 'GET' })
57
66
  .handler(async () => {
58
67
  const request = getRequest()
59
68
  const session = await getCurrentSession(request)
60
- if (session?.user?.id) return session
69
+ if (session?.user?.id) {
70
+ // getCurrentSession 已含桌面 guest 回落(鉴权单点):guest 首启仍需懒 seed
71
+ // 本地影子用户(本地优先 P1),使 assertOwned/requireUserInDb/FK 全链可写。
72
+ // 失败仅 warn 不阻断会话发放——首启体验优先(规格 §3.1),下一请求再重试。
73
+ if (isGuestSession(session)) await ensureGuestSeeded()
74
+ return session
75
+ }
61
76
  if (!isDesktopServer()) return null
62
77
  const remoteAuth = await readRemoteAuth()
63
78
  if (remoteAuth) return buildRemoteSession(remoteAuth)
64
- // 桌面 guest 分支懒 seed 影子用户(本地优先 P1):首启为 local-guest 落库,
65
- // 使 assertOwned/requireUserInDb/FK 全链可写。失败仅 warn 不阻断会话发放
66
- // ——首启体验优先(规格 §3.1),下一请求再重试。
67
- try {
68
- await ensureLocalShadowUser()
69
- } catch (err) {
70
- console.warn('[getSessionFn] 本地影子用户落库失败(不阻断 guest 会话):', err)
71
- }
79
+ // 兜底分支(getCurrentSession 已覆盖 guest,此处防御未来改动):保持旧行为
80
+ await ensureGuestSeeded()
72
81
  return buildGuestSession()
73
82
  })
74
83