@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
@@ -3,6 +3,14 @@
3
3
  // backend serverFn 进程内 fs 直达,无 IPC)。Tauri invoke 实现已随
4
4
  // Tauri 2 → Electron 迁移清除(2026-10-31);云/Web 部署(DEPLOY_TARGET
5
5
  // 非 desktop)恒 null / no-op,行为与旧版一致。
6
+ //
7
+ // A12 敏感文件加密(desktop 本地优先 P2):文件含云会话 token(敏感),
8
+ // 内部 fs IO 经 globalThis.__allblueDesktopCodec 透明加解密(codec 由 Electron
9
+ // Pro 域注入:main 直连 safeStorage,backend 经 entry.cjs parentPort IPC 请求
10
+ // main——safeStorage 是 main 进程 API,复审⑥ P2-13)。接缝缺省(web/OSS/云/
11
+ // dev 非 desktop/旧壳)行为逐字节不变:明文落盘、明文可读。密文格式
12
+ // enc:v1:<base64>(与 Pro 仓 cloud-session.json 同前缀);无前缀旧文件直通
13
+ // (平滑迁移:读取兼容,下次写入自然升级密文)。
6
14
  import { promises as fs } from "node:fs"
7
15
  import path from "node:path"
8
16
 
@@ -39,13 +47,59 @@ function filePath(): string | null {
39
47
  return path.join(dir, "remote-auth.json")
40
48
  }
41
49
 
50
+ // ---------- A12 desktop codec 接缝(与 Pro 仓 login.ts 同型) ----------
51
+
52
+ /** 密文前缀标记(Pro 仓 electron/sync/encfile.ts ENC_PREFIX,两仓统一) */
53
+ const DESKTOP_ENC_PREFIX = "enc:v1:"
54
+
55
+ /**
56
+ * desktop codec 接缝契约:encrypt(plain) 返回最终落盘内容(enc:v1: 密文,
57
+ * 或 safeStorage 不可用时降级明文——不二次加前缀);decrypt(raw) 收磁盘原文
58
+ * (识别前缀解密,旧明文直通)。Pro 域注入:main 直连 safeStorage(globalThis
59
+ * 自持),backend 经 entry.cjs parentPort IPC 请求 main(同 conflict-rpc 模型)。
60
+ */
61
+ interface DesktopCodec {
62
+ encrypt(plain: string): Promise<string | Buffer>
63
+ decrypt(cipher: string): Promise<string>
64
+ }
65
+
66
+ // 调用时点读取(非模块加载期捕获)——backend entry.cjs 在 nitro 动态 import
67
+ // 前顶装,规避注入时序;写放如返回 Buffer(原始密文字节)则补前缀 + base64。
68
+ declare global {
69
+ var __allblueDesktopCodec: DesktopCodec | undefined
70
+ }
71
+
72
+ async function encodeDiskContent(plain: string): Promise<string> {
73
+ const codec = globalThis.__allblueDesktopCodec
74
+ if (!codec) return plain
75
+ const out = await codec.encrypt(plain)
76
+ if (typeof out === "string") return out
77
+ return `${DESKTOP_ENC_PREFIX}${Buffer.from(out).toString("base64")}`
78
+ }
79
+
80
+ async function decodeDiskContent(raw: string): Promise<string> {
81
+ if (!raw.startsWith(DESKTOP_ENC_PREFIX)) return raw
82
+ const codec = globalThis.__allblueDesktopCodec
83
+ if (!codec) throw new Error("密文 remote-auth 但 codec 未注入")
84
+ return await codec.decrypt(raw)
85
+ }
86
+
42
87
  async function readFile(): Promise<RemoteAuthFile | null> {
43
88
  const file = filePath()
44
89
  if (!file) return null
45
90
  try {
46
- return JSON.parse(await fs.readFile(file, "utf8")) as RemoteAuthFile
91
+ const raw = await fs.readFile(file, "utf8")
92
+ // 密文形态(带前缀)必须经 codec 解密;旧明文按原样解析(迁移兼容,
93
+ // 不依赖 codec 在场)。密文但 codec 缺失(旧壳/异常态)→ 警告并视为
94
+ // 未登录(下次登录写入自愈),不抛错反噬调用方。
95
+ if (!raw.startsWith(DESKTOP_ENC_PREFIX)) return JSON.parse(raw) as RemoteAuthFile
96
+ if (!globalThis.__allblueDesktopCodec) {
97
+ console.warn("[remote-auth] 密文 remote-auth 但 codec 未注入(视为未登录,下次写入自愈)")
98
+ return null
99
+ }
100
+ return JSON.parse(await decodeDiskContent(raw)) as RemoteAuthFile
47
101
  } catch {
48
- // 文件不存在/损坏均视为未登录(损坏文件在下次写入时被覆盖自愈)
102
+ // 文件不存在/损坏/解密失败均视为未登录(损坏文件在下次写入时被覆盖自愈)
49
103
  return null
50
104
  }
51
105
  }
