@allbluecn/web-app 0.23.3 → 0.23.5

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 (134) hide show
  1. package/package.json +3 -3
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
  3. package/src/components/desktop/__tests__/cloud-login-rollback.test.tsx +161 -0
  4. package/src/components/desktop/cloud-login.tsx +29 -26
  5. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
  6. package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
  7. package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
  8. package/src/components/desktop-agent/agent-chat.tsx +221 -0
  9. package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
  10. package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
  11. package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
  12. package/src/config/sidebar-routes.tsx +14 -1
  13. package/src/lib/changelog/sdk-versions.ts +1 -1
  14. package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
  15. package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
  16. package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
  17. package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
  18. package/src/lib/desktop-agent/availability.ts +39 -0
  19. package/src/lib/desktop-agent/connection.ts +72 -0
  20. package/src/lib/desktop-agent/errors.ts +21 -0
  21. package/src/lib/desktop-agent/tool-result.ts +170 -0
  22. package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
  23. package/src/lib/desktop-bridge-types.ts +22 -1
  24. package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
  25. package/src/lib/hotkeys/registry.ts +9 -1
  26. package/src/lib/queries/README.md +1 -0
  27. package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
  28. package/src/lib/queries/desktop-agent.ts +25 -0
  29. package/src/paraglide/messages/_index.d.ts +42 -0
  30. package/src/paraglide/messages/_index.js +42 -0
  31. package/src/paraglide/messages/agent_desktop_chatcwd1.d.ts +19 -0
  32. package/src/paraglide/messages/agent_desktop_chatcwd1.js +30 -0
  33. package/src/paraglide/messages/agent_desktop_chatemptyhint2.d.ts +17 -0
  34. package/src/paraglide/messages/agent_desktop_chatemptyhint2.js +30 -0
  35. package/src/paraglide/messages/agent_desktop_chatemptytitle2.d.ts +17 -0
  36. package/src/paraglide/messages/agent_desktop_chatemptytitle2.js +30 -0
  37. package/src/paraglide/messages/agent_desktop_chatindependenthint2.d.ts +17 -0
  38. package/src/paraglide/messages/agent_desktop_chatindependenthint2.js +30 -0
  39. package/src/paraglide/messages/agent_desktop_chatnoworkspace2.d.ts +17 -0
  40. package/src/paraglide/messages/agent_desktop_chatnoworkspace2.js +30 -0
  41. package/src/paraglide/messages/agent_desktop_chatplaceholder1.d.ts +17 -0
  42. package/src/paraglide/messages/agent_desktop_chatplaceholder1.js +30 -0
  43. package/src/paraglide/messages/agent_desktop_chatsend1.d.ts +17 -0
  44. package/src/paraglide/messages/agent_desktop_chatsend1.js +30 -0
  45. package/src/paraglide/messages/agent_desktop_chatstop1.d.ts +17 -0
  46. package/src/paraglide/messages/agent_desktop_chatstop1.js +30 -0
  47. package/src/paraglide/messages/agent_desktop_chatthinking1.d.ts +17 -0
  48. package/src/paraglide/messages/agent_desktop_chatthinking1.js +30 -0
  49. package/src/paraglide/messages/agent_desktop_errorconnection1.d.ts +17 -0
  50. package/src/paraglide/messages/agent_desktop_errorconnection1.js +30 -0
  51. package/src/paraglide/messages/agent_desktop_errorforbidden1.d.ts +17 -0
  52. package/src/paraglide/messages/agent_desktop_errorforbidden1.js +30 -0
  53. package/src/paraglide/messages/agent_desktop_errorgeneric1.d.ts +19 -0
  54. package/src/paraglide/messages/agent_desktop_errorgeneric1.js +30 -0
  55. package/src/paraglide/messages/agent_desktop_errorunauthorized1.d.ts +17 -0
  56. package/src/paraglide/messages/agent_desktop_errorunauthorized1.js +30 -0
  57. package/src/paraglide/messages/agent_desktop_errorunavailable1.d.ts +17 -0
  58. package/src/paraglide/messages/agent_desktop_errorunavailable1.js +30 -0
  59. package/src/paraglide/messages/agent_desktop_pagedescription1.d.ts +17 -0
  60. package/src/paraglide/messages/agent_desktop_pagedescription1.js +30 -0
  61. package/src/paraglide/messages/agent_desktop_title.d.ts +16 -0
  62. package/src/paraglide/messages/agent_desktop_title.js +29 -0
  63. package/src/paraglide/messages/agent_desktop_toolcountfiles2.d.ts +19 -0
  64. package/src/paraglide/messages/agent_desktop_toolcountfiles2.js +30 -0
  65. package/src/paraglide/messages/agent_desktop_toolcounthits2.d.ts +19 -0
  66. package/src/paraglide/messages/agent_desktop_toolcounthits2.js +30 -0
  67. package/src/paraglide/messages/agent_desktop_toolcountlines2.d.ts +19 -0
  68. package/src/paraglide/messages/agent_desktop_toolcountlines2.js +30 -0
  69. package/src/paraglide/messages/agent_desktop_toolemptydir2.d.ts +17 -0
  70. package/src/paraglide/messages/agent_desktop_toolemptydir2.js +30 -0
  71. package/src/paraglide/messages/agent_desktop_toolerror1.d.ts +17 -0
  72. package/src/paraglide/messages/agent_desktop_toolerror1.js +30 -0
  73. package/src/paraglide/messages/agent_desktop_toolfind1.d.ts +19 -0
  74. package/src/paraglide/messages/agent_desktop_toolfind1.js +30 -0
  75. package/src/paraglide/messages/agent_desktop_toolgrep1.d.ts +19 -0
  76. package/src/paraglide/messages/agent_desktop_toolgrep1.js +30 -0
  77. package/src/paraglide/messages/agent_desktop_toolls1.d.ts +19 -0
  78. package/src/paraglide/messages/agent_desktop_toolls1.js +30 -0
  79. package/src/paraglide/messages/agent_desktop_toolmore1.d.ts +19 -0
  80. package/src/paraglide/messages/agent_desktop_toolmore1.js +30 -0
  81. package/src/paraglide/messages/agent_desktop_toolnomatches2.d.ts +17 -0
  82. package/src/paraglide/messages/agent_desktop_toolnomatches2.js +30 -0
  83. package/src/paraglide/messages/agent_desktop_toolother1.d.ts +19 -0
  84. package/src/paraglide/messages/agent_desktop_toolother1.js +30 -0
  85. package/src/paraglide/messages/agent_desktop_toolread1.d.ts +19 -0
  86. package/src/paraglide/messages/agent_desktop_toolread1.js +30 -0
  87. package/src/paraglide/messages/agent_desktop_toolrunning1.d.ts +17 -0
  88. package/src/paraglide/messages/agent_desktop_toolrunning1.js +30 -0
  89. package/src/paraglide/messages/agent_desktop_tooltruncated1.d.ts +17 -0
  90. package/src/paraglide/messages/agent_desktop_tooltruncated1.js +30 -0
  91. package/src/paraglide/messages/agent_desktop_workspaceadd1.d.ts +17 -0
  92. package/src/paraglide/messages/agent_desktop_workspaceadd1.js +30 -0
  93. package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.d.ts +17 -0
  94. package/src/paraglide/messages/agent_desktop_workspaceaddfailed2.js +30 -0
  95. package/src/paraglide/messages/agent_desktop_workspacedescription1.d.ts +17 -0
  96. package/src/paraglide/messages/agent_desktop_workspacedescription1.js +30 -0
  97. package/src/paraglide/messages/agent_desktop_workspaceempty1.d.ts +17 -0
  98. package/src/paraglide/messages/agent_desktop_workspaceempty1.js +30 -0
  99. package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.d.ts +17 -0
  100. package/src/paraglide/messages/agent_desktop_workspaceemptyhint2.js +30 -0
  101. package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.d.ts +17 -0
  102. package/src/paraglide/messages/agent_desktop_workspaceloadfailed2.js +30 -0
  103. package/src/paraglide/messages/agent_desktop_workspaceremove1.d.ts +17 -0
  104. package/src/paraglide/messages/agent_desktop_workspaceremove1.js +30 -0
  105. package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.d.ts +17 -0
  106. package/src/paraglide/messages/agent_desktop_workspaceremovefailed2.js +30 -0
  107. package/src/paraglide/messages/agent_desktop_workspacetitle1.d.ts +17 -0
  108. package/src/paraglide/messages/agent_desktop_workspacetitle1.js +30 -0
  109. package/src/paraglide/messages/agent_desktop_workspaceunsupported1.d.ts +17 -0
  110. package/src/paraglide/messages/agent_desktop_workspaceunsupported1.js +30 -0
  111. package/src/paraglide/messages/common_hotkey_desc_agentabort1.d.ts +17 -0
  112. package/src/paraglide/messages/common_hotkey_desc_agentabort1.js +30 -0
  113. package/src/paraglide/messages/sync_bind_rolled_back.d.ts +16 -0
  114. package/src/paraglide/messages/sync_bind_rolled_back.js +29 -0
  115. package/src/routeTree.gen.ts +23 -0
  116. package/src/routes/-tests/agent-gate.test.ts +45 -0
  117. package/src/routes/_nav/agent/route.lazy.tsx +63 -0
  118. package/src/routes/_nav/agent/route.tsx +21 -0
  119. package/src/routes/api/sync/account-status.ts +4 -2
  120. package/src/routes/api/sync/push.ts +11 -3
  121. package/src/server/__tests__/desktop-cloud-identity.test.ts +331 -0
  122. package/src/server/__tests__/desktop-session-merge.test.ts +42 -0
  123. package/src/server/__tests__/exchange-remote-token.test.ts +92 -10
  124. package/src/server/__tests__/remote-auth.test.ts +39 -0
  125. package/src/server/desktop-bootstrap.ts +16 -5
  126. package/src/server/desktop-cloud-identity.ts +247 -0
  127. package/src/server/remote-auth.ts +33 -1
  128. package/src/server/serverFns/auth/session.ts +27 -2
  129. package/src/server/serverFns/sync/transfer.ts +28 -27
  130. package/src/server/serverFns/workspace-kb.ts +10 -0
  131. package/src/server/sync/__tests__/ownership-transfer.test.ts +261 -48
  132. package/src/server/sync/__tests__/sync-first-push.e2e.test.ts +349 -0
  133. package/src/server/sync/account-status-cloud.ts +41 -0
  134. package/src/server/sync/ownership-transfer.ts +88 -49
