@allbluecn/web-app 0.23.2 → 0.23.4

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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
  3. package/src/components/desktop/__tests__/desktop-state-listener.test.tsx +183 -0
  4. package/src/components/desktop/__tests__/sync-indicator.test.tsx +130 -0
  5. package/src/components/desktop/cloud-login.tsx +108 -6
  6. package/src/components/desktop/desktop-state-listener.tsx +76 -0
  7. package/src/components/desktop/shell-widgets.tsx +12 -5
  8. package/src/components/desktop/sync-indicator.tsx +62 -13
  9. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
  10. package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
  11. package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
  12. package/src/components/desktop-agent/agent-chat.tsx +221 -0
  13. package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
  14. package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
  15. package/src/components/layout/sidebar-03/__tests__/user-menu-anonymous.test.tsx +4 -0
  16. package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
  17. package/src/components/layout/sidebar-03/user-menu.tsx +63 -0
  18. package/src/config/sidebar-routes.tsx +14 -1
  19. package/src/lib/__tests__/session.test.ts +95 -0
  20. package/src/lib/auth/auth.config.ts +13 -0
  21. package/src/lib/changelog/sdk-versions.ts +1 -1
  22. package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
  23. package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
  24. package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
  25. package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
  26. package/src/lib/desktop-agent/availability.ts +39 -0
  27. package/src/lib/desktop-agent/connection.ts +72 -0
  28. package/src/lib/desktop-agent/errors.ts +21 -0
  29. package/src/lib/desktop-agent/tool-result.ts +170 -0
  30. package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
  31. package/src/lib/desktop-bridge-types.ts +58 -1
  32. package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
  33. package/src/lib/hotkeys/registry.ts +9 -1
  34. package/src/lib/queries/README.md +1 -0
  35. package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
  36. package/src/lib/queries/desktop-agent.ts +25 -0
  37. package/src/lib/session.ts +15 -1
  38. package/src/paraglide/messages/_index.js +820 -0
  39. package/src/paraglide/messages/en.js +260 -0
  40. package/src/paraglide/messages/zh.js +260 -0
  41. package/src/routeTree.gen.ts +23 -0
  42. package/src/routes/-tests/agent-gate.test.ts +45 -0
  43. package/src/routes/_nav/agent/route.lazy.tsx +63 -0
  44. package/src/routes/_nav/agent/route.tsx +21 -0
  45. package/src/routes/api/sync/account-status.ts +5 -21
  46. package/src/server/__tests__/exchange-remote-token.test.ts +113 -0
  47. package/src/server/__tests__/remote-auth.test.ts +131 -1
  48. package/src/server/desktop-bootstrap.ts +19 -3
  49. package/src/server/desktop-session-merge.ts +51 -0
  50. package/src/server/remote-auth.ts +61 -3
  51. package/src/server/serverFns/auth/session.ts +28 -28
  52. package/src/server/serverFns/sync/transfer.ts +168 -0
  53. package/src/server/serverfn-public-allowlist.ts +4 -0
  54. package/src/server/sync/__tests__/ownership-transfer.test.ts +896 -0
  55. package/src/server/sync/account-status.ts +36 -0
  56. package/src/server/sync/ownership-transfer.ts +291 -0
  57. package/src/server/sync/saas-profile.ts +70 -0
@@ -23,6 +23,7 @@ import { Route as LoginLoginRouteRouteImport } from './routes/_login/login/route
23
23
  import { Route as LoginRegisterRouteRouteImport } from './routes/_login/register/route'
24
24
  import { Route as LoginResetPasswordRouteRouteImport } from './routes/_login/reset-password/route'
25
25
  import { Route as LoginVerifyEmailRouteRouteImport } from './routes/_login/verify-email/route'
26
+ import { Route as NavAgentRouteRouteImport } from './routes/_nav/agent/route'
26
27
  import { Route as NavAppsRouteRouteImport } from './routes/_nav/apps/route'
27
28
  import { Route as NavBugsRouteRouteImport } from './routes/_nav/bugs/route'
28
29
  import { Route as NavChangelogRouteRouteImport } from './routes/_nav/changelog/route'
@@ -251,6 +252,13 @@ const LoginVerifyEmailRouteRoute = LoginVerifyEmailRouteRouteImport.update({
251
252
  } as any).lazy(() =>
252
253
  import('./routes/_login/verify-email/route.lazy').then((d) => d.Route),
253
254
  )
