@allbluecn/web-app 0.23.5 → 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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/components/desktop-agent/agent-chat.tsx +3 -2
  3. package/src/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
  4. package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
  5. package/src/components/shared-kit/auth/auth-client.ts +4 -0
  6. package/src/lib/__tests__/desktop-logout.test.ts +239 -0
  7. package/src/lib/__tests__/session.test.ts +85 -2
  8. package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
  9. package/src/lib/auth/anonymous-safe.ts +5 -0
  10. package/src/lib/auth/page-gate.ts +11 -1
  11. package/src/lib/changelog/sdk-versions.ts +1 -1
  12. package/src/lib/desktop-agent/__tests__/connection.test.ts +93 -6
  13. package/src/lib/desktop-agent/connection.ts +36 -15
  14. package/src/lib/desktop-bridge-types.ts +12 -1
  15. package/src/lib/desktop-env.ts +25 -0
  16. package/src/lib/desktop-logout.ts +117 -0
  17. package/src/lib/session.ts +70 -0
  18. package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
  19. package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
  20. package/src/routes/_login/login/route.tsx +18 -3
  21. package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
  22. package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
  23. package/src/routes/_nav/route.tsx +5 -1
  24. package/src/routes/_nav/settings/account/route.tsx +5 -0
  25. package/src/routes/_nav/settings/team/route.tsx +5 -0
  26. package/src/routes/api/auth/$.ts +87 -2
  27. package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
  28. package/src/routes/api/sync/__tests__/bootstrap.test.ts +67 -0
  29. package/src/routes/api/sync/__tests__/pull.test.ts +68 -0
  30. package/src/routes/api/sync/__tests__/push.test.ts +69 -0
  31. package/src/routes/api/sync/__tests__/snapshot.test.ts +66 -0
  32. package/src/routes/api/sync/bootstrap.ts +20 -15
  33. package/src/routes/api/sync/pull.ts +62 -57
  34. package/src/routes/api/sync/push.ts +63 -58
  35. package/src/routes/api/sync/snapshot.ts +71 -66
  36. package/src/server/desktop-guest.ts +5 -1
  37. package/src/server/serverFns/auth/session.ts +19 -10
  38. package/src/server/sync/http-errors.ts +43 -0
@@ -1,5 +1,16 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { isRedirect } from '@tanstack/react-router'
3
+
4
+ // 桌面判定双通道(SSR/服务端导航读 DEPLOY_TARGET,客户端导航读 desktopBridge
5
+ // 运行时事实):两者任一为真都不应把本地用户踢去 /login(桌面免登录 P0)
6
+ const H = vi.hoisted(() => ({ desktopServer: false, desktopClient: false }))
7
+
8
+ vi.mock('@/server/desktop-guest', async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import('@/server/desktop-guest')>()
10
+ return { ...actual, isDesktopServer: () => H.desktopServer }
11
+ })
12
+ vi.mock('@/lib/desktop-env', () => ({ isDesktopClient: () => H.desktopClient }))
13
+
3
14
  import { requireSessionPage } from '../page-gate'
4
15
 