@@ -11,13 +11,32 @@
11
11
  // /rate-limit 全 mock(不真写盘、不真限流)。
12
12
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
13
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
- }))
14
+ const H = vi.hoisted(() => {
15
+ type UserWhere = { where?: Record<string, string>; create?: Record<string, unknown> }
16
+ const tx = {
17
+ user: {
18
+ findUnique: vi.fn(async (_args?: UserWhere) => null as Record<string, unknown> | null),
19
+ upsert: vi.fn(async (args?: UserWhere) => args?.create),
20
+ },
21
+ }
22
+ return {
23
+ writeRemoteAuth: vi.fn(),
24
+ writeRemoteSession: vi.fn(),
25
+ generateEntitlementsToken: vi.fn(),
26
+ checkAuthRateLimit: vi.fn(),
27
+ fetch: vi.fn(),
28
+ tx,
29
+ prisma: {
30
+ $transaction: vi.fn(async (fn: (t: typeof tx) => Promise<unknown>) => fn(tx)),
31
+ },
32
+ }
33
+ })
34
+
35
+ /** 取第 n 次 user.upsert 的 create 载荷(测试断言入口) */
36
+ function upsertCreate(n = 0): Record<string, unknown> {
37
+ const call = H.tx.user.upsert.mock.calls[n]?.[0] as { create?: Record<string, unknown> } | undefined
38
+ return call?.create ?? {}
39
+ }
21
40
 