@@ -53,9 +107,13 @@ async function readFile(): Promise<RemoteAuthFile | null> {
53
107
  async function writeFile(next: RemoteAuthFile): Promise<void> {
54
108
  const file = filePath()
55
109
  if (!file) return
110
+ // codec 在场 → 密文落盘(encodeDiskContent 可能因 IPC 异常抛错:宁可不写
111
+ // ——由 writeRemoteAuth/writeRemoteSession 的既有 catch 吞掉并告警——不做
112
+ // 明文兜底);无 codec → 明文(行为=现状)。
113
+ const content = await encodeDiskContent(JSON.stringify(next, null, 2))
56
114
  const tmp = `${file}.tmp`
57
115
  // 0600:文件含云会话 token(敏感),限属主读写;tmp+rename 原子替换防半写
58
- await fs.writeFile(tmp, JSON.stringify(next, null, 2), { mode: 0o600 })
116
+ await fs.writeFile(tmp, content, { mode: 0o600 })
59
117
  await fs.rename(tmp, file)
60
118
  }
61
119
 
@@ -15,18 +15,17 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { DEFAULT_AI_MODEL, DEFAULT_AI_PROVIDER } from '@/lib/ai/default-model'
19
18
  import { createServerFn } from '@tanstack/react-start'
20
19
  import { z } from 'zod'
21
20
  import { getRequest } from '@tanstack/react-start/server'
22
21
  import type { FeatureKey } from '@allbluecn/kernel'
23
- import type { Session } from '@/lib/session'
24
22
  import { getCurrentSession } from '@/lib/session'
23
+ import { resetProfileCache } from '@/lib/auth/auth.config'
25
24
  import { buildGuestSession, isDesktopServer } from '@/server/desktop-guest'
26
25
  import { ensureLocalShadowUser } from '@/server/desktop-bootstrap'
27
26
  import { readRemoteAuth, writeRemoteAuth, writeRemoteSession, clearRemoteAuth } from '@/server/remote-auth'
28
27
  import { generateEntitlementsToken } from '@/server/entitlements-token'
29
- import type { RemoteAuthData } from '@/server/remote-auth'
28
+ import { buildRemoteSession } from '@/server/desktop-session-merge'
30
29
  import { checkAuthRateLimit } from '@/server/middlewares/rate-limit'
31
30
 
32
31
  const SAAS_URL = process.env.DESKTOP_SAAS_URL || 'https://allblue.litsex.cn'
@@ -48,26 +47,9 @@ const saasProfileSchema = z.object({
48
47
  signedToken: z.string().optional(),
49
48
  })
50
49
 
51
- function buildRemoteSession(data: RemoteAuthData): Session {
52
- return {
53
- user: {
54
- id: data.userId,
55
- name: data.username ?? data.userId,
56
- email: data.email,
57
- role: (data.role ?? 'USER') as 'USER' | 'ADMIN' | 'SUPER_ADMIN',
58
- isGuest: false,
59
- // 云端头像透传(SaaS desktop-callback 下发;缺省回落首字母,修复桌面端
60
- // 当前用户头像不显示)
61
- avatarConfig: data.avatarConfig,
62
- bgShape: data.bgShape,
63
- bgColor: data.bgColor,
64
- aiProvider: DEFAULT_AI_PROVIDER,
65
- aiModel: DEFAULT_AI_MODEL,
66
- hasAiKey: false,
67
- },
68
- expires: 'never',
69
- }
70
- }
50
+ // buildRemoteSession 已抽出共享至 @/server/desktop-session-merge(A10):
51
+ // getCurrentSession 桌面回落需复用同一组装,且 lib/session 不得 import
52
+ // serverFns/**(循环依赖),组装点移至 server 域纯模块。
71
53
 
72
54
  export const getSessionFn = createServerFn({ method: 'GET' })
73
55
  .handler(async () => {
@@ -141,13 +123,18 @@ export const exchangeRemoteTokenFn = createServerFn({ method: 'POST' })
141
123
  email: profile.user.email,
142
124
  role: profile.user.role,
143
125
  })
144
- if (!signedToken) {
145
- console.warn('[exchangeRemoteTokenFn] 无法签发权益 token(缺少 ENTITLEMENTS_SIGNING_SECRET),降级 OSS')
146
- await writeRemoteSession(profile.user.id, planName, token)
147
- return { success: true, userId: profile.user.id, planName, degraded: true }
148
- }
149
126
  }