255
+ const NavAgentRouteRoute = NavAgentRouteRouteImport.update({
256
+ id: '/agent',
257
+ path: '/agent',
258
+ getParentRoute: () => NavRouteRoute,
259
+ } as any).lazy(() =>
260
+ import('./routes/_nav/agent/route.lazy').then((d) => d.Route),
261
+ )
254
262
  const NavAppsRouteRoute = NavAppsRouteRouteImport.update({
255
263
  id: '/apps',
256
264
  path: '/apps',
@@ -1123,6 +1131,7 @@ export interface FileRoutesByFullPath {
1123
1131
  '/register': typeof LoginRegisterRouteRoute
1124
1132
  '/reset-password': typeof LoginResetPasswordRouteRoute
1125
1133
  '/verify-email': typeof LoginVerifyEmailRouteRoute
1134
+ '/agent': typeof NavAgentRouteRoute
1126
1135
  '/apps': typeof NavAppsRouteRouteWithChildren
1127
1136
  '/bugs': typeof NavBugsRouteRouteWithChildren
1128
1137
  '/changelog': typeof NavChangelogRouteRoute
@@ -1262,6 +1271,7 @@ export interface FileRoutesByTo {
1262
1271
  '/register': typeof LoginRegisterRouteRoute
1263
1272
  '/reset-password': typeof LoginResetPasswordRouteRoute
1264
1273
  '/verify-email': typeof LoginVerifyEmailRouteRoute
1274
+ '/agent': typeof NavAgentRouteRoute
1265
1275
  '/apps': typeof NavAppsRouteRouteWithChildren
1266
1276
  '/changelog': typeof NavChangelogRouteRoute
1267
1277
  '/dashboard': typeof NavDashboardRouteRoute
@@ -1390,6 +1400,7 @@ export interface FileRoutesById {
1390
1400
  '/_login/register': typeof LoginRegisterRouteRoute
1391
1401
  '/_login/reset-password': typeof LoginResetPasswordRouteRoute
1392
1402
  '/_login/verify-email': typeof LoginVerifyEmailRouteRoute
1403
+ '/_nav/agent': typeof NavAgentRouteRoute
1393
1404
  '/_nav/apps': typeof NavAppsRouteRouteWithChildren
1394
1405
  '/_nav/bugs': typeof NavBugsRouteRouteWithChildren
1395
1406
  '/_nav/changelog': typeof NavChangelogRouteRoute
@@ -1531,6 +1542,7 @@ export interface FileRouteTypes {
1531
1542
  | '/register'
1532
1543
  | '/reset-password'
1533
1544
  | '/verify-email'
1545
+ | '/agent'
1534
1546
  | '/apps'
1535
1547
  | '/bugs'
1536
1548
  | '/changelog'
@@ -1670,6 +1682,7 @@ export interface FileRouteTypes {
1670
1682
  | '/register'
1671
1683
  | '/reset-password'
1672
1684
  | '/verify-email'
1685
+ | '/agent'
1673
1686
  | '/apps'
1674
1687
  | '/changelog'
1675
1688
  | '/dashboard'
@@ -1797,6 +1810,7 @@ export interface FileRouteTypes {
1797
1810
  | '/_login/register'
1798
1811
  | '/_login/reset-password'
1799
1812
  | '/_login/verify-email'
1813
+ | '/_nav/agent'
1800
1814
  | '/_nav/apps'
1801
1815
  | '/_nav/bugs'
1802
1816
  | '/_nav/changelog'
@@ -2059,6 +2073,13 @@ declare module '@tanstack/react-router' {
2059
2073
  preLoaderRoute: typeof LoginVerifyEmailRouteRouteImport
2060
2074
  parentRoute: typeof LoginRouteRoute
2061
2075
  }
2076
+ '/_nav/agent': {
2077
+ id: '/_nav/agent'
2078
+ path: '/agent'
2079
+ fullPath: '/agent'
2080
+ preLoaderRoute: typeof NavAgentRouteRouteImport
2081
+ parentRoute: typeof NavRouteRoute
2082
+ }
2062
2083
  '/_nav/apps': {
2063
2084
  id: '/_nav/apps'
2064
2085
  path: '/apps'
@@ -3351,6 +3372,7 @@ const NavChatRouteWithChildren =
3351
3372
  NavChatRoute._addFileChildren(NavChatRouteChildren)
3352
3373
 
3353
3374
  interface NavRouteRouteChildren {
3375
+ NavAgentRouteRoute: typeof NavAgentRouteRoute
3354
3376
  NavAppsRouteRoute: typeof NavAppsRouteRouteWithChildren
3355
3377
  NavBugsRouteRoute: typeof NavBugsRouteRouteWithChildren
3356
3378
  NavChangelogRouteRoute: typeof NavChangelogRouteRoute
@@ -3372,6 +3394,7 @@ interface NavRouteRouteChildren {
3372
3394
  }
3373
3395
 
3374
3396
  const NavRouteRouteChildren: NavRouteRouteChildren = {
3397
+ NavAgentRouteRoute: NavAgentRouteRoute,
3375
3398
  NavAppsRouteRoute: NavAppsRouteRouteWithChildren,
3376
3399
  NavBugsRouteRoute: NavBugsRouteRouteWithChildren,
3377
3400
  NavChangelogRouteRoute: NavChangelogRouteRoute,
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手页(/_nav/agent)路由门控(桌面本地优先 P3-B5):依赖桌面壳
6
+ // AgentService,云 Web / OSS 构建无此页——beforeLoad 以构建期常量
7
+ // isDesktopEdition(VITE_DEPLOY_TARGET)判定,非桌面构建直接 throw notFound
8
+ // (SSR 即 404,无占位闪烁)。运行时的桥探测(useDesktopAgentPresence)另由
9
+ // lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx 覆盖。
10
+ // 断言方式同 -edition-routing.test.tsx:直接调 Route.options.beforeLoad。
11
+ import { afterEach, describe, expect, it, vi } from 'vitest'
12
+
13
+ async function importAgentRoute(desktop: boolean) {
14
+ vi.resetModules()
15
+ vi.doMock('@/lib/edition-desktop', () => ({ isDesktopEdition: () => desktop }))
16
+ return import('../_nav/agent/route')
17
+ }
18
+
19
+ /** beforeLoad 抛出的 notFound 对象(@tanstack/router-core:{ isNotFound: true }) */
20
+ function catchNotFound(fn: () => unknown): { isNotFound?: boolean } | null {
21
+ try {
22
+ fn()
23
+ return null
24
+ } catch (error) {
25
+ return error as { isNotFound?: boolean }
26
+ }
27
+ }
28
+
29
+ afterEach(() => {
30
+ vi.doUnmock('@/lib/edition-desktop')
31
+ vi.resetModules()
32
+ })
33
+
34
+ describe('/_nav/agent beforeLoad 门控', () => {
35
+ it('非桌面构建(云 Web / OSS):throw notFound(页面不存在,SSR 即 404)', async () => {
36
+ const { Route } = await importAgentRoute(false)
37
+ const result = catchNotFound(() => Route.options.beforeLoad!({} as never))
38
+ expect(result?.isNotFound).toBe(true)
39
+ })
40
+
41
+ it('桌面构建:放行(运行时无桥由 lazy 组件的 useDesktopAgentPresence 落到 NotFound)', async () => {
42
+ const { Route } = await importAgentRoute(true)
43
+ expect(Route.options.beforeLoad!({} as never)).toBeUndefined()
44
+ })
45
+ })
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手页(桌面本地优先 P3-B5):授权目录卡 + agent 对话区。门控说明见
6
+ // 同目录 route.tsx;本文件承担运行时桥探测(mounted 模式:SSR / hydration 首帧
7
+ // 渲染空白、不读 window,挂载后 Electron 恢复页面,无桥即 NotFound)。
8
+ //
9
+ // 双层判定:presence(含 agentPort 装配,见 lib/desktop-agent/availability)决定
10
+ // 页面是否可达;isAgentWorkspaceSupported(B1 三方法)决定目录授权是否可用——
11
+ // 旧壳缺三方法时整页只留升级提示,不渲染对话区(否则对话区「请先添加授权目录」
12
+ // 与卡片「请升级客户端」互相打脸)。
13
+ import { useState } from 'react'
14
+ import { createLazyFileRoute } from '@tanstack/react-router'
15
+ import { useQuery } from '@tanstack/react-query'
16
+ import { AgentChat } from '@/components/desktop-agent/agent-chat'
17
+ import { AgentWorkspaceCard } from '@/components/desktop-agent/agent-workspace-card'
18
+ import { isAgentWorkspaceSupported } from '@/lib/desktop-agent/availability'
19
+ import { useDesktopAgentPresence } from '@/lib/desktop-agent/use-desktop-agent-presence'
20
+ import { desktopAgentWorkspacesQueryOptions } from '@/lib/queries/desktop-agent'
21
+ import { m } from '@/paraglide/messages.js'
22
+ import { NavNotFound } from '../-components/nav-not-found'
23
+
24
+ export const Route = createLazyFileRoute('/_nav/agent')({
25
+ component: DesktopAgentRoute,
26
+ })
27
+
28
+ function DesktopAgentRoute() {
29
+ const presence = useDesktopAgentPresence()
30
+ if (presence === 'pending') return null
31
+ if (presence === 'absent') return <NavNotFound />
32
+ return <DesktopAgentPage />
33
+ }
34
+
35
+ function DesktopAgentPage() {
36
+ const supportsWorkspaces = isAgentWorkspaceSupported()
37
+ // enabled 与卡片同口径:不支持目录授权时不查清单,避免无效 IPC
38
+ const { data: workspaces = [] } = useQuery({
39
+ ...desktopAgentWorkspacesQueryOptions(),
40
+ enabled: supportsWorkspaces,
41
+ })
42
+ const [picked, setPicked] = useState<string | null>(null)
43
+ const [running, setRunning] = useState(false)
44
+ // 未选择 / 选中项已被撤销 → 回落清单首项(清单为空则 null,对话区禁用输入)
45
+ const cwd = picked !== null && workspaces.includes(picked) ? picked : (workspaces[0] ?? null)
46
+
47
+ return (
48
+ <div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
49
+ <header className="space-y-1">
50
+ <h1 className="text-lg font-semibold">{m.agent_desktop_title()}</h1>
51
+ <p className="text-sm text-muted-foreground">{m.agent_desktop_pageDescription()}</p>
52
+ </header>
53
+ <div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
54
+ <div className="shrink-0 lg:w-80 lg:overflow-y-auto">
55
+ <AgentWorkspaceCard selected={cwd} onSelect={setPicked} disabled={running} />
56
+ </div>
57
+ {/* 旧壳(无目录授权 IPC)只留卡片里的升级提示;否则每个目录一份会话
58
+ (换目录即新会话,运行中由 disabled 锁定切换) */}
59
+ {supportsWorkspaces && <AgentChat key={cwd ?? 'none'} cwd={cwd} onRunningChange={setRunning} />}
60
+ </div>
61
+ </div>
62
+ )
63
+ }
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 本地文件助手(桌面本地优先 P3-B5):依赖桌面壳的 AgentService,云 Web / OSS
6
+ // 部署无此页。门控三层,保证「入口可见 ⟹ 页面可达」:
7
+ // ① beforeLoad:构建期常量 isDesktopEdition()(VITE_DEPLOY_TARGET,同构执行、SSR
8
+ // 安全——与 settings/sync、_public/index 的 beforeLoad 同口径),非桌面构建直接
9
+ // notFound(服务端即 404,无占位闪烁);
10
+ // ② lazy 组件:挂载后 getDesktopBridge() 运行时探测(useDesktopAgentPresence)——
11
+ // 桌面构建被浏览器直开 backend 端口(无桥)→ NotFound;
12
+ // ③ 侧栏入口:同一 presence 判定条件渲染(app-sidebar)。
13
+ // 无 loader:授权清单走 react-query 工厂(lib/queries/desktop-agent,IPC 数据源)。
14
+ import { createFileRoute, notFound } from '@tanstack/react-router'
15
+ import { isDesktopEdition } from '@/lib/edition-desktop'
16
+
17
+ export const Route = createFileRoute('/_nav/agent')({
18
+ beforeLoad: () => {
19
+ if (!isDesktopEdition()) throw notFound()
20
+ },
21
+ })
@@ -4,13 +4,13 @@
4
4
 
5
5
  // account-status 端点(桌面本地优先 P2-A5,迁移设计归属转移前置核对):桌面端绑定
6
6
  // 云账号前探测目标账号是否「空账号」——仅空账号才走归属转移(一期唯一路径)。
7
- // 空账号口径(复审⑥ P1-9 扩容):当前用户六表 count 全 0——stories/bugs/testCases
8
- // /projects 之外计入 knowledgeBases/knowledgeDocuments,防仅有知识库数据的云账号
9
- // 被误判空而清掉知识库。属主字段:story/bug/testCase → createdById、project →
10
- // ownerId、knowledgeBase → userId、knowledgeDocument → createdBy(schema 实查)。
7
+ // 六表 count 口径与属主字段实查收敛在 server/sync/account-status.ts 的
8
+ // isAccountEmpty(A8a 抽取,transferOwnershipFn 复核复用同源),此处只做
9
+ // requireAuth + 响应映射。
11
10
  import { createFileRoute } from "@tanstack/react-router"
12
11
  import { getPrisma } from "@allbluecn/database/get-prisma"
13
12
  import { requireAuth, UnauthorizedError } from "@/server/auth-helpers"
13
+ import { isAccountEmpty } from "@/server/sync/account-status"
14
14
 
15
15
  export const Route = createFileRoute("/api/sync/account-status")({
16
16
  server: {
@@ -21,23 +21,7 @@ export const Route = createFileRoute("/api/sync/account-status")({
21
21
  const userId = session.user.id
22
22
 
23
23
  const prisma = await getPrisma()
24
- const [stories, bugs, testCases, projects, knowledgeBases, knowledgeDocuments] =
25
- await Promise.all([
26
- prisma.story.count({ where: { createdById: userId } }),
27
- prisma.bug.count({ where: { createdById: userId } }),
28
- prisma.testCase.count({ where: { createdById: userId } }),
29
- prisma.project.count({ where: { ownerId: userId } }),
30
- prisma.knowledgeBase.count({ where: { userId } }),
31
- prisma.knowledgeDocument.count({ where: { createdBy: userId } }),
32
- ])
33
-
34
- const empty =
35
- stories === 0 &&
36
- bugs === 0 &&
37
- testCases === 0 &&
38
- projects === 0 &&
39
- knowledgeBases === 0 &&
40
- knowledgeDocuments === 0
24
+ const empty = await isAccountEmpty(prisma, userId)
41
25
 
42
26
  return Response.json({ empty })
43
27
  } catch (e) {
@@ -0,0 +1,113 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // exchangeRemoteTokenFn 双路径落盘测试(A10 修复,控制者核实缺口):
6
+ // session 块(remote-auth.json 的 session.userId/planName,readRemoteAuth 登录态
7
+ // 唯一依据)必须两条成功路径都写——原先仅降级路径写,生产 SaaS 恒发 signedToken
8
+ // → signedToken 路径云身份 100% 不激活 + 云态写入属主错为 guest。
9
+ // mock 惯例照 desktop-bootstrap.test.ts(csv-export 样板):createServerFn 捕获器
10
+ // 直调 handler;SaaS fetch 用 vi.stubGlobal 拦截,remote-auth/entitlements-token
11
+ // /rate-limit 全 mock(不真写盘、不真限流)。
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
+
14
+ const H = vi.hoisted(() => ({
15
+ writeRemoteAuth: vi.fn(),
16
+ writeRemoteSession: vi.fn(),
17
+ generateEntitlementsToken: vi.fn(),
18
+ checkAuthRateLimit: vi.fn(),
19
+ fetch: vi.fn(),
20
+ }))
21
+
22
+ vi.mock('@tanstack/react-start', () => {
23
+ // 每个 serverFn 实例独立 capture(session.ts 有三个 serverFn,防串号)
24
+ const createServerFn = () => {
25
+ const capture: { validator?: unknown; handler?: unknown } = {}
26
+ const api = {
27
+ validator: (v: unknown) => {
28
+ capture.validator = v
29
+ return api
30
+ },
31
+ middleware: () => api,
32
+ handler: (h: unknown) => {
33
+ capture.handler = h
34
+ return async (args?: { data?: unknown }) => {
35
+ const v = capture.validator as { parse?: (d: unknown) => unknown } | undefined
36
+ const data = v && typeof v.parse === 'function' ? v.parse(args?.data) : args?.data
37
+ return (capture.handler as (p: { data?: unknown }) => unknown)({ data })
38
+ }
39
+ },
40
+ }
41
+ return api
42
+ }
43
+ return { createServerFn }
44
+ })
45
+
46
+ vi.mock('@tanstack/react-start/server', () => ({ getRequest: () => ({}) }))
47
+
48
+ vi.mock('@/lib/session', () => ({ getCurrentSession: vi.fn(async () => null) }))
49
+
50
+ vi.mock('@/server/desktop-bootstrap', () => ({ ensureLocalShadowUser: vi.fn() }))
51
+
52
+ vi.mock('@/server/remote-auth', () => ({
53
+ readRemoteAuth: vi.fn(async () => null),
54
+ writeRemoteAuth: H.writeRemoteAuth,
55
+ writeRemoteSession: H.writeRemoteSession,
56
+ clearRemoteAuth: vi.fn(),
57
+ }))
58
+
59
+ vi.mock('@/server/entitlements-token', () => ({
60
+ generateEntitlementsToken: H.generateEntitlementsToken,
61
+ }))
62
+
63
+ vi.mock('@/server/middlewares/rate-limit', () => ({ checkAuthRateLimit: H.checkAuthRateLimit }))
64
+
65
+ import { exchangeRemoteTokenFn } from '../serverFns/auth/session'
66
+
67
+ describe('exchangeRemoteTokenFn 双路径落盘(A10 修复)', () => {
68
+ const saved = process.env.DEPLOY_TARGET
69
+
70
+ beforeEach(() => {
71
+ vi.resetAllMocks()
72
+ vi.stubGlobal('fetch', H.fetch)
73
+ })
74
+
75
+ afterEach(() => {
76
+ vi.unstubAllEnvs()
77
+ vi.unstubAllGlobals()
78
+ if (saved === undefined) delete process.env.DEPLOY_TARGET
79
+ else process.env.DEPLOY_TARGET = saved
80
+ })
81
+
82
+ it('signedToken 路径(生产 SaaS 恒发):session 块 + token 双写', async () => {
83
+ process.env.DEPLOY_TARGET = 'desktop'
84
+ H.fetch.mockResolvedValue({
85
+ ok: true,
86
+ json: async () => ({
87
+ user: { id: 'cloud-1', name: 'Cloud 用户', email: 'cloud@example.com' },
88
+ planName: 'pro',
89
+ features: { sync: true },
90
+ signedToken: 'signed-abc',
91
+ }),
92
+ })
93
+ const res = await exchangeRemoteTokenFn({ data: { token: 'tok-123' } })
94
+ expect(res).toEqual({ success: true, userId: 'cloud-1', planName: 'pro' })
95
+ // 修复断言:session 块必须落盘(缺它 readRemoteAuth 恒 null → 云身份不激活)
96
+ expect(H.writeRemoteSession).toHaveBeenCalledWith('cloud-1', 'pro', 'tok-123')
97
+ expect(H.writeRemoteAuth).toHaveBeenCalledWith('signed-abc', 'tok-123')
98
+ expect(H.generateEntitlementsToken).not.toHaveBeenCalled()
99
+ })
100
+
101
+ it('degraded 路径(本地无签名 secret):仅写 session 块,不写 writeRemoteAuth', async () => {
102
+ process.env.DEPLOY_TARGET = 'desktop'
103
+ H.fetch.mockResolvedValue({
104
+ ok: true,
105
+ json: async () => ({ user: { id: 'cloud-2' }, planName: 'free' }),
106
+ })
107
+ H.generateEntitlementsToken.mockResolvedValue(null)
108
+ const res = await exchangeRemoteTokenFn({ data: { token: 'tok-456' } })
109
+ expect(res).toEqual({ success: true, userId: 'cloud-2', planName: 'free', degraded: true })
110
+ expect(H.writeRemoteSession).toHaveBeenCalledWith('cloud-2', 'free', 'tok-456')
111
+ expect(H.writeRemoteAuth).not.toHaveBeenCalled()
112
+ })
113
+ })
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest"
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
2
2
  import { promises as fs } from "node:fs"
3
3
  import os from "node:os"
4
4
  import path from "node:path"
@@ -70,3 +70,133 @@ describe("remote-auth(Electron fs 持久层)", () => {
70
70
  expect(await readRemoteAuth()).toBeNull()
71
71
  })
72
72
  })
73
+
74
+ // ---------- A12 desktop codec 接缝(safeStorage 透明加解密) ----------
75
+
76
+ // stub codec(Pro 域 entry.cjs/main.ts 注入的 globalThis.__allblueDesktopCodec
77
+ // 同契约形态:XOR 对称可逆,密文经 enc:v1: 前缀 + base64 落盘,与真实现等价)
78
+ function stubCodec() {
79
+ const KEY = 0x5a
80
+ const xor = (b: Buffer) => Buffer.from([...b].map((x) => x ^ KEY))
81
+ return {
82
+ encrypt: async (plain: string) => `enc:v1:${xor(Buffer.from(plain, "utf8")).toString("base64")}`,
83
+ decrypt: async (raw: string) => {
84
+ if (!raw.startsWith("enc:v1:")) return raw
85
+ return xor(Buffer.from(raw.slice("enc:v1:".length), "base64")).toString("utf8")
86
+ },
87
+ }
88
+ }
89
+
90
+ describe("remote-auth desktop codec 接缝(A12)", () => {
91
+ let dir: string
92
+ const saved: Record<string, string | undefined> = {}
93
+ const savedCodec = { value: undefined as unknown }
94
+
95
+ beforeEach(async () => {
96
+ for (const k of ENV_KEYS) saved[k] = process.env[k]
97
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), "remote-auth-codec-test-"))
98
+ process.env.DESKTOP_USER_DATA_DIR = dir
99
+ process.env.DEPLOY_TARGET = "desktop"
100
+ savedCodec.value = globalThis.__allblueDesktopCodec
101
+ })
102
+
103
+ afterEach(async () => {
104
+ globalThis.__allblueDesktopCodec = savedCodec.value as typeof globalThis.__allblueDesktopCodec
105
+ for (const k of ENV_KEYS) {
106
+ if (saved[k] === undefined) delete process.env[k]
107
+ else process.env[k] = saved[k]
108
+ }
109
+ await fs.rm(dir, { recursive: true, force: true })
110
+ })
111
+
112
+ it("codec 注入:密文落盘(磁盘字节不含明文 token)且读回一致", async () => {
113
+ globalThis.__allblueDesktopCodec = stubCodec()
114
+ await writeRemoteAuth("tok-1", "stale-st")
115
+ await writeRemoteSession("user-1", "pro", "fresh-st")
116
+ const raw = await fs.readFile(path.join(dir, "remote-auth.json"), "utf8")
117
+ expect(raw.startsWith("enc:v1:")).toBe(true)
118
+ expect(raw).not.toContain("fresh-st")
119
+ expect(raw).not.toContain("stale-st")
120
+ expect(raw).not.toContain("tok-1")
121
+ expect(raw).not.toContain("sessionToken")
122
+ expect(() => JSON.parse(raw)).toThrow()
123
+ // roundtrip:读回一致
124
+ expect(await readRemoteAuth()).toMatchObject({
125
+ userId: "user-1",
126
+ planName: "pro",
127
+ sessionToken: "fresh-st",
128
+ })
129
+ })
130
+
131
+ it("codec 注入但磁盘为旧明文:按明文读取(平滑迁移,无前缀直通)", async () => {
132
+ globalThis.__allblueDesktopCodec = stubCodec()
133
+ const legacy = JSON.stringify({
134
+ session: { userId: "user-1", planName: "pro", sessionToken: "legacy-st" },
135
+ })
136
+ await fs.writeFile(path.join(dir, "remote-auth.json"), legacy, "utf8")
137
+ expect(await readRemoteAuth()).toMatchObject({ sessionToken: "legacy-st" })
138
+ })
139
+
140
+ it("无 codec:行为不变——明文落盘(现状回归锚,逐字节兼容)", async () => {
141
+ globalThis.__allblueDesktopCodec = undefined
142
+ await writeRemoteAuth("tok-1")
143
+ await writeRemoteSession("user-1", "pro", "fresh-st")
144
+ const raw = await fs.readFile(path.join(dir, "remote-auth.json"), "utf8")
145
+ const parsed = JSON.parse(raw) as { session?: { sessionToken?: string } }
146
+ expect(parsed.session?.sessionToken).toBe("fresh-st") // 明文形态 = 现状
147
+ expect(await readRemoteAuth()).toMatchObject({ sessionToken: "fresh-st" })
148
+ })
149
+
150
+ it("codec 降级形态(encrypt 返回无前缀明文):不二次加前缀、明文落盘可读回", async () => {
151
+ // safeStorage 不可用降级由 Pro 域算子收敛为「原样明文」输出——接缝必须
152
+ // 原样落盘,不得对降级明文再补前缀(否则读回解密必败)
153
+ globalThis.__allblueDesktopCodec = {
154
+ encrypt: async (p: string) => p,
155
+ decrypt: async (r: string) => r,
156
+ }
157
+ await writeRemoteSession("user-1", "pro", "fresh-st")
158
+ const raw = await fs.readFile(path.join(dir, "remote-auth.json"), "utf8")
159
+ expect(raw.startsWith("enc:v1:")).toBe(false)
160
+ expect((JSON.parse(raw) as { session?: { sessionToken?: string } }).session?.sessionToken).toBe(
161
+ "fresh-st",
162
+ )
163
+ expect(await readRemoteAuth()).toMatchObject({ sessionToken: "fresh-st" })
164
+ })
165
+
166
+ it("密文文件但 codec 缺失(独立预检分支):warn + null(视为未登录,下次写入自愈)", async () => {
167
+ // 旧壳读到新格式 / 异常态:磁盘密文在场、globalThis codec 未注入——
168
+ // readFile 显式预检 + warn + null,不抛错反噬调用方。warn 断言钉死独立
169
+ // 预检分支(防空转:无预检时 JSON.parse 抛错同样落 catch → null,结果
170
+ // 相同但丢诊断信号)
171
+ globalThis.__allblueDesktopCodec = undefined
172
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
173
+ try {
174
+ await fs.writeFile(
175
+ path.join(dir, "remote-auth.json"),
176
+ `enc:v1:${Buffer.from('{"session":{"userId":"user-1"}}').toString("base64")}`,
177
+ "utf8",
178
+ )
179
+ expect(await readRemoteAuth()).toBeNull()
180
+ expect(
181
+ warnSpy.mock.calls.some((args) => String(args[0]).includes("codec 未注入")),
182
+ ).toBe(true)
183
+ } finally {
184
+ warnSpy.mockRestore()
185
+ }
186
+ })
187
+
188
+ it("密文在场但 decrypt 抛错(IPC 失败/密文损坏):catch 吞错 → null", async () => {
189
+ globalThis.__allblueDesktopCodec = {
190
+ encrypt: async (p: string) => `enc:v1:${Buffer.from(p).toString("base64")}`,
191
+ decrypt: async () => {
192
+ throw new Error("codec rpc 超时")
193
+ },
194
+ }
195
+ await fs.writeFile(
196
+ path.join(dir, "remote-auth.json"),
197
+ `enc:v1:${Buffer.from("garbage-not-json").toString("base64")}`,
198
+ "utf8",
199
+ )
200
+ expect(await readRemoteAuth()).toBeNull()
201
+ })
202
+ })
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { randomUUID } from 'node:crypto'
27
27
  import { prisma } from '@allbluecn/database'
28
+ import type { Prisma } from '@allbluecn/database'
28
29
  import { ensureBuiltinIndependentProject } from '@/server/builtin-project'
29
30
  import {
30
31
  GUEST_EMAIL,
@@ -54,7 +55,24 @@ function shadowBgColor(seed: string): string {
54
55
  export async function ensureLocalShadowUser(): Promise<string> {
55
56
  if (!isDesktopServer()) return GUEST_USER_ID
56
57
 
57
- await prisma.user.upsert({
58
+ await ensureLocalShadowUserWith(prisma)
59
+
60
+ await ensureBuiltinIndependentProject(GUEST_USER_ID)
61
+ return GUEST_USER_ID
62
+ }
63
+
64
+ /**
65
+ * 影子用户 upsert 主体(可传事务客户端,A9 抽取):公开无参包装
66
+ * (ensureLocalShadowUser)的语义不变,反向转移(ownership-transfer 的
67
+ * reverseTransferOwnership)在**同一事务内**调本函数重建 guest 行——原实现
68
+ * 直用模块级 prisma 单例,事务内调用会绕过 tx(属主翻转引用的 FK 目标必须与
69
+ * 翻转同事务存在,否则并发删除/回滚会留孤儿)。
70
+ * 只含 users 行 upsert:内置独立项目走 ensureBuiltinIndependentProject(模块级
71
+ * prisma),由登出后的 getSessionFn guest 分支懒补(项目行经反向转移已回 guest
72
+ * 属主,通常已存在)。
73
+ */
74
+ export async function ensureLocalShadowUserWith(tx: Prisma.TransactionClient): Promise<string> {
75
+ await tx.user.upsert({
58
76
  where: { id: GUEST_USER_ID },
59
77
  create: {
60
78
  id: GUEST_USER_ID,
@@ -68,7 +86,5 @@ export async function ensureLocalShadowUser(): Promise<string> {
68
86
  },
69
87
  update: {},
70
88
  })
71
-
72
- await ensureBuiltinIndependentProject(GUEST_USER_ID)
73
89
  return GUEST_USER_ID
74
90
  }
@@ -0,0 +1,51 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 桌面轻量云身份组装(桌面本地优先 P2 / A10):RemoteAuthData → Session 的
6
+ // 单一组装点 + getCurrentSession 的桌面回落入口。独立模块原因:lib/session
7
+ // 的回落需要组装函数,而 lib/session 不得 import serverFns/**(serverFns/
8
+ // auth/session 反向 import lib/session,会成环)——故从 serverFns/auth/
9
+ // session.ts 抽出 buildRemoteSession 共享(字段逐字一致,两处不再漂移)。
10
+ // 本模块保持纯数据组装 + remote-auth 纯 fs 读取(node:fs + env 守卫),
11
+ // 无 prisma/server 重依赖链;lib/session 对本模块的单条 import 经 eslint
12
+ // 文件级豁免放行(apps/web/eslint.config.mjs)。
13
+ import { DEFAULT_AI_MODEL, DEFAULT_AI_PROVIDER } from '@/lib/ai/default-model'
14
+ import { isDesktopServer } from '@/server/desktop-guest'
15
+ import { readRemoteAuth } from '@/server/remote-auth'
16
+ import type { RemoteAuthData } from '@/server/remote-auth'
17
+ import type { Session } from '@/lib/session'
18
+
19
+ export function buildRemoteSession(data: RemoteAuthData): Session {
20
+ return {
21
+ user: {
22
+ id: data.userId,
23
+ name: data.username ?? data.userId,
24
+ email: data.email,
25
+ role: (data.role ?? 'USER') as 'USER' | 'ADMIN' | 'SUPER_ADMIN',
26
+ isGuest: false,
27
+ // 云端头像透传(SaaS desktop-callback 下发;缺省回落首字母,修复桌面端
28
+ // 当前用户头像不显示)
29
+ avatarConfig: data.avatarConfig,
30
+ bgShape: data.bgShape,
31
+ bgColor: data.bgColor,
32
+ aiProvider: DEFAULT_AI_PROVIDER,
33
+ aiModel: DEFAULT_AI_MODEL,
34
+ hasAiKey: false,
35
+ },
36
+ expires: 'never',
37
+ }
38
+ }
39
+
40
+ /**
41
+ * getCurrentSession 的桌面回落段(优先级链:authjs session → desktop
42
+ * remoteAuth → null):authjs session 无效且为 desktop 部署时,读
43
+ * remote-auth.json 组装轻量云身份(user.id = 云 userId)。desktop 判定
44
+ * 先于 fs 读取(非 desktop 恒 null、零 IO),调用方无需重复判定。
45
+ */
46
+ export async function getDesktopRemoteSession(): Promise<Session | null> {
47
+ if (!isDesktopServer()) return null
48
+ const remoteAuth = await readRemoteAuth()
49
+ if (!remoteAuth) return null
50
+ return buildRemoteSession(remoteAuth)
51
+ }