22
41
  vi.mock('@tanstack/react-start', () => {
23
42
  // 每个 serverFn 实例独立 capture(session.ts 有三个 serverFn,防串号)
@@ -62,6 +81,8 @@ vi.mock('@/server/entitlements-token', () => ({
62
81
 
63
82
  vi.mock('@/server/middlewares/rate-limit', () => ({ checkAuthRateLimit: H.checkAuthRateLimit }))
64
83
 
84
+ vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: async () => H.prisma }))
85
+
65
86
  import { exchangeRemoteTokenFn } from '../serverFns/auth/session'
66
87
 
67
88
  describe('exchangeRemoteTokenFn 双路径落盘(A10 修复)', () => {
@@ -69,6 +90,10 @@ describe('exchangeRemoteTokenFn 双路径落盘(A10 修复)', () => {
69
90
 
70
91
  beforeEach(() => {
71
92
  vi.resetAllMocks()
93
+ // resetAllMocks 清掉 hoisted 里的默认实现——逐用例回填(空库:无同 id 行)
94
+ H.tx.user.findUnique.mockResolvedValue(null)
95
+ H.tx.user.upsert.mockImplementation(async (args) => args?.create)
96
+ H.prisma.$transaction.mockImplementation(async (fn) => fn(H.tx))
72
97
  vi.stubGlobal('fetch', H.fetch)
73
98
  })
74
99
 
@@ -79,7 +104,7 @@ describe('exchangeRemoteTokenFn 双路径落盘(A10 修复)', () => {
79
104
  else process.env.DEPLOY_TARGET = saved
80
105
  })
81
106
 
82
- it('signedToken 路径(生产 SaaS 恒发):session 块 + token 双写', async () => {
107
+ it('signedToken 路径(生产 SaaS 恒发):session 块 + token 双写 + 云身份行落库', async () => {
83
108
  process.env.DEPLOY_TARGET = 'desktop'
84
109
  H.fetch.mockResolvedValue({
85
110
  ok: true,
@@ -93,9 +118,27 @@ describe('exchangeRemoteTokenFn 双路径落盘(A10 修复)', () => {
93
118
  const res = await exchangeRemoteTokenFn({ data: { token: 'tok-123' } })
94
119
  expect(res).toEqual({ success: true, userId: 'cloud-1', planName: 'pro' })
95
120
  // 修复断言:session 块必须落盘(缺它 readRemoteAuth 恒 null → 云身份不激活)
96
- expect(H.writeRemoteSession).toHaveBeenCalledWith('cloud-1', 'pro', 'tok-123')
121
+ // C2:profile 一并落盘(桌面回落会话组装真实昵称/邮箱)
122
+ expect(H.writeRemoteSession).toHaveBeenCalledWith('cloud-1', 'pro', 'tok-123', {
123
+ name: 'Cloud 用户',
124
+ email: 'cloud@example.com',
125
+ avatarConfig: undefined,
126
+ bgShape: undefined,
127
+ bgColor: undefined,
128
+ })
97
129
  expect(H.writeRemoteAuth).toHaveBeenCalledWith('signed-abc', 'tok-123')
98
130
  expect(H.generateEntitlementsToken).not.toHaveBeenCalled()
131
+ // C2 云身份自洽:users 行必须建出来(否则按 cloudUserId 的列表全空 / P2003)
132
+ expect(H.prisma.$transaction).toHaveBeenCalledTimes(1)
133
+ expect(H.tx.user.upsert).toHaveBeenCalledTimes(1)
134
+ const create = upsertCreate()
135
+ expect(create).toMatchObject({
136
+ id: 'cloud-1',
137
+ email: 'cloud@example.com',
138
+ username: 'Cloud 用户',
139
+ status: 'ACTIVE',
140
+ })
141
+ expect(typeof create.password).toBe('string')
99
142
  })
100
143
 
101
144
  it('degraded 路径(本地无签名 secret):仅写 session 块,不写 writeRemoteAuth', async () => {
@@ -107,7 +150,46 @@ describe('exchangeRemoteTokenFn 双路径落盘(A10 修复)', () => {
107
150
  H.generateEntitlementsToken.mockResolvedValue(null)
108
151
  const res = await exchangeRemoteTokenFn({ data: { token: 'tok-456' } })
109
152
  expect(res).toEqual({ success: true, userId: 'cloud-2', planName: 'free', degraded: true })
110
- expect(H.writeRemoteSession).toHaveBeenCalledWith('cloud-2', 'free', 'tok-456')
153
+ expect(H.writeRemoteSession).toHaveBeenCalledWith('cloud-2', 'free', 'tok-456', {
154
+ name: undefined,
155
+ email: undefined,
156
+ avatarConfig: undefined,
157
+ bgShape: undefined,
158
+ bgColor: undefined,
159
+ })
111
160
  expect(H.writeRemoteAuth).not.toHaveBeenCalled()
161
+ // 降级路径同样要有身份行(权益降级不等于身份缺失)
162
+ expect(H.tx.user.upsert).toHaveBeenCalledTimes(1)
163
+ })
164
+
165
+ it('同 email 已被本地凭据用户占用 → 降级占位邮箱,不 P2002 崩登录', async () => {
166
+ process.env.DEPLOY_TARGET = 'desktop'
167
+ H.fetch.mockResolvedValue({
168
+ ok: true,
169
+ json: async () => ({
170
+ user: { id: 'cloud-3', name: 'Cloud 三', email: 'taken@example.com' },
171
+ planName: 'free',
172
+ signedToken: 'signed-x',
173
+ }),
174
+ })
175
+ H.tx.user.findUnique.mockImplementation(
176
+ async (args?: { where?: Record<string, string> }) =>
177
+ args?.where?.email === 'taken@example.com' ? { id: 'local-user-1' } : null,
178
+ )
179
+ await exchangeRemoteTokenFn({ data: { token: 'tok-789' } })
180
+ expect(upsertCreate().email).toBe('cloud-3@cloud.local')
181
+ })
182
+
183
+ it('身份行已存在(重复 exchange)→ 幂等:不 upsert、不覆盖既有资料', async () => {
184
+ process.env.DEPLOY_TARGET = 'desktop'
185
+ H.fetch.mockResolvedValue({
186
+ ok: true,
187
+ json: async () => ({ user: { id: 'cloud-4' }, planName: 'free', signedToken: 'signed-y' }),
188
+ })
189
+ H.tx.user.findUnique.mockResolvedValue({ id: 'cloud-4' })
190
+ await exchangeRemoteTokenFn({ data: { token: 'tok-999' } })
191
+ expect(H.tx.user.upsert).not.toHaveBeenCalled()
192
+ // 会话仍照常落盘(激活与建行解耦:行在不在都要写 session)
193
+ expect(H.writeRemoteSession).toHaveBeenCalledTimes(1)
112
194
  })
113
195
  })
@@ -58,6 +58,45 @@ describe("remote-auth(Electron fs 持久层)", () => {
58
58
  expect(await readRemoteAuth()).toBeNull()
59
59
  })
60
60
 
61
+ it("C2 云资料落盘:writeRemoteSession 第 4 参 profile 读回为真实昵称/邮箱/头像", async () => {
62
+ await writeRemoteSession("user-1", "pro", "st-1", {
63
+ name: "云上名字",
64
+ email: "cloud@example.com",
65
+ avatarConfig: { hat: 1, face: 2 },
66
+ bgShape: "circle",
67
+ bgColor: "#3b82f6",
68
+ })
69
+ expect(await readRemoteAuth()).toMatchObject({
70
+ userId: "user-1",
71
+ username: "云上名字",
72
+ email: "cloud@example.com",
73
+ avatarConfig: { hat: 1, face: 2 },
74
+ bgShape: "circle",
75
+ bgColor: "#3b82f6",
76
+ })
77
+ // 后续 writeRemoteSession 省略 profile(旧调用形态)→ 已落盘资料不被抹掉
78
+ await writeRemoteSession("user-1", "pro", "st-2")
79
+ expect(await readRemoteAuth()).toMatchObject({
80
+ sessionToken: "st-2",
81
+ username: "云上名字",
82
+ })
83
+ // writeRemoteAuth 走 merge 语义,profile 与 session 互不覆盖
84
+ await writeRemoteAuth("signed-token", "st-2")
85
+ expect(await readRemoteAuth()).toMatchObject({ username: "云上名字", sessionToken: "st-2" })
86
+ })
87
+
88
+ it("C2 旧文件无 profile:读回资料字段全省略(回落 UUID/首字母,行为与旧版一致)", async () => {
89
+ await fs.writeFile(
90
+ path.join(dir, "remote-auth.json"),
91
+ JSON.stringify({ session: { userId: "user-1", planName: "pro" } }),
92
+ "utf8",
93
+ )
94
+ const data = await readRemoteAuth()
95
+ expect(data).toMatchObject({ userId: "user-1", planName: "pro" })
96
+ expect(data?.username).toBeUndefined()
97
+ expect(data?.avatarConfig).toBeUndefined()
98
+ })
99
+
61
100
  it("非 desktop 部署恒 no-op:写入不落盘、读取恒 null", async () => {
62
101
  process.env.DEPLOY_TARGET = "vercel"
63
102
  await writeRemoteSession("user-1", "pro")
@@ -70,11 +70,17 @@ export async function ensureLocalShadowUser(): Promise<string> {
70
70
  * 只含 users 行 upsert:内置独立项目走 ensureBuiltinIndependentProject(模块级
71
71
  * prisma),由登出后的 getSessionFn guest 分支懒补(项目行经反向转移已回 guest
72
72
  * 属主,通常已存在)。
73
+ *
74
+ * @param preferences C3:反向转移把云身份行的用户级配置列(AI key/模型/语言/
75
+ * 时区等)带给重建的 guest 行。只在 create 分支生效——影子行已存在时保留
76
+ * 本机既有值(update 留空的幂等语义不变)。
73
77
  */
74
- export async function ensureLocalShadowUserWith(tx: Prisma.TransactionClient): Promise<string> {
75
- await tx.user.upsert({
76
- where: { id: GUEST_USER_ID },
77
- create: {
78
+ export async function ensureLocalShadowUserWith(
79
+ tx: Prisma.TransactionClient,
80
+ preferences?: Record<string, unknown>,
81
+ ): Promise<string> {
82
+ const create = Object.assign(
83
+ {
78
84
  id: GUEST_USER_ID,
79
85
  email: GUEST_EMAIL,
80
86
  username: GUEST_USERNAME,
@@ -82,8 +88,13 @@ export async function ensureLocalShadowUserWith(tx: Prisma.TransactionClient): P
82
88
  avatarConfig: defaultAvatarConfig as object,
83
89
  bgShape: 'square',
84
90
  bgColor: shadowBgColor(GUEST_USERNAME),
85
- status: 'ACTIVE',
91
+ status: 'ACTIVE' as const,
86
92
  },
93
+ preferences ?? {},
94
+ ) as Prisma.UserUncheckedCreateInput
95
+ await tx.user.upsert({
96
+ where: { id: GUEST_USER_ID },
97
+ create,
87
98
  update: {},
88
99
  })
89
100
  return GUEST_USER_ID
@@ -0,0 +1,247 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 云身份行幂等落库(桌面本地优先 P3 收尾 C2 / 裁定 R3)。
6
+ //
7
+ // 背景:exchangeRemoteTokenFn 原先只写 remote-auth.json,不建本地 users 行 →
8
+ // 云会话激活后按 cloudUserId 的列表全空、写入撞 P2003(外键目标不存在)、
9
+ // 同步 scope 解析不到任何行——「云身份自洽」缺口。非空账号 / 探测不可达 /
10
+ // 旧壳路径直接把用户带进这个坏态。
11
+ //
12
+ // 本模块是**唯一**的云身份建行入口(exchange、正向转移共用;反向转移复用
13
+ // ensureLocalShadowUserWith 重建 guest 行):
14
+ // - 幂等:已存在同 id 行 → 不覆盖用户后续改名/改头像(update 分支留空);唯一
15
+ // 例外是**带 copyPreferencesFrom 的调用**(正向转移)——此时对目标行里仍为空
16
+ // /默认值的用户级配置列做「仅填空」更新(见 ensureCloudIdentity);
17
+ // - 唯一冲突不崩:email / username 可能已被本地凭据注册的用户占用(users 两列
18
+ // 都是 @unique),逐个探测后降级——email 降级为 `<cloudUserId>@cloud.local`
19
+ // (保留云端邮箱会撞 P2002 直接崩登录;清空 email 会让行彻底失联,占位符
20
+ // 至少保留「这是云账号 X」的可追溯性),username 依次降级 name → id → `<id>@cloud`;
21
+ // - password 随机占位(同 desktop-guest / desktop-bootstrap 惯例):云身份不走
22
+ // 本地凭据登录,凭据比对必失败。
23
+ import { randomUUID } from 'node:crypto'
24
+ import type { Prisma } from '@allbluecn/database'
25
+
26
+ /** 云端资料(SaaS desktop-callback 下发的身份字段子集) */
27
+ export interface CloudIdentityProfile {
28
+ id: string
29
+ name?: string | null
30
+ email?: string | null
31
+ avatarConfig?: Record<string, unknown> | null
32
+ bgShape?: string | null
33
+ bgColor?: string | null
34
+ }
35
+
36
+ export interface CloudIdentityResult {
37
+ /** true = 本次新建了 users 行;false = 行已存在(幂等 no-op) */
38
+ created: boolean
39
+ email: string | null
40
+ username: string | null
41
+ /** email 因与本地既有用户冲突而降级为占位地址 */
42
+ emailFallback: boolean
43
+ }
44
+
45
+ /** 云身份占位邮箱后缀(冲突降级用;不参与邮件投递) */
46
+ const CLOUD_EMAIL_DOMAIN = 'cloud.local'
47
+
48
+ function placeholderEmail(id: string): string {
49
+ return `${id}@${CLOUD_EMAIL_DOMAIN}`
50
+ }
51
+
52
+ async function isEmailTaken(
53
+ tx: Prisma.TransactionClient,
54
+ email: string,
55
+ selfId: string,
56
+ ): Promise<boolean> {
57
+ const row = await tx.user.findUnique({ where: { email }, select: { id: true } })
58
+ return !!row && row.id !== selfId
59
+ }
60
+
61
+ async function isUsernameTaken(
62
+ tx: Prisma.TransactionClient,
63
+ username: string,
64
+ selfId: string,
65
+ ): Promise<boolean> {
66
+ const row = await tx.user.findUnique({ where: { username }, select: { id: true } })
67
+ return !!row && row.id !== selfId
68
+ }
69
+
70
+ /**
71
+ * 幂等确保云身份行存在(id = cloudUserId)。
72
+ *
73
+ * @param tx 事务客户端(exchange 用独立事务、转移用转移事务——不可传模块单例)
74
+ * @param profile 云端资料(name/email/avatar 缺省时按 id 兜底)
75
+ * @param options.copyPreferencesFrom 正向转移传 guest 行 id、反向转移传云行 id:
76
+ * 新建行时复制该用户的用户级配置列(C3:AI 配置/语言/时区等随身份带走,
77
+ * 否则 guest 行删除后配置随级联消失);**已存在行时改为「仅填空」更新**
78
+ * (R1:真实绑定链是 exchange 先幂等建行 → 转移这里命中 existing,若直接
79
+ * no-op 则 guest 行随后被删、locale/timezone/AI key 等全部丢失)
80
+ */
81
+ export async function ensureCloudIdentity(
82
+ tx: Prisma.TransactionClient,
83
+ profile: CloudIdentityProfile,
84
+ options: { copyPreferencesFrom?: string } = {},
85
+ ): Promise<CloudIdentityResult> {
86
+ const id = profile.id?.trim()
87
+ if (!id) throw new Error('[desktop-cloud-identity] 云身份 id 不能为空')
88
+
89
+ const existing = await tx.user.findUnique({ where: { id }, select: { id: true } })
90
+ if (existing) {
91
+ // update 分支留空语义:已存在即 no-op(幂等,不覆盖用户后续资料)。
92
+ // 例外:带 copyPreferencesFrom 时对「仍为空/默认值」的配置列做仅填空更新
93
+ // (真实顺序 = exchange 先建行 → 转移命中 existing;不填则配置随 guest 行删除丢失)。
94
+ // 不传源(exchange 自建行)时保持纯 no-op——交换阶段没有可复制的源。
95
+ if (options.copyPreferencesFrom) {
96
+ await fillMissingPreferences(tx, id, options.copyPreferencesFrom)
97
+ }
98
+ return { created: false, email: null, username: null, emailFallback: false }
99
+ }
100
+
101
+ const preferredEmail = profile.email?.trim() || null
102
+ let email = preferredEmail ?? placeholderEmail(id)
103
+ let emailFallback = false
104
+ if (preferredEmail && (await isEmailTaken(tx, preferredEmail, id))) {
105
+ email = placeholderEmail(id)
106
+ emailFallback = true
107
+ }
108
+
109
+ const preferredName = profile.name?.trim() || id
110
+ let username = preferredName
111
+ if (await isUsernameTaken(tx, username, id)) {
112
+ username = id
113
+ if (await isUsernameTaken(tx, username, id)) {
114
+ username = `${id}@cloud`
115
+ }
116
+ }
117
+
118
+ const preferences = options.copyPreferencesFrom
119
+ ? await readUserPreferences(tx, options.copyPreferencesFrom)
120
+ : {}
121
+
122
+ // 用户级配置列按 schema 实际类型(String/Json)展开——统一经 Object.assign
123
+ // 收口,避免逐列手写可选字段(新增配置列只改 USER_PREFERENCE_COLUMNS)
124
+ const create = Object.assign(
125
+ {
126
+ id,
127
+ email,
128
+ username,
129
+ // 随机占位(非 bcrypt 哈希):云身份不走本地凭据登录
130
+ password: randomUUID(),
131
+ status: 'ACTIVE' as const,
132
+ avatarConfig: (profile.avatarConfig ?? undefined) as object | undefined,
133
+ bgShape: profile.bgShape ?? undefined,
134
+ bgColor: profile.bgColor ?? undefined,
135
+ },
136
+ preferences,
137
+ ) as Prisma.UserUncheckedCreateInput
138
+
139
+ await tx.user.upsert({ where: { id }, create, update: {} })
140
+
141
+ return { created: true, email, username, emailFallback }
142
+ }
143
+
144
+ /**
145
+ * 用户级配置列清单(C3 裁定「AI 配置/偏好随身份翻转」):
146
+ * 属「用户偏好 / 个人配置」而非业务数据的 User 标量列——身份翻转(正向
147
+ * guest→cloud、反向 cloud→guest)时必须随行带走,否则:
148
+ * ① 正向:guest 行删除后这些配置随行消失(AI key 直接丢);
149
+ * ② 反向:云态填的 key 随云行删除消失。
150
+ *
151
+ * 口径:schema User 模型中「用户自己填/选的个人配置」列——
152
+ * aiProvider / aiApiKey / aiModel / aiSourceMode(AI 配置)、locale(语言)、
153
+ * timezone(时区)、storyDisplayOptions(需求展示偏好)、preferredCurrency
154
+ * (计费币种)、defaultWorkspaceId(默认工作空间,属主翻转后同一 id 仍有效)。
155
+ * 不含 role/status/tokenVersion/emailVerified/password(账号安全态,不是偏好);
156
+ * 不含 avatarConfig/bgShape/bgColor(身份行创建时由云端资料显式写入)。
157
+ */
158
+ export const USER_PREFERENCE_COLUMNS = [
159
+ 'aiApiKey',
160
+ 'aiModel',
161
+ 'aiProvider',
162
+ 'aiSourceMode',
163
+ 'defaultWorkspaceId',
164
+ 'locale',
165
+ 'preferredCurrency',
166
+ 'storyDisplayOptions',
167
+ 'timezone',
168
+ ] as const
169
+
170
+ export type UserPreferences = Partial<Record<(typeof USER_PREFERENCE_COLUMNS)[number], unknown>>
171
+
172
+ /**
173
+ * 非空配置列的 DB 默认值——与「未设置」等价,可被源值填充(R1「仅填空值」口径)。
174
+ * 目前仅 preferredCurrency(schema: `String @default("usd")`,列 NOT NULL,读出来
175
+ * 恒非 null,不特殊处理会永远被视为「已有值」而拒绝填充)。新增非空默认列时同步登记。
176
+ */
177
+ const PREFERENCE_DEFAULT_VALUES: Partial<Record<(typeof USER_PREFERENCE_COLUMNS)[number], unknown>> = {
178
+ preferredCurrency: 'usd',
179
+ }
180
+
181
+ /** 列值是否「空」:null/undefined,或等于该列的 DB 默认值(见 PREFERENCE_DEFAULT_VALUES) */
182
+ function isUnsetPreference(
183
+ column: (typeof USER_PREFERENCE_COLUMNS)[number],
184
+ value: unknown,
185
+ ): boolean {
186
+ if (value === undefined || value === null) return true
187
+ return column in PREFERENCE_DEFAULT_VALUES && value === PREFERENCE_DEFAULT_VALUES[column]
188
+ }
189
+
190
+ /** 原样读取用户级配置列(含 null——「仅填空」判定需要区分 null 与有值) */
191
+ async function readUserPreferencesRaw(
192
+ tx: Prisma.TransactionClient,
193
+ userId: string,
194
+ ): Promise<Record<string, unknown> | null> {
195
+ const select = Object.fromEntries(USER_PREFERENCE_COLUMNS.map((c) => [c, true]))
196
+ const row = await tx.user.findUnique({
197
+ where: { id: userId },
198
+ select: select as Record<(typeof USER_PREFERENCE_COLUMNS)[number], true>,
199
+ })
200
+ return (row as Record<string, unknown> | null) ?? null
201
+ }
202
+
203
+ /** 读取用户级配置列(只取非 null——复制时 null 不覆盖目标行已有值) */
204
+ export async function readUserPreferences(
205
+ tx: Prisma.TransactionClient,
206
+ userId: string,
207
+ ): Promise<UserPreferences> {
208
+ const row = await readUserPreferencesRaw(tx, userId)
209
+ if (!row) return {}
210
+ const out: UserPreferences = {}
211
+ for (const col of USER_PREFERENCE_COLUMNS) {
212
+ const value = row[col]
213
+ if (value !== undefined && value !== null) out[col] = value
214
+ }
215
+ return out
216
+ }
217
+
218
+ /**
219
+ * 「仅填空值」把源用户的配置列补到目标用户(R1)。
220
+ *
221
+ * 口径:源值非空 且 目标值仍为空/默认值 才写(**绝不覆盖目标已有配置**——
222
+ * 用户已在云态填过的值优先)。只写真正需要补的列,patch 为空时不发 UPDATE
223
+ * (保持「已存在即零写入」的幂等可观测性)。返回实际写入的 patch(测试断言用)。
224
+ */
225
+ async function fillMissingPreferences(
226
+ tx: Prisma.TransactionClient,
227
+ targetUserId: string,
228
+ sourceUserId: string,
229
+ ): Promise<UserPreferences> {
230
+ const source = await readUserPreferences(tx, sourceUserId)
231
+ if (Object.keys(source).length === 0) return {}
232
+ const target = await readUserPreferencesRaw(tx, targetUserId)
233
+ if (!target) return {}
234
+
235
+ const patch: Record<string, unknown> = {}
236
+ for (const col of USER_PREFERENCE_COLUMNS) {
237
+ const value = source[col]
238
+ if (value === undefined || value === null) continue
239
+ if (!isUnsetPreference(col, target[col])) continue
240
+ if (target[col] === value) continue
241
+ patch[col] = value
242
+ }
243
+ if (Object.keys(patch).length === 0) return {}
244
+
245
+ await tx.user.update({ where: { id: targetUserId }, data: patch })
246
+ return patch as UserPreferences
247
+ }
@@ -30,6 +30,19 @@ export interface RemoteAuthData {
30
30
  bgColor?: string
31
31
  }
32
32
 
33
+ /**
34
+ * 云账号资料快照(C2:exchange 时从 SaaS profile 落盘,session merge 组装真实
35
+ * 昵称/邮箱/头像——此前 remote-auth 只存 userId,桌面回落会话恒渲染 UUID)。
36
+ * add-only:旧文件无此块 → 全部省略,行为与旧版一致(UI 回落首字母/ID)。
37
+ */
38
+ export interface RemoteAuthProfile {
39
+ name?: string | null
40
+ email?: string | null
41
+ avatarConfig?: Record<string, number> | null
42
+ bgShape?: string | null
43
+ bgColor?: string | null
44
+ }
45
+
33
46
  /** 磁盘形态:auth(登录 token)与 session(exchange 后的会话态)分次写入、合并读取 */
34
47
  interface RemoteAuthFile {
35
48
  token?: string
@@ -38,6 +51,8 @@ interface RemoteAuthFile {
38
51
  userId: string
39
52
  planName: string
40
53
  sessionToken?: string
54
+ /** C2 云资料(add-only;缺省 = 旧版文件) */
55
+ profile?: RemoteAuthProfile
41
56
  }
42
57
  }
43
58
 
@@ -121,11 +136,18 @@ export async function readRemoteAuth(): Promise<RemoteAuthData | null> {
121
136
  if (process.env.DEPLOY_TARGET !== 'desktop') return null
122
137
  const data = await readFile()
123
138
  if (!data?.session?.userId) return null
139
+ const profile = data.session.profile
124
140
  return {
125
141
  userId: data.session.userId,
126
142
  planName: data.session.planName,
127
143
  features: {},
128
144
  sessionToken: data.session.sessionToken ?? data.sessionToken,
145
+ // C2:云资料透传(旧文件无 profile → 全 undefined,回落旧行为)
146
+ username: profile?.name ?? undefined,
147
+ email: profile?.email ?? undefined,
148
+ avatarConfig: profile?.avatarConfig ?? undefined,
149
+ bgShape: profile?.bgShape ?? undefined,
150
+ bgColor: profile?.bgColor ?? undefined,
129
151
  }
130
152
  }
131
153
 
@@ -144,11 +166,21 @@ export async function writeRemoteSession(
144
166
  userId: string,
145
167
  planName: string,
146
168
  sessionToken?: string,
169
+ profile?: RemoteAuthProfile,
147
170
  ): Promise<void> {
148
171
  if (process.env.DEPLOY_TARGET !== 'desktop') return
149
172
  try {
150
173
  const prev = (await readFile()) ?? {}
151
- await writeFile({ ...prev, session: { userId, planName, sessionToken } })
174
+ await writeFile({
175
+ ...prev,
176
+ // profile 省略时保留既有块(writeRemoteAuth 等后续写入不回退资料)
177
+ session: {
178
+ userId,
179
+ planName,
180
+ sessionToken,
181
+ ...(profile ? { profile } : prev.session?.profile ? { profile: prev.session.profile } : {}),
182
+ },
183
+ })
152
184
  } catch (e) {
153
185
  console.warn('[remote-auth] write_remote_session 失败(本地会话态未更新):', e instanceof Error ? e.message : String(e))
154
186
  }
@@ -19,10 +19,12 @@ import { createServerFn } from '@tanstack/react-start'
19
19
  import { z } from 'zod'
20
20
  import { getRequest } from '@tanstack/react-start/server'
21
21
  import type { FeatureKey } from '@allbluecn/kernel'
22
+ import { getPrisma } from '@allbluecn/database/get-prisma'
22
23
  import { getCurrentSession } from '@/lib/session'
23
24
  import { resetProfileCache } from '@/lib/auth/auth.config'
24
25
  import { buildGuestSession, isDesktopServer } from '@/server/desktop-guest'
25
26
  import { ensureLocalShadowUser } from '@/server/desktop-bootstrap'
27
+ import { ensureCloudIdentity } from '@/server/desktop-cloud-identity'
26
28
  import { readRemoteAuth, writeRemoteAuth, writeRemoteSession, clearRemoteAuth } from '@/server/remote-auth'
27
29
  import { generateEntitlementsToken } from '@/server/entitlements-token'
28
30
  import { buildRemoteSession } from '@/server/desktop-session-merge'
@@ -125,12 +127,35 @@ export const exchangeRemoteTokenFn = createServerFn({ method: 'POST' })
125
127
  })
126
128
  }
127
129
 
130
+ // 云身份自洽(C2 / 裁定 R3):先幂等落本地 users 行(id = 云 userId)——
131
+ // 否则云会话激活后按 cloudUserId 的列表全空、写入撞 P2003、同步 scope 解析
132
+ // 不到任何行。放在 remote-auth 落盘之前:建行失败则整个 exchange 失败,
133
+ // renderer 走回滚路径,绝不留下「已激活但无身份行」的坏态。
134
+ const prisma = await getPrisma()
135
+ await prisma.$transaction((tx) =>
136
+ ensureCloudIdentity(tx, {
137
+ id: profile.user.id,
138
+ name: profile.user.name,
139
+ email: profile.user.email,
140
+ avatarConfig: profile.user.avatarConfig,
141
+ bgShape: profile.user.bgShape,
142
+ bgColor: profile.user.bgColor,
143
+ }),
144
+ )
145
+
128
146
  // session 块双路径无条件落盘(A10 修复,控制者核实的激活缺口):readRemoteAuth
129
147
  // 以 data.session.userId 为登录态唯一依据,原先仅降级路径写 session——生产
130
148
  // SaaS 恒发 signedToken,signedToken 路径云身份 100% 不激活、云态写入属主错
131
149
  // 为 guest。writeRemoteSession / writeRemoteAuth 均 merge 语义({...prev,
132
- // ...}),先 session 后 token 不互踩。
133
- await writeRemoteSession(profile.user.id, planName, token)
150
+ // ...}),先 session 后 token 不互踩。profile 一并落盘(C2):桌面回落会话
151
+ // 由此组装真实昵称/邮箱/头像,不再回落 UUID。
152
+ await writeRemoteSession(profile.user.id, planName, token, {
153
+ name: profile.user.name,
154
+ email: profile.user.email,
155
+ avatarConfig: profile.user.avatarConfig,
156
+ bgShape: profile.user.bgShape,
157
+ bgColor: profile.user.bgColor,
158
+ })
134
159
  if (!signedToken) {
135
160
  console.warn('[exchangeRemoteTokenFn] 无法签发权益 token(缺少 ENTITLEMENTS_SIGNING_SECRET),降级 OSS')
136
161
  return { success: true, userId: profile.user.id, planName, degraded: true }