5
16
  function makeArgs(session: { user?: { id?: string } } | null) {
@@ -10,6 +21,11 @@ function makeArgs(session: { user?: { id?: string } } | null) {
10
21
  }
11
22
 
12
23
  describe('requireSessionPage', () => {
24
+ beforeEach(() => {
25
+ H.desktopServer = false
26
+ H.desktopClient = false
27
+ })
28
+
13
29
  it('已登录放行(不抛错)', () => {
14
30
  expect(() => requireSessionPage(makeArgs({ user: { id: 'u1' } }))).not.toThrow()
15
31
  })
@@ -26,4 +42,24 @@ describe('requireSessionPage', () => {
26
42
  expect(options?.to).toBe('/login')
27
43
  expect(options?.search?.redirect).toBe('/settings/account')
28
44
  })
45
+
46
+ it('桌面(服务端 SSR:DEPLOY_TARGET=desktop):未登录也放行', () => {
47
+ H.desktopServer = true
48
+ expect(() => requireSessionPage(makeArgs(null))).not.toThrow()
49
+ })
50
+
51
+ it('桌面(客户端导航:desktopBridge 在场):未登录也放行', () => {
52
+ H.desktopClient = true
53
+ expect(() => requireSessionPage(makeArgs(null))).not.toThrow()
54
+ })
55
+
56
+ it('web(两通道都为假):行为不变,未登录仍跳 /login', () => {
57
+ let caught: unknown
58
+ try {
59
+ requireSessionPage(makeArgs(null))
60
+ } catch (e) {
61
+ caught = e
62
+ }
63
+ expect(isRedirect(caught as never)).toBe(true)
64
+ })
29
65
  })
@@ -1,4 +1,5 @@
1
1
  import type { AnyRouter } from '@tanstack/react-router'
2
+ import { isDesktopClient } from '@/lib/desktop-env'
2
3
 
3
4
  const UNAUTHORIZED_NAME = 'UnauthorizedError'
4
5
  const UNAUTHORIZED_STATUS = 401
@@ -30,6 +31,10 @@ export function navigateToLoginOnUnauthorized(
30
31
  router: Pick<AnyRouter, 'state' | 'navigate'>,
31
32
  ): boolean {
32
33
  if (!isUnauthorizedError(error)) return false
34
+ // 桌面端免登录:401 不等于「需要登录」(本地 guest 已是合法身份;个别端点的
35
+ // 401 是数据/云态问题),自动跳登录会把用户从正在编辑的页面踢走。登录云端
36
+ // 由用户在 settings/sync 自主发起,本兜底在桌面壳内不动作。
37
+ if (isDesktopClient()) return false
33
38
  const loc = router.state.location
34
39
  router.navigate({
35
40
  to: '/login',
@@ -1,14 +1,24 @@
1
1
  import { redirect } from '@tanstack/react-router'
2
+ import { isDesktopServer } from '@/server/desktop-guest'
3
+ import { isDesktopClient } from '@/lib/desktop-env'
2
4
 
3
5
  interface SessionLike {
4
6
  user?: { id?: string } | null
5
7
  }
6
8
 
7
- /** 「需登录页面」beforeLoad gate:未登录跳登录并携带回跳。直接用于路由 beforeLoad。 */
9
+ /**
10
+ * 「需登录页面」beforeLoad gate:未登录跳登录并携带回跳。直接用于路由 beforeLoad。
11
+ *
12
+ * 桌面端(Electron + 本地 PGlite)免登录:guest 影子会话即合法本地身份,是否
13
+ * 登录云端由用户在 settings/sync 自主决定——一律放行,不自动踢 /login。
14
+ * 判定必须同时覆盖两种执行环境:SSR/服务端导航(isDesktopServer,读
15
+ * DEPLOY_TARGET)与客户端导航(isDesktopClient,读 desktopBridge 运行时事实)。
16
+ */
8
17
  export function requireSessionPage(args: {
9
18
  context: { session: SessionLike | null }
10
19
  location: { pathname: string; searchStr: string }
11
20
  }): void {
21
+ if (isDesktopServer() || isDesktopClient()) return
12
22
  if (!args.context.session?.user?.id) {
13
23
  throw redirect({
14
24
  to: '/login',
@@ -112,7 +112,7 @@ export const SDK_GROUPS: SdkGroup[] = [
112
112
  "items": [
113
113
  {
114
114
  "name": "@allbluecn/web-app",
115
- "version": "v0.23.5",
115
+ "version": "v0.23.7",
116
116
  "lastUpdate": "Nov 1, 2026",
117
117
  "source": "apps/web/package.json",
118
118
  "docsUrl": "https://github.com/allbluecn/allblue"
@@ -2,10 +2,12 @@
2
2
  // AllBlue - 理想之海
3
3
  // Copyright (C) 2026 AllBlue Contributors
4
4
 
5
- // desktop-agent 连接(桌面本地优先 P3-B5):appInfo 调用时懒取 agentPort/agentToken
6
- // → 真实 fetchServerSentEvents 直连 http://127.0.0.1:{port}/prompt。fetch 打桩、
7
- // SSE 解析走真实 @tanstack/ai-client 实现——请求线格式即与 Pro 端点的跨仓契约:
8
- // URL / X-Desktop-Token / X-Run-Id 头 / forwardedProps.cwd(端点 extractCwd 取值位)。
5
+ // desktop-agent 连接(桌面本地优先 P3-B5):调用时懒取 agent 端点 → 真实
6
+ // fetchServerSentEvents 直连 http://127.0.0.1:{port}/prompt。fetch 打桩、SSE 解析
7
+ // 走真实 @tanstack/ai-client 实现——请求线格式即与 Pro 端点的跨仓契约:URL /
8
+ // X-Desktop-Token / X-Run-Id 头 / forwardedProps.cwd(端点 extractCwd 取值位)。
9
+ // 两种壳形态各覆盖:新壳经 agentCredentials 按需取凭据(appInfo.agentToken 恒 null,
10
+ // 不参与连接),旧壳回落 appInfo 的 agentPort/agentToken。
9
11
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
12
  import type { UIMessage } from '@tanstack/ai-client'
11
13
 
@@ -18,7 +20,15 @@ type AppInfo = {
18
20
  agentToken?: string | null
19
21
  }
20
22
 
21
- const bridgeRef: { current: { appInfo: () => Promise<AppInfo> } | null } = { current: null }
23
+ type AgentCredentials = { port: number; token: string }
24
+
25
+ type Bridge = {
26
+ appInfo: () => Promise<AppInfo>
27
+ /** 新壳按需取凭据通道;旧壳无此方法(undefined) */
28
+ agentCredentials?: () => Promise<AgentCredentials | null>
29
+ }
30
+
31
+ const bridgeRef: { current: Bridge | null } = { current: null }
22
32
 
23
33
  vi.mock('@/lib/desktop-bridge-types', () => ({
24
34
  getDesktopBridge: () => bridgeRef.current,
@@ -36,6 +46,14 @@ function bridgeWith(info: Partial<AppInfo>) {
36
46
  return { appInfo: vi.fn(async () => ({ ...BASE_INFO, ...info })) }
37
47
  }
38
48
 
49
+ /** 新壳桥:appInfo 里 agentPort 仍广播(门控用)、agentToken 恒 null;凭据走按需 IPC */
50
+ function newShellBridge(credentials: AgentCredentials | null, info: Partial<AppInfo> = {}) {
51
+ return {
52
+ appInfo: vi.fn(async () => ({ ...BASE_INFO, agentPort: null, agentToken: null, ...info })),
53
+ agentCredentials: vi.fn(async () => credentials),
54
+ }
55
+ }
56
+
39
57
  const encoder = new TextEncoder()
40
58
  function sse(event: Record<string, unknown>): Uint8Array {
41
59
  return encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
@@ -119,6 +137,50 @@ describe('resolveDesktopAgentEndpoint(appInfo 懒取)', () => {
119
137
  })
120
138
  })
121
139
 
140
+ describe('resolveDesktopAgentEndpoint(新壳 agentCredentials 优先 / 旧壳回落 appInfo)', () => {
141
+ it('新壳:用 agentCredentials 的 {port, token} 构造端点,不读 appInfo 的令牌', async () => {
142
+ // appInfo 故意给一个不同的端口 + 陈旧令牌:端点必须只来自 agentCredentials
143
+ const bridge = newShellBridge({ port: 2468, token: 'tok-cred' }, { agentPort: 4567, agentToken: 'ok' })
144
+ bridgeRef.current = bridge
145
+ await expect(resolveDesktopAgentEndpoint()).resolves.toEqual({
146
+ url: 'http://127.0.0.1:2468/prompt',
147
+ token: 'tok-cred',
148
+ })
149
+ expect(bridge.agentCredentials).toHaveBeenCalledTimes(1)
150
+ expect(bridge.appInfo).not.toHaveBeenCalled()
151
+ })
152
+
153
+ it('新壳:agentCredentials 返回 null(未装配)或抛错(IPC 断开)→ null,不回落 appInfo', async () => {
154
+ const bridge = newShellBridge(null)
155
+ bridgeRef.current = bridge
156
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
157
+ expect(bridge.appInfo).not.toHaveBeenCalled()
158
+
159
+ bridgeRef.current = {
160
+ appInfo: vi.fn(async () => ({ ...BASE_INFO, agentPort: 4567, agentToken: null })),
161
+ agentCredentials: vi.fn(async () => {
162
+ throw new Error('ipc closed')
163
+ }),
164
+ }
165
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
166
+ })
167
+
168
+ it('新壳:agentCredentials 产出的端口/令牌非法 → null(与 appInfo 路径同口径)', async () => {
169
+ bridgeRef.current = newShellBridge({ port: 0, token: 'tok' })
170
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
171
+ bridgeRef.current = newShellBridge({ port: 4567, token: '' })
172
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
173
+ })
174
+
175
+ it('旧壳(无 agentCredentials)→ 回落 appInfo 的 agentPort + agentToken', async () => {
176
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
177
+ await expect(resolveDesktopAgentEndpoint()).resolves.toEqual({
178
+ url: 'http://127.0.0.1:4567/prompt',
179
+ token: 'tok-abc',
180
+ })
181
+ })
182
+ })
183
+
122
184
  describe('createDesktopAgentConnection(fetchServerSentEvents 包装)', () => {
123
185
  it('端点不可用 → 抛 DesktopAgentUnavailableError,且不发起 fetch', async () => {
124
186
  const fetchMock = stubSseFetch(RUN_EVENTS)
@@ -173,6 +235,31 @@ describe('createDesktopAgentConnection(fetchServerSentEvents 包装)', () =>
173
235
  expect(body.messages).toEqual([expect.objectContaining({ role: 'user', content: '列出目录' })])
174
236
  })
175
237
 
238
+ it('新壳:URL / X-Desktop-Token 取自 agentCredentials(appInfo 的 agentToken 恒 null 不参与)', async () => {
239
+ const fetchMock = stubSseFetch(RUN_EVENTS)
240
+ const bridge = newShellBridge({ port: 2468, token: 'tok-cred' }, { agentPort: 4567, agentToken: null })
241
+ bridgeRef.current = bridge
242
+ await drain(
243
+ createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, { threadId: 't-1', runId: 'r-1' }),
244
+ )
245
+ expect(fetchMock).toHaveBeenCalledTimes(1)
246
+ const [url, init] = fetchMock.mock.calls[0]!
247
+ expect(url).toBe('http://127.0.0.1:2468/prompt')
248
+ expect((init?.headers as Record<string, string>)['X-Desktop-Token']).toBe('tok-cred')
249
+ // 每次 connect 都重新按需取一次凭据(backend 重启换端口/令牌后下一问即用新值)
250
+ expect(bridge.agentCredentials).toHaveBeenCalledTimes(1)
251
+ expect(bridge.appInfo).not.toHaveBeenCalled()
252
+ })
253
+
254
+ it('新壳未装配(agentCredentials → null)→ 抛 DesktopAgentUnavailableError,不发 fetch', async () => {
255
+ const fetchMock = stubSseFetch(RUN_EVENTS)
256
+ bridgeRef.current = newShellBridge(null)
257
+ await expect(
258
+ drain(createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, { threadId: 't', runId: 'r' })),
259
+ ).rejects.toBeInstanceOf(DesktopAgentUnavailableError)
260
+ expect(fetchMock).not.toHaveBeenCalled()
261
+ })
262
+
176
263
  it('SSE 帧原样产出为 AG-UI chunk(RUN_STARTED 起、RUN_FINISHED 止)', async () => {
177
264
  stubSseFetch(RUN_EVENTS)
178
265
  bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
@@ -191,7 +278,7 @@ describe('createDesktopAgentConnection(fetchServerSentEvents 包装)', () =>
191
278
  ])
192
279
  })
193
280
 
194
- it('每次 connect 都重取 appInfo(backend 重启换端口/令牌后下一问即用新值)', async () => {
281
+ it('旧壳:每次 connect 都重取 appInfo(backend 重启换端口/令牌后下一问即用新值)', async () => {
195
282
  const fetchMock = stubSseFetch(RUN_EVENTS)
196
283
  const appInfo = vi
197
284
  .fn<() => Promise<AppInfo>>()
@@ -5,13 +5,18 @@
5
5
  // 本地文件助手的 agent 连接(桌面本地优先 P3-B5):useChat 的 connection 适配。
6
6
  //
7
7
  // 端点 = Pro desktop 域 AgentService(B4):backend utilityProcess 内 127.0.0.1
8
- // 随机端口 `POST /prompt`(AG-UI SSE),须带 `X-Desktop-Token`。端口与令牌经
9
- // desktopBridge.appInfo() 的 agentPort/agentToken 广播——backend 重启即换新值
10
- // (main 侧 backend exit 清空),所以**每次请求时懒取**,不在组件顶层静态拼 URL。
8
+ // 随机端口 `POST /prompt`(AG-UI SSE),须带 `X-Desktop-Token`。端口与令牌经桌面桥
9
+ // 取得,两种壳形态:
10
+ // - 新壳:`desktopBridge.agentCredentials()` 按需 IPC 返回 { port, token }(令牌不再
11
+ // 随 appInfo 常驻广播——常驻信息面会让任意 renderer JS(含 XSS)随时拿到令牌去读
12
+ // 已授权目录;appInfo.agentToken 在新壳恒为 null 占位)。
13
+ // - 旧壳(无 agentCredentials):回落 `desktopBridge.appInfo()` 的 agentPort/agentToken。
14
+ // backend 重启即换新值(main 侧 backend exit 清空),所以**每次请求时懒取**,不在组件
15
+ // 顶层静态拼 URL。
11
16
  //
12
17
  // 为什么包一层而不是直接 fetchServerSentEvents(url, options):其 url 参数只接受
13
18
  // 同步 getter,且在 connect() 内先于(可异步的)options 求值——URL 无法来自同一次
14
- // 调用里 await 的 appInfo。包装只做「await appInfo → 构造 fetchServerSentEvents →
19
+ // 调用里 await 的桥调用。包装只做「await 桥 → 构造 fetchServerSentEvents →
15
20
  // 原样委托 connect」,SSE 解析/AG-UI 协议/abort 全部是官方实现,无自定义协议面。
16
21
  //
17
22
  // 请求线格式(与 Pro 端点的跨仓契约,见 agent-service.ts extractCwd/extractPrompt):
@@ -28,8 +33,8 @@ export interface DesktopAgentEndpoint {
28
33
  token: string
29
34
  }
30
35
 
31
- /** agent 端点不可用:无桥(云 Web / OSS)、旧壳(appInfo 无 agent 字段)、或
32
- * agent 未装配(DESKTOP_AGENT 门控未开 / bundle 装配失败 → agentPort null) */
36
+ /** agent 端点不可用:无桥(云 Web / OSS)、旧壳(appInfo 无 agent 字段)、新壳未装配
37
+ * (agentCredentials 返回 null)、或 DESKTOP_AGENT 门控未开 / bundle 装配失败 */
33
38
  export class DesktopAgentUnavailableError extends Error {
34
39
  constructor() {
35
40
  super('desktop agent endpoint unavailable')
@@ -37,20 +42,36 @@ export class DesktopAgentUnavailableError extends Error {
37
42
  }
38
43
  }
39
44
 
40
- /** 调用时读取当前 agent 端点;不可用返回 null(端口/令牌非法同样视为不可用,不拼坏 URL) */
45
+ /** 端口合法性(1..65535 整数):非法即「不可用」,不拼出坏 URL */
46
+ function isValidAgentPort(port: unknown): port is number {
47
+ return typeof port === 'number' && Number.isInteger(port) && port >= 1 && port <= 65535
48
+ }
49
+
50
+ /** 由端口 + 令牌构造端点;任一非法返回 null(不吞半截凭据)。令牌空串按不可用处理
51
+ * ——新壳 appInfo.agentToken 恒 null,回落路径若拿到空串说明壳未装配 */
52
+ function buildEndpoint(port: unknown, token: unknown): DesktopAgentEndpoint | null {
53
+ if (!isValidAgentPort(port)) return null
54
+ if (typeof token !== 'string' || token === '') return null
55
+ return { url: `http://127.0.0.1:${port}/prompt`, token }
56
+ }
57
+
58
+ /** 调用时读取当前 agent 端点;不可用返回 null(端口/令牌非法同样视为不可用,不拼坏 URL)。
59
+ * 取端点优先 agentCredentials(新壳按需取凭证),旧壳回落 appInfo 的 agentPort/agentToken。 */
41
60
  export async function resolveDesktopAgentEndpoint(): Promise<DesktopAgentEndpoint | null> {
42
61
  const bridge = getDesktopBridge()
43
62
  if (!bridge) return null
44
- // main 未就绪 / IPC 断开时 appInfo 会抛错:归「服务未就绪」(不可用 → 上层抛
45
- // DesktopAgentUnavailableError),不把原生错误原文当「运行失败:<原文>」透出
63
+ // 新壳独占使用 agentCredentials:其存在即表示令牌不再经 appInfo 广播,回落拿到
64
+ // 的只会是恒 null 占位,无收益。IPC 断开/main 未就绪时抛错与 appInfo 同口径归
65
+ // 「服务未就绪」(不可用),不把原生错误原文透出成「运行失败:<原文>」
66
+ if (typeof bridge.agentCredentials === 'function') {
67
+ const credentials = await bridge.agentCredentials().catch(() => null)
68
+ if (!credentials) return null
69
+ return buildEndpoint(credentials.port, credentials.token)
70
+ }
71
+ // 旧壳:端口/令牌同随 appInfo 广播
46
72
  const info = await bridge.appInfo().catch(() => null)
47
73
  if (!info) return null
48
- const { agentPort, agentToken } = info
49
- if (typeof agentPort !== 'number' || !Number.isInteger(agentPort) || agentPort < 1 || agentPort > 65535) {
50
- return null
51
- }
52
- if (typeof agentToken !== 'string' || agentToken === '') return null
53
- return { url: `http://127.0.0.1:${agentPort}/prompt`, token: agentToken }
74
+ return buildEndpoint(info.agentPort, info.agentToken)
54
75
  }
55
76
 
56
77
  /**
@@ -9,15 +9,26 @@ export interface DesktopBridgeApi {
9
9
  * 字段):backend utilityProcess 装配 AgentService 后随 appInfo 广播——
10
10
  * agentPort = 127.0.0.1 随机端口,agentToken = 启动时生成的访问令牌
11
11
  * (请求须带 X-Desktop-Token 头)。null = agent 未装配(旧壳,或 DESKTOP_AGENT
12
- * 门控未开/bundle 装配失败降级),renderer 据此隐藏 agent 入口。 */
12
+ * 门控未开/bundle 装配失败降级),renderer 据此隐藏 agent 入口。
13
+ * ⚠ agentToken 仅旧壳有值:新壳把它移出 appInfo 常驻广播(任意 renderer JS
14
+ * 含 XSS 都能随时取到令牌去读已授权目录),改由 agentCredentials 按需取一次,
15
+ * 保留下来的该字段**恒为 null(纯兼容占位,勿再消费)**。agentPort 仍随
16
+ * appInfo 广播(仅端口、不可用于取文件,供入口门控)。 */
13
17
  appInfo: () => Promise<{
14
18
  version: string
15
19
  clientId: string
16
20
  platform: string
17
21
  backendPort: number | null
18
22
  agentPort?: number | null
23
+ /** @deprecated 新壳恒 null(令牌不再广播);请改用 agentCredentials() */
19
24
  agentToken?: string | null
20
25
  }>
26
+ /** 本地文件助手 agent 凭据按需获取(Pro 侧新增 IPC `desktopBridge:agentCredentials`):
27
+ * 仅在建立 agent 连接时调用一次,返回 { port, token }(port = 127.0.0.1 随机端口,
28
+ * token = 请求头 X-Desktop-Token 的值);agent 未装配 → null。与 appInfo 不同,
29
+ * 本方法**不是**常驻可查询的信息面,令牌不进入任何长期可读的全局面。
30
+ * 旧壳无此方法 → undefined,调用方回落到 appInfo 的 agentPort/agentToken。 */
31
+ agentCredentials?: () => Promise<{ port: number; token: string } | null>
21
32
  cloudLogin: (email: string, password: string) => Promise<{ ok: boolean; userId?: string; error?: string }>
22
33
  /** A10 轻量云身份激活:登录成功后取 cloud-session.json 解出的纯 session
23
34
  * token(renderer 再走 exchangeRemoteTokenFn 换权益并写 remote-auth)。 */
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // 桌面端运行时判定(客户端安全):Electron preload 注入的 window.desktopBridge
6
+ // 是「当前页面确实跑在桌面壳里」的运行时事实。
7
+ //
8
+ // 为什么不能只用 isDesktopServer():它读 process.env.DEPLOY_TARGET,只在
9
+ // sidecar(Node 服务端)进程里成立。TanStack Router 的 beforeLoad 在**客户端
10
+ // 导航时也会重跑**,浏览器上下文没有 DEPLOY_TARGET → false → requireSessionPage
11
+ // 把桌面 guest 误判成「未登录」踢去 /login(桌面本地优先实测缺陷)。
12
+ //
13
+ // 为什么不用 isDesktopEdition()(VITE_DEPLOY_TARGET 构建期常量):那是「构建
14
+ // 目标」判定;Electron 壳以运行时 env 启动 sidecar 时 bundle 未必内联 desktop,
15
+ // 会 false negative。桥对象的判定口径与 session-guard / sync-indicator /
16
+ // cloud-login 一致(它们都以 getDesktopBridge() 为准)。
17
+ //
18
+ // SSR 恒 false(无 window):服务端侧由 isDesktopServer() 覆盖,两者在
19
+ // beforeLoad 里「服务端 SSR 放行 + 客户端导航放行」形成闭环,无 hydration 分叉。
20
+ import { getDesktopBridge } from './desktop-bridge-types'
21
+
22
+ /** 是否运行在桌面壳渲染进程(客户端);SSR / 浏览器恒 false */
23
+ export function isDesktopClient(): boolean {
24
+ return getDesktopBridge() !== null
25
+ }
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ // 桌面云态登出编排(桌面本地优先 P2-A9 反向转移,从 sidebar-03/user-menu.tsx
19
+ // 抽出为可测纯编排:零 React / 零 i18n / 零 DOM 依赖,副作用全由调用方注入)。
20
+ //
21
+ // 编排顺序(裁定口径,改动需同步单测):
22
+ // ① 置转移锁(复用 bind 锁文件——引擎每 tick 判定同源,「转移进行中引擎勿 tick」;
23
+ // 旧壳无此 IPC ⇒ lock 省略,视为置锁成功,锁为双保险);
24
+ // ② transfer(serverFn reverseTransferOwnershipFn:校验 remoteAuth/会话本人 →
25
+ // 单事务 cloud→guest + outbox/row_state 同事务清空 + 重建 guest 行;事务提交后
26
+ // server 侧已清 remote-auth——崩窗口收敛);
27
+ // ③ 双会话清理:logoutRemote(幂等 re-clear remote-auth)+ clearCloudSession
28
+ // (清 cloud-session.json,引擎 guest 守卫回 idle)→ 解锁(先清云会话再解锁,
29
+ // 引擎恢复 tick 前云会话已不可用);
30
+ // ④ clearQueries + 整页 reload(reload 后 getSessionFn 回落 guest 会话)。
31
+ //
32
+ // 失败路径(保持既有语义):
33
+ // - 置锁失败(抛错或 ok=false):notify('failed') + 返回 lock-failed,不转移、不
34
+ // reload(留在云态,数据仍是云属主,可重试);
35
+ // - 转移失败:尽力解锁 + notify('failed') + 返回 transfer-failed,不清会话、不
36
+ // reload(单事务已回滚,重试幂等);
37
+ // - 会话清理/解锁失败:全部 best-effort 吞掉,**不阻塞** ④(云会话已由 serverFn
38
+ // 侧清理,解锁抛错也不阻止 reload——reload 后引擎按 guest 守卫重新判定)。
39
+
40
+ /** 用户可见提示事件(文案由调用方决定,本模块不持有 i18n) */
41
+ export type DesktopLogoutNotice = 'in-progress' | 'failed'
42
+
43
+ /** 编排终态:lock-failed / transfer-failed 表示留在云态(未 reload) */
44
+ export type DesktopLogoutStatus = 'done' | 'lock-failed' | 'transfer-failed'
45
+
46
+ export interface DesktopLogoutDeps {
47
+ /** 置转移锁(旧壳无 IPC ⇒ 省略 = 视为成功) */
48
+ lock?: () => Promise<{ ok: boolean; error?: string }>
49
+ /** 反向转移(失败必须抛出,单事务回滚语义由 serverFn 保证) */
50
+ transfer: (cloudUserId: string) => Promise<unknown>
51
+ /** 清 remote-auth(幂等 best-effort,失败不得阻塞后续) */
52
+ logoutRemote: () => Promise<unknown>
53
+ /** 清 cloud-session.json(旧壳无 IPC ⇒ 省略 = no-op) */
54
+ clearCloudSession?: () => Promise<unknown>
55
+ /** 解转移锁(失败不得阻塞后续) */
56
+ unlock?: () => Promise<unknown>
57
+ /** 清 react-query 缓存 */
58
+ clearQueries: () => void
59
+ /** 整页 reload */
60
+ reload: () => void
61
+ /** 提示回调(in-progress 在置锁前,failed 在失败路径) */
62
+ notify?: (notice: DesktopLogoutNotice) => void
63
+ }
64
+
65
+ /**
66
+ * 执行桌面云态登出编排。**调用方负责在桌面壳存在且无本地 authjs 会话时调用**
67
+ * (云态判别式),本地凭据登出仍走 signOut 链。
68
+ */
69
+ export async function runDesktopLogout(
70
+ cloudUserId: string,
71
+ deps: DesktopLogoutDeps,
72
+ ): Promise<DesktopLogoutStatus> {
73
+ deps.notify?.('in-progress')
74
+
75
+ // ① 转移锁
76
+ let lockOk = true
77
+ if (deps.lock) {
78
+ try {
79
+ lockOk = (await deps.lock()).ok
80
+ } catch {
81
+ lockOk = false
82
+ }
83
+ }
84
+ if (!lockOk) {
85
+ deps.notify?.('failed')
86
+ return 'lock-failed'
87
+ }
88
+
89
+ // ② 反向转移(失败:单事务回滚留在云态,尽力解锁后可重试)
90
+ try {
91
+ await deps.transfer(cloudUserId)
92
+ } catch {
93
+ await bestEffort(deps.unlock)
94
+ deps.notify?.('failed')
95
+ return 'transfer-failed'
96
+ }
97
+
98
+ // ③ 双会话清理 + 解锁(全部尽力而为:serverFn 已清 remote-auth,此处兜底)
99
+ await bestEffort(deps.logoutRemote)
100
+ await bestEffort(deps.clearCloudSession)
101
+ await bestEffort(deps.unlock)
102
+
103
+ // ④ 缓存清空 + 整页刷新(reload 后 desktop-state 回 guest)
104
+ deps.clearQueries()
105
+ deps.reload()
106
+ return 'done'
107
+ }
108
+
109
+ /** best-effort:缺省跳过、抛错吞掉(失败路径不得阻塞主流程) */
110
+ async function bestEffort(action?: () => Promise<unknown>): Promise<void> {
111
+ if (!action) return
112
+ try {
113
+ await action()
114
+ } catch {
115
+ /* 尽力而为 */
116
+ }
117
+ }
@@ -22,6 +22,11 @@ import { getAuthConfig } from './auth/auth.config'
22
22
  // desktop-session-merge 为纯数据组装 + remote-auth 纯 fs 读取(node:fs +
23
23
  // env 守卫),无 prisma/server 重依赖链,客户端/云端部署零副作用。
24
24
  import { getDesktopRemoteSession } from '@/server/desktop-session-merge'
25
+ // 桌面免登录(本地优先 P0 修复):guest 影子身份是本地合法身份,必须在**鉴权
26
+ // 单点**生效——requireAuth / server seam / 各 serverFn 的软鉴权(getAccountTeamFn、
27
+ // design/detail 等)全部复用本函数,只在这里补一次即可让全站口径一致;否则
28
+ // 这些端点把 guest 判成未登录,路由 beforeLoad 收到 unauthorized 后踢 /login。
29
+ import { buildGuestSession, isDesktopServer } from '@/server/desktop-guest'
25
30
 
26
31
  export type SessionUser = NonNullable<AuthSession['user']> & {
27
32
  id?: string
@@ -43,6 +48,51 @@ export type SessionUser = NonNullable<AuthSession['user']> & {
43
48
 
44
49
  export type Session = Omit<AuthSession, 'user'> & { user?: SessionUser | null }
45
50
 
51
+ /** desktop guest 降级的 warn-once 标记(进程级;沿用 requireAuth 的排查线索语义:
52
+ * 桌面单机 guest 合法,但服务器部署误设 DEPLOY_TARGET=desktop 时匿名请求会变成
53
+ * 共享 guest 身份(fail-open)——至少留一条日志) */
54
+ let warnedDesktopGuest = false
55
+
56
+ /** fail-open 安全自检的 warn/error-once 标记(进程级) */
57
+ let warnedDesktopSecurity = false
58
+
59
+ /**
60
+ * 桌面回落入口的安全自检(进程级一次)。
61
+ *
62
+ * `DEPLOY_TARGET=desktop` 在本仓是**安全边界**而不只是功能开关:它让匿名请求
63
+ * 变成共享的本地 guest 身份(AI / SSE / anyhub 文件代理等全部 getCurrentSession
64
+ * 消费点一起生效)。真正的桌面后端由 Electron 主进程注入 `DESKTOP_USER_DATA_DIR`
65
+ * (PGlite 数据目录 + remote-auth.json 的根)——该 env 缺失而 DEPLOY_TARGET 仍是
66
+ * desktop,最可能的解释是「云部署误设了 DEPLOY_TARGET」。
67
+ *
68
+ * 当前口径:**只 console.error 告警,不硬失败**(开发/测试也放行)。生产环境是否
69
+ * 直接拒绝启动/拒绝 guest 回落由控制者另行裁决;在此之前保留可用的同时留下显眼
70
+ * 线索。硬失败点应加在本函数之后(返回 guest 之前)。
71
+ */
72
+ function checkDesktopFallbackEnvOnce(): void {
73
+ if (warnedDesktopSecurity) return
74
+ warnedDesktopSecurity = true
75
+ if (process.env.DESKTOP_USER_DATA_DIR) return
76
+ console.error(
77
+ '[session] 安全告警:DEPLOY_TARGET=desktop 但未设置 DESKTOP_USER_DATA_DIR。\n' +
78
+ ' DEPLOY_TARGET=desktop 是安全边界:匿名请求会被当作本地 guest 身份(AI / SSE / 文件代理等全部放行)。\n' +
79
+ ' 若非桌面单机部署,请立即移除 DEPLOY_TARGET=desktop 以恢复鉴权;\n' +
80
+ ' 桌面壳会由 Electron 主进程注入 DESKTOP_USER_DATA_DIR(PGlite 目录 / remote-auth.json 根)。',
81
+ )
82
+ }
83
+
84
+ function guestSession() {
85
+ checkDesktopFallbackEnvOnce()
86
+ if (!warnedDesktopGuest) {
87
+ warnedDesktopGuest = true
88
+ console.warn(
89
+ '[session] DEPLOY_TARGET=desktop:无会话请求已回落到本地 guest 身份。' +
90
+ '若这是服务器/云端部署,请移除 DEPLOY_TARGET=desktop 以恢复鉴权。',
91
+ )
92
+ }
93
+ return buildGuestSession()
94
+ }
95
+
46
96
  export async function getCurrentSession(request?: Request): Promise<Session | null> {
47
97
  if (!request) return null
48
98
  try {
@@ -56,12 +106,32 @@ export async function getCurrentSession(request?: Request): Promise<Session | nu
56
106
  // 604 处自动受益;非 desktop 恒 null(desktop 判定先于 fs 读取,零 IO)。
57
107
  const remoteSession = await getDesktopRemoteSession()
58
108
  if (remoteSession) return remoteSession
109
+ // 桌面免登录(本地优先 P0):既无 cloud 身份也未登录时,guest 影子用户即
110
+ // 本机当前用户(与 getSessionFn / requireAuth 的 guest 分支同一身份)。
111
+ // 放在本单点而非各端点:否则 account-team / design-detail 等「软鉴权」端点
112
+ // 把 guest 判成未登录 → beforeLoad unauthorized → 自动跳 /login(实测 P0)。
113
+ if (isDesktopServer()) return guestSession()
59
114
  return session
60
115
  } catch (error) {
61
116
  // JWT 解密失败(AUTH_SECRET 轮换 / 跨应用同名 cookie 污染)等效于无 session:
62
117
  // 返回 null 走未登录分支;@auth/core 会自行清理坏 cookie。
63
118
  // 不兜底时 JWTSessionError 会炸穿所有未捕获的调用点(route beforeLoad / serverFn)。
64
119
  console.warn('[session] 读取 session 失败(坏 cookie 或 auth 配置异常),降级为未登录:', error instanceof Error ? error.message : error)
120
+ // 桌面端不存在「未登录」态:坏 cookie / 鉴权配置异常不得让本地用户掉线
121
+ // (否则首屏 session 为 null → 页面守卫与控制台全是登录引导)。
122
+ //
123
+ // 但「authjs 抛错」≠「本机无身份」:桌面 cloud 态的真身份在 remote-auth.json,
124
+ // 必须先试云身份再回落 guest——直接回 guest 会让云账号用户看起来「数据全空」,
125
+ // 期间写入还会落到 local-guest 属主(数据错主,后续反向转移也救不回来)。
126
+ if (isDesktopServer()) {
127
+ try {
128
+ const remoteSession = await getDesktopRemoteSession()
129
+ if (remoteSession) return remoteSession
130
+ } catch (remoteError) {
131
+ console.warn('[session] 桌面云身份回落失败,降级本地 guest:', remoteError instanceof Error ? remoteError.message : remoteError)
132
+ }
133
+ return guestSession()
134
+ }
65
135
  return null
66
136
  }
67
137
  }