@allbluecn/web-app 0.23.1 → 0.23.3
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.
- package/package.json +3 -3
- package/src/components/desktop/__tests__/desktop-state-listener.test.tsx +183 -0
- package/src/components/desktop/__tests__/sync-indicator.test.tsx +130 -0
- package/src/components/desktop/cloud-login.tsx +108 -6
- package/src/components/desktop/desktop-state-listener.tsx +76 -0
- package/src/components/desktop/shell-widgets.tsx +12 -5
- package/src/components/desktop/sync-indicator.tsx +62 -13
- package/src/components/layout/sidebar-03/__tests__/user-menu-anonymous.test.tsx +4 -0
- package/src/components/layout/sidebar-03/user-menu.tsx +63 -0
- package/src/lib/__tests__/session.test.ts +95 -0
- package/src/lib/auth/auth.config.ts +13 -0
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-bridge-types.ts +36 -0
- package/src/lib/session.ts +15 -1
- package/src/paraglide/messages/_index.d.ts +11 -0
- package/src/paraglide/messages/_index.js +11 -0
- package/src/paraglide/messages/desktop_cloud_exchange_failed.d.ts +16 -0
- package/src/paraglide/messages/desktop_cloud_exchange_failed.js +29 -0
- package/src/paraglide/messages/desktop_state_cloud.d.ts +16 -0
- package/src/paraglide/messages/desktop_state_cloud.js +29 -0
- package/src/paraglide/messages/desktop_state_guest.d.ts +16 -0
- package/src/paraglide/messages/desktop_state_guest.js +29 -0
- package/src/paraglide/messages/desktop_state_transferring.d.ts +16 -0
- package/src/paraglide/messages/desktop_state_transferring.js +29 -0
- package/src/paraglide/messages/sync_bind_failed.d.ts +16 -0
- package/src/paraglide/messages/sync_bind_failed.js +29 -0
- package/src/paraglide/messages/sync_bind_in_progress.d.ts +16 -0
- package/src/paraglide/messages/sync_bind_in_progress.js +29 -0
- package/src/paraglide/messages/sync_bind_nonempty.d.ts +16 -0
- package/src/paraglide/messages/sync_bind_nonempty.js +29 -0
- package/src/paraglide/messages/sync_bind_probe_unreachable.d.ts +16 -0
- package/src/paraglide/messages/sync_bind_probe_unreachable.js +29 -0
- package/src/paraglide/messages/sync_bind_shell_unsupported.d.ts +16 -0
- package/src/paraglide/messages/sync_bind_shell_unsupported.js +29 -0
- package/src/paraglide/messages/sync_logout_failed.d.ts +16 -0
- package/src/paraglide/messages/sync_logout_failed.js +29 -0
- package/src/paraglide/messages/sync_logout_in_progress.d.ts +16 -0
- package/src/paraglide/messages/sync_logout_in_progress.js +29 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/api/sync/__tests__/account-status.test.ts +109 -0
- package/src/routes/api/sync/account-status.ts +39 -0
- package/src/server/__tests__/exchange-remote-token.test.ts +113 -0
- package/src/server/__tests__/remote-auth.test.ts +131 -1
- package/src/server/ai-gateway/__tests__/tools-authorization.test.ts +4 -2
- package/src/server/ai-gateway/tools/server.ts +7 -0
- package/src/server/desktop-bootstrap.ts +19 -3
- package/src/server/desktop-session-merge.ts +51 -0
- package/src/server/remote-auth.ts +61 -3
- package/src/server/serverFns/__tests__/knowledge-readable-kb.test.ts +21 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +46 -0
- package/src/server/serverFns/auth/session.ts +28 -28
- package/src/server/serverFns/harness/attach-to-kb.ts +3 -1
- package/src/server/serverFns/knowledge.ts +4 -1
- package/src/server/serverFns/sync/transfer.ts +168 -0
- package/src/server/serverFns/workspace.ts +15 -0
- package/src/server/serverfn-public-allowlist.ts +4 -0
- package/src/server/sync/__tests__/ownership-transfer.test.ts +896 -0
- package/src/server/sync/account-status.ts +36 -0
- package/src/server/sync/ownership-transfer.ts +291 -0
- package/src/server/sync/saas-profile.ts +70 -0
|
@@ -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
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
})
|
|
@@ -44,7 +44,7 @@ export const attachToKbFn = createServerFn({ method: "POST" })
|
|
|
44
44
|
const userId = context.user.id
|
|
45
45
|
|
|
46
46
|
const run = await assertRunOwnership(data.generationRunId, userId)
|
|
47
|
-
await assertKbOwnership(data.kbId, userId)
|
|
47
|
+
const kb = await assertKbOwnership(data.kbId, userId)
|
|
48
48
|
|
|
49
49
|
const text = extractText(run.result)
|
|
50
50
|
if (!text?.trim()) throw new Error("Generation run has no extractable text")
|
|
@@ -63,6 +63,8 @@ export const attachToKbFn = createServerFn({ method: "POST" })
|
|
|
63
63
|
properties: {},
|
|
64
64
|
content: { markdown: text },
|
|
65
65
|
createdBy: userId,
|
|
66
|
+
// 冗余同步 scope 列(P2 A7):kb.workspaceId 写时快照,null = stay local
|
|
67
|
+
syncWorkspaceId: kb.workspaceId,
|
|
66
68
|
},
|
|
67
69
|
select: { id: true },
|
|
68
70
|
})
|
|
@@ -219,7 +219,8 @@ export const createDocumentFn = createServerFn({ method: 'POST' })
|
|
|
219
219
|
await requirePermission(userId, 'knowledge', 'write')
|
|
220
220
|
const kb = await prisma.knowledgeBase.findFirst({
|
|
221
221
|
where: { id: data.kbId, ...READABLE_KB(context.user.id) },
|
|
222
|
-
|
|
222
|
+
// workspaceId:文档冗余同步 scope 列(syncWorkspaceId)的来源(P2 A7)
|
|
223
|
+
select: { id: true, workspaceId: true },
|
|
223
224
|
})
|
|
224
225
|
if (!kb) throw new Error('未找到或无权限')
|
|
225
226
|
// parentId 必须存在且属于同一 KB(审计 2026-10-25:防把文档挂到他人 KB 节点下)
|
|
@@ -247,6 +248,8 @@ export const createDocumentFn = createServerFn({ method: 'POST' })
|
|
|
247
248
|
sortOrder: nextSort,
|
|
248
249
|
properties: {},
|
|
249
250
|
createdBy: userId,
|
|
251
|
+
// 同步 scope 写时快照:null(个人库)= 一期不进同步面(stay local)
|
|
252
|
+
syncWorkspaceId: kb.workspaceId,
|
|
250
253
|
},
|
|
251
254
|
select: { id: true, title: true, type: true, parentId: true },
|
|
252
255
|
})
|
|
@@ -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
|
+
})
|
|
@@ -303,10 +303,25 @@ export const deleteWorkspaceFn = createServerFn({ method: "POST" })
|
|
|
303
303
|
where: { workspaceId: data.workspaceId },
|
|
304
304
|
});
|
|
305
305
|
} else {
|
|
306
|
+
// 冗余同步 scope 级联(P2 A7):kb 解绑工作区(workspaceId → null)时,
|
|
307
|
+
// 其文档的 syncWorkspaceId 必须同步置空,否则文档残留已消失的 scope
|
|
308
|
+
// (同步面按快照列归属,不回查 kb)。delete 分支文档随 FK 级联硬删,无需处理。
|
|
309
|
+
const unlinkedKbIds = (
|
|
310
|
+
await tx.knowledgeBase.findMany({
|
|
311
|
+
where: { workspaceId: data.workspaceId },
|
|
312
|
+
select: { id: true },
|
|
313
|
+
})
|
|
314
|
+
).map((kb) => kb.id);
|
|
306
315
|
await tx.knowledgeBase.updateMany({
|
|
307
316
|
where: { workspaceId: data.workspaceId },
|
|
308
317
|
data: { workspaceId: null },
|
|
309
318
|
});
|
|
319
|
+
if (unlinkedKbIds.length > 0) {
|
|
320
|
+
await tx.knowledgeDocument.updateMany({
|
|
321
|
+
where: { kbId: { in: unlinkedKbIds } },
|
|
322
|
+
data: { syncWorkspaceId: null },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
310
325
|
}
|
|
311
326
|
|
|
312
327
|
// 清理无 onDelete 级联的关联记录,避免外键约束阻止删除
|
|
@@ -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 列表(未登录场景)
|