150
127
 
128
+ // session 块双路径无条件落盘(A10 修复,控制者核实的激活缺口):readRemoteAuth
129
+ // 以 data.session.userId 为登录态唯一依据,原先仅降级路径写 session——生产
130
+ // SaaS 恒发 signedToken,signedToken 路径云身份 100% 不激活、云态写入属主错
131
+ // 为 guest。writeRemoteSession / writeRemoteAuth 均 merge 语义({...prev,
132
+ // ...}),先 session 后 token 不互踩。
133
+ await writeRemoteSession(profile.user.id, planName, token)
134
+ if (!signedToken) {
135
+ console.warn('[exchangeRemoteTokenFn] 无法签发权益 token(缺少 ENTITLEMENTS_SIGNING_SECRET),降级 OSS')
136
+ return { success: true, userId: profile.user.id, planName, degraded: true }
137
+ }
151
138
  await writeRemoteAuth(signedToken, token)
152
139
  return { success: true, userId: profile.user.id, planName }
153
140
  })
@@ -159,3 +146,16 @@ export const logoutRemoteFn = createServerFn({ method: 'POST' }).validator(z.obj
159
146
  }
160
147
  throw new Error('logoutRemoteFn 仅桌面端可用')
161
148
  })
149
+
150
+ // 身份切换缓存三件套之「服务端模块缓存」(A11,设计 D1):desktop-state 身份切换
151
+ // 事件(guest↔cloud)到达 renderer 时调用——auth.config.ts 的 profileCache 是
152
+ // backend 进程内存缓存,renderer 无法直清,本 serverFn 是唯一载体。公开端点理由
153
+ // (serverfn-public-allowlist):调用时机是身份切换(登出语义延伸,会话可能已
154
+ // 失效),且只清进程内 Map、不暴露任何用户数据——与 logoutRemoteFn 同形态。
155
+ // 幂等无副作用(缓存 miss 自然回源),非桌面端调用同样安全(clear 无害)。
156
+ export const resetAuthProfileCacheFn = createServerFn({ method: 'POST' })
157
+ .validator(z.object({}))
158
+ .handler(async () => {
159
+ resetProfileCache()
160
+ return { success: true }
161
+ })
@@ -0,0 +1,168 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // 归属转移 serverFn(桌面本地优先 P2-A8a):renderer 在云登录成功回调链发起
6
+ // (cloudLogin(main) → exchangeRemoteTokenFn(A10) → 本 serverFn),localhost
7
+ // cookie 天然过鉴权(backend 进程无 cookie,不得由 bind-flow 直调——复审⑥ P1-8)。
8
+ // 校验链:① readRemoteAuth().userId 必须等于 data.cloudUserId(转移目标必须等于
9
+ // 已交换登录的云账号,防参数伪造;非 desktop / 无 remoteAuth 时 readRemoteAuth
10
+ // 恒 null → 拒绝);② 复核目标为空账号(isAccountEmpty,与
11
+ // /api/sync/account-status 端点同源);③ 调 transferOwnership 执行
12
+ // GUEST_USER_ID → cloudUserId 的单事务转移。outbox 语义由触发器自然产出
13
+ // (op='upsert' + base_version=0),本 serverFn 不触碰同步两表。
14
+ // 身份字段(A8b 裁定):name/avatarConfig 等可省略——云昵称/头像仅 SaaS 权威
15
+ // (remote-auth.json session 块不存 profile,renderer 拿不到),省略时 handler
16
+ // 以 remoteAuth.sessionToken 自取 SaaS profile 补齐;字段全传则用传入值(向后
17
+ // 兼容,A8a 老调用形态不变)。
18
+ // 同文件第二端点 probeCloudAccountStatusFn(claim 探测):renderer 在 exchange
19
+ // 激活后、bind:begin 前调用,代持云会话 token 探测目标账号是否空账号(云账号
20
+ // 资料与账号状态只有 SaaS 权威)——探测失败按非空处理(宁可不转不可错转)。
21
+ // 同文件第三端点 reverseTransferOwnershipFn(A9 登出反向转移):登出时把云身份
22
+ // 数据翻回 guest,详见其实现处注释。
23
+ import { z } from "zod"
24
+ import { createServerFn } from "@tanstack/react-start"
25
+ import { getPrisma } from "@allbluecn/database/get-prisma"
26
+ import { authContextMiddleware } from "@/server/middlewares/auth-context"
27
+ import { checkAuthRateLimit } from "@/server/middlewares/rate-limit"
28
+ import { readRemoteAuth, clearRemoteAuth } from "@/server/remote-auth"
29
+ import { GUEST_USER_ID, isDesktopServer } from "@/server/desktop-guest"
30
+ import { isAccountEmpty } from "@/server/sync/account-status"
31
+ import { reverseTransferOwnership, transferOwnership } from "@/server/sync/ownership-transfer"
32
+ import { fetchSaasProfile, DESKTOP_SAAS_URL } from "@/server/sync/saas-profile"
33
+
34
+ const transferInputSchema = z.object({
35
+ cloudUserId: z.string().min(1),
36
+ name: z.string().min(1).optional(),
37
+ email: z.string().email().nullish(),
38
+ avatarConfig: z.record(z.string(), z.unknown()).nullish(),
39
+ bgShape: z.string().nullish(),
40
+ bgColor: z.string().nullish(),
41
+ })
42
+
43
+ export const transferOwnershipFn = createServerFn({ method: "POST" })
44
+ .middleware([authContextMiddleware])
45
+ .validator(transferInputSchema)
46
+ .handler(async ({ data }) => {
47
+ // ① 转移目标必须等于已交换登录的云账号(readRemoteAuth 非 desktop 恒 null)
48
+ const remoteAuth = await readRemoteAuth()
49
+ if (!remoteAuth || remoteAuth.userId !== data.cloudUserId) {
50
+ throw new Error(
51
+ `[sync/transfer] 转移目标与已登录云账号不匹配(remoteAuth.userId=${remoteAuth?.userId ?? "null"})`,
52
+ )
53
+ }
54
+
55
+ // ② 复核目标为空账号(仅空账号才允许归属转移,一期唯一路径)
56
+ const prisma = await getPrisma()
57
+ if (!(await isAccountEmpty(prisma, data.cloudUserId))) {
58
+ throw new Error("[sync/transfer] 目标云账号非空账号,拒绝归属转移")
59
+ }
60
+
61
+ // ②b 身份字段补齐(A8b 裁定):name 省略 = renderer 未传身份字段(一期主
62
+ // 路径),以 remoteAuth.sessionToken 自取 SaaS profile;资料获取失败抛错走
63
+ // bind:fail——绝不以空/错资料落云身份行。字段全传则用传入值(向后兼容)。
64
+ let name = data.name
65
+ let email = data.email ?? null
66
+ let avatarConfig = (data.avatarConfig ?? null) as Record<string, unknown> | null
67
+ let bgShape = data.bgShape ?? null
68
+ let bgColor = data.bgColor ?? null
69
+ if (name === undefined) {
70
+ const profile = await fetchSaasProfile(remoteAuth.sessionToken)
71
+ name = profile.name?.trim() || data.cloudUserId
72
+ email = data.email ?? profile.email
73
+ avatarConfig = data.avatarConfig ?? profile.avatarConfig
74
+ bgShape = data.bgShape ?? profile.bgShape
75
+ bgColor = data.bgColor ?? profile.bgColor
76
+ }
77
+
78
+ // ③ 单事务转移 GUEST → cloudUserId(失败整体回滚,调用方走 bind:fail 恢复)
79
+ return transferOwnership(prisma, GUEST_USER_ID, {
80
+ id: data.cloudUserId,
81
+ name,
82
+ email,
83
+ avatarConfig,
84
+ bgShape,
85
+ bgColor,
86
+ })
87
+ })
88
+
89
+ // 登出反向转移(A9):renderer 用户菜单登出时发起,把云身份名下数据翻回 guest
90
+ // (数据保留本机且 guest 可访问)。校验链:① 桌面守卫(非 desktop 一律拒绝,同
91
+ // exchange/logoutRemote 口径);② readRemoteAuth().userId === data.cloudUserId
92
+ // (登出者必须是当前云身份本人;无 remoteAuth = 已登出/非云态 → 拒绝);③ 会话
93
+ // 身份 === data.cloudUserId(防「本地凭据会话与云身份并存」时误触发反向转移——
94
+ // authjs 会话有效时 session.user.id 是本地用户而非云 id);④ 单事务
95
+ // cloud → GUEST 反向转移(outbox/row_state 同事务清空防反向 op 上云)。
96
+ // 事务提交成功后立即清 remote-auth(幂等):反向转移一旦提交数据已 guest 属主,
97
+ // 云身份残留会让 fallback 会话显示「云态零数据」假丢数据窗口(A8b 发现 2 的镜像
98
+ // ——正向是转移成功 + 云身份未写,反向是转移成功 + 云身份未清)。renderer 侧的
99
+ // logoutRemoteFn 保留为登出编排第二步(幂等 re-clear,裁定顺序不变)。
100
+ export const reverseTransferOwnershipFn = createServerFn({ method: "POST" })
101
+ .middleware([authContextMiddleware])
102
+ .validator(z.object({ cloudUserId: z.string().min(1) }))
103
+ .handler(async ({ data, context }) => {
104
+ // ① 桌面守卫(远端部署下本端点可被任意会话调来清他人 remote-auth / 翻转数据)
105
+ if (!isDesktopServer()) {
106
+ throw new Error("reverseTransferOwnershipFn 仅桌面端可用")
107
+ }
108
+
109
+ // ② 登出者必须是当前云身份本人
110
+ const remoteAuth = await readRemoteAuth()
111
+ if (!remoteAuth || remoteAuth.userId !== data.cloudUserId) {
112
+ throw new Error(
113
+ `[sync/transfer] 登出目标与已激活云身份不匹配(remoteAuth.userId=${remoteAuth?.userId ?? "null"})`,
114
+ )
115
+ }
116
+
117
+ // ③ 会话身份必须是云身份本人(云态下 fallback 会话 user.id 即云 id)
118
+ if (context.user.id !== data.cloudUserId) {
119
+ throw new Error(
120
+ `[sync/transfer] 会话身份与登出目标不匹配(session.userId=${context.user.id})`,
121
+ )
122
+ }
123
+
124
+ // ④ 单事务反向转移 cloud → guest(失败整体回滚留在云态,renderer 解锁后可重试)
125
+ const prisma = await getPrisma()
126
+ return reverseTransferOwnership(prisma, data.cloudUserId, GUEST_USER_ID)
127
+ .then(async (result) => {
128
+ // 提交成功后清 remote-auth(幂等;clearRemoteAuth 内部吞错仅 warn,不反噬主流程)
129
+ await clearRemoteAuth()
130
+ return result
131
+ })
132
+ })
133
+
134
+ // claim 探测(A8b 编排时序第 3 步):exchange 激活后、bind:begin 前,renderer
135
+ // 经本端点代持云会话 token 探测目标云账号是否空账号(isAccountEmpty 口径在 SaaS
136
+ // 侧执行——账号状态只有 SaaS 权威,本地库看不到云端数据)。探测失败(SaaS 不可
137
+ // 达/非 200/形状漂移)一律按非空处理(宁可不转不可错转),unreachable 标记供
138
+ // renderer 区分提示;非空账号一期不支持转移。
139
+ export const probeCloudAccountStatusFn = createServerFn({ method: "POST" })
140
+ .middleware([authContextMiddleware])
141
+ .handler(async () => {
142
+ // 与 exchangeRemoteTokenFn 同口径:非桌面部署下任何客户端都能借本服务探测
143
+ // 任意 token 的账号状态(审计 2026-10-25 形态)
144
+ if (!isDesktopServer()) {
145
+ throw new Error("probeCloudAccountStatusFn 仅桌面端可用")
146
+ }
147
+ // 防把账号状态探测代理成对上游 SaaS 的暴力面
148
+ await checkAuthRateLimit("probeCloudAccountStatus", 20, 15 * 60 * 1000)
149
+
150
+ const remoteAuth = await readRemoteAuth()
151
+ if (!remoteAuth?.sessionToken) {
152
+ throw new Error("[sync/transfer] 无云会话(未交换登录),无法探测账号状态")
153
+ }
154
+
155
+ try {
156
+ const res = await fetch(`${DESKTOP_SAAS_URL}/api/sync/account-status`, {
157
+ headers: { Cookie: `auth.session-token=${remoteAuth.sessionToken}` },
158
+ credentials: "omit",
159
+ signal: AbortSignal.timeout(10_000),
160
+ })
161
+ if (!res.ok) return { empty: false, unreachable: true }
162
+ const json = (await res.json().catch(() => null)) as { empty?: unknown } | null
163
+ if (typeof json?.empty !== "boolean") return { empty: false, unreachable: true }
164
+ return json.empty ? { empty: true } : { empty: false }
165
+ } catch {
166
+ return { empty: false, unreachable: true }
167
+ }
168
+ })
@@ -24,6 +24,10 @@ export const SERVERFN_PUBLIC_ALLOWLIST = [
24
24
  'exchangeRemoteTokenFn',
25
25
  // 清除桌面端 remote-auth 持久化状态(登出语义,无会话也可调用)
26
26
  'logoutRemoteFn',
27
+ // 全量清空 profileCache 进程内缓存(A11 身份切换缓存三件套之一):调用时机是
28
+ // desktop-state 身份切换(登出语义延伸,会话可能已失效),只清进程内 Map
29
+ // 不暴露任何用户数据,与 logoutRemoteFn 同形态
30
+ 'resetAuthProfileCacheFn',
27
31
 
28
32
  // ── 登录/注册/初始化流程(鉴权前的入口)──
29
33
  // 登录页读取可用 OAuth provider 列表(未登录场景)