@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
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 空账号探测(桌面本地优先 P2,A8a 从 /api/sync/account-status 路由抽取的
|
|
6
|
+
// 服务端函数形态):绑定云账号前核对目标账号是否「空账号」——仅空账号才走
|
|
7
|
+
// 归属转移(一期唯一路径)。
|
|
8
|
+
// 空账号口径(复审⑥ P1-9 扩容):当前用户六表 count 全 0——stories/bugs/testCases
|
|
9
|
+
// /projects 之外计入 knowledgeBases/knowledgeDocuments,防仅有知识库数据的云账号
|
|
10
|
+
// 被误判空而清掉知识库。属主字段:story/bug/testCase → createdById、project →
|
|
11
|
+
// ownerId、knowledgeBase → userId、knowledgeDocument → createdBy(schema 实查)。
|
|
12
|
+
import type { PrismaClient } from "@allbluecn/database"
|
|
13
|
+
|
|
14
|
+
export async function isAccountEmpty(
|
|
15
|
+
prisma: PrismaClient,
|
|
16
|
+
userId: string,
|
|
17
|
+
): Promise<boolean> {
|
|
18
|
+
const [stories, bugs, testCases, projects, knowledgeBases, knowledgeDocuments] =
|
|
19
|
+
await Promise.all([
|
|
20
|
+
prisma.story.count({ where: { createdById: userId } }),
|
|
21
|
+
prisma.bug.count({ where: { createdById: userId } }),
|
|
22
|
+
prisma.testCase.count({ where: { createdById: userId } }),
|
|
23
|
+
prisma.project.count({ where: { ownerId: userId } }),
|
|
24
|
+
prisma.knowledgeBase.count({ where: { userId } }),
|
|
25
|
+
prisma.knowledgeDocument.count({ where: { createdBy: userId } }),
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
stories === 0 &&
|
|
30
|
+
bugs === 0 &&
|
|
31
|
+
testCases === 0 &&
|
|
32
|
+
projects === 0 &&
|
|
33
|
+
knowledgeBases === 0 &&
|
|
34
|
+
knowledgeDocuments === 0
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 归属转移核心(正向,桌面本地优先 P2-A8a):桌面端绑定「空云账号」时,把本地
|
|
6
|
+
// guest(local-guest)名下全部业务数据一次性翻转到云账号名下,随后删除 guest
|
|
7
|
+
// 影子行——调用方(A8b bind-flow 编排 + transferOwnershipFn)保证已停写快照,
|
|
8
|
+
// 本函数纯 DB:属主翻转 + 云身份行 + 删 guest 行在**单事务**内原子完成,失败
|
|
9
|
+
// 即整体回滚(调用方按 bind:fail 路径恢复快照)。
|
|
10
|
+
//
|
|
11
|
+
// 属主列全集的派生口径(A8 预裁定,A5 实证):schema **全量 User-relation FK
|
|
12
|
+
// 标量列**(遍历 @relation 到 User 的关系取其 fields 标量列,共 79 处;含
|
|
13
|
+
// ownerId/userId/createdById/createdBy/assignee 类),排除 users 表自身与
|
|
14
|
+
// TRANSFER_EXCLUDED(配置类不转移,规格 Q1①)。为什么必须全量:转移末尾删除
|
|
15
|
+
// guest users 行,任何漏改的 User-FK 列要么触发 onDelete: Cascade **级联删掉
|
|
16
|
+
// 业务数据**,要么留下指向已删用户的孤儿行 / Restrict 直接炸删除——全量翻转是
|
|
17
|
+
// 规格 Q1②「防孤儿行」的唯一安全读法。
|
|
18
|
+
//
|
|
19
|
+
// ⚠ 不触碰 sync_outbox/sync_row_state(简报 ④,审查重点 2):属主 UPDATE 走
|
|
20
|
+
// 常规 Prisma 写入,已装触发的表自然产出 op='upsert' + base_version=0 的整行
|
|
21
|
+
// change log,推送拓扑 A7 已修为 upsert 正向——转移面因此天然含全部注册表,
|
|
22
|
+
// 本函数手碰同步两表反而会污染版本语义。测试 ④ 以「零触达」断言钉死此边界。
|
|
23
|
+
//
|
|
24
|
+
// 幂等语义:重复调用不抛错——UPDATE where fromUserId 匹配 0 行(已翻转)、
|
|
25
|
+
// 云身份行 upsert update 分支留空(no-op)、guest 行已删(deleteMany 计 0
|
|
26
|
+
// no-op;delete 会抛 P2025 破坏二跑,故删行必须走 deleteMany)。
|
|
27
|
+
// 清单对齐哨兵:__tests__/ownership-transfer.test.ts 内固定清单与本常量逐项
|
|
28
|
+
// 相等——schema 增删 User-relation FK 时该测试红,须同步重扫本清单。
|
|
29
|
+
|
|
30
|
+
import { randomUUID } from "node:crypto"
|
|
31
|
+
import type { PrismaClient } from "@allbluecn/database"
|
|
32
|
+
import { ensureLocalShadowUserWith } from "@/server/desktop-bootstrap"
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 配置类排除清单(规格 Q1①「配置类不转移」):
|
|
36
|
+
* - `AiProviderConfig.userId`:用户 AI 配置属配置数据而非业务数据,不随归属
|
|
37
|
+
* 翻转(转移后 guest 的 AI 配置随 guest 行级联删除——删前 guest 已不再使用)。
|
|
38
|
+
* - DesktopSetting 无 user 列(桌面设置按设备存储),不在口径内。
|
|
39
|
+
*/
|
|
40
|
+
export const TRANSFER_EXCLUDED: readonly string[] = ["AiProviderConfig.userId"]
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 属主列全集:schema 全量 User-relation FK 标量列(79 处)减 TRANSFER_EXCLUDED
|
|
44
|
+
* = 78 处「表.列」(table 为 Prisma 模型名,delegate 取 lowerFirst)。
|
|
45
|
+
* 派生方法(复核命令):
|
|
46
|
+
* grep -nE "^\s*\w+\s+User\??\s+@relation" packages/database/schema.prisma
|
|
47
|
+
* 取各行的 fields: [...] 标量列;与 User 模型反向关系数组(79 项)互为镜像。
|
|
48
|
+
* 顺序按 schema 声明序排列(对齐测试按排序集合比较,顺序不承载语义)。
|
|
49
|
+
*/
|
|
50
|
+
export const OWNERSHIP_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
|
|
51
|
+
{ table: "Account", column: "userId" },
|
|
52
|
+
{ table: "Project", column: "ownerId" },
|
|
53
|
+
{ table: "ProjectMember", column: "userId" },
|
|
54
|
+
{ table: "Workspace", column: "ownerId" },
|
|
55
|
+
{ table: "WorkspaceMember", column: "userId" },
|
|
56
|
+
{ table: "TeamMembership", column: "ownerId" },
|
|
57
|
+
{ table: "TeamMembership", column: "memberId" },
|
|
58
|
+
{ table: "TeamInvitation", column: "ownerId" },
|
|
59
|
+
{ table: "TeamJoinRequest", column: "ownerId" },
|
|
60
|
+
{ table: "TeamJoinRequest", column: "userId" },
|
|
61
|
+
{ table: "ProjectActivity", column: "userId" },
|
|
62
|
+
{ table: "ChatThread", column: "userId" },
|
|
63
|
+
{ table: "HarnessPlugin", column: "userId" },
|
|
64
|
+
{ table: "BusinessAgentBinding", column: "userId" },
|
|
65
|
+
{ table: "LoginLog", column: "userId" },
|
|
66
|
+
{ table: "InvitationUse", column: "userId" },
|
|
67
|
+
{ table: "PasswordResetToken", column: "userId" },
|
|
68
|
+
{ table: "EmailVerificationToken", column: "userId" },
|
|
69
|
+
{ table: "EmailPreference", column: "userId" },
|
|
70
|
+
{ table: "KnowledgeBase", column: "userId" },
|
|
71
|
+
{ table: "KnowledgeDocument", column: "createdBy" },
|
|
72
|
+
{ table: "Design", column: "userId" },
|
|
73
|
+
{ table: "AgentConversation", column: "userId" },
|
|
74
|
+
{ table: "DesignVersion", column: "userId" },
|
|
75
|
+
{ table: "Review", column: "reviewerId" },
|
|
76
|
+
{ table: "ReviewComment", column: "userId" },
|
|
77
|
+
{ table: "Comment", column: "userId" },
|
|
78
|
+
{ table: "CommentIgnore", column: "userId" },
|
|
79
|
+
{ table: "AnyHubApp", column: "userId" },
|
|
80
|
+
{ table: "AnyHubMarketApp", column: "userId" },
|
|
81
|
+
{ table: "AnyHubRecommendation", column: "reviewedBy" },
|
|
82
|
+
{ table: "AnyHubRecommender", column: "userId" },
|
|
83
|
+
{ table: "LegalRegulation", column: "userId" },
|
|
84
|
+
{ table: "IndustryCompliance", column: "userId" },
|
|
85
|
+
{ table: "DictionaryTerm", column: "userId" },
|
|
86
|
+
{ table: "WebsiteBookmark", column: "userId" },
|
|
87
|
+
{ table: "BookmarkCategory", column: "userId" },
|
|
88
|
+
{ table: "KnowledgeShareRecord", column: "userId" },
|
|
89
|
+
{ table: "ArticleFavorite", column: "userId" },
|
|
90
|
+
{ table: "Story", column: "assigneeId" },
|
|
91
|
+
{ table: "Story", column: "createdById" },
|
|
92
|
+
{ table: "Story", column: "updatedById" },
|
|
93
|
+
{ table: "Label", column: "createdById" },
|
|
94
|
+
{ table: "StoryActivity", column: "userId" },
|
|
95
|
+
{ table: "StoryComment", column: "userId" },
|
|
96
|
+
{ table: "StoryReaction", column: "userId" },
|
|
97
|
+
{ table: "CommentReaction", column: "userId" },
|
|
98
|
+
{ table: "WorkspaceQuickDoc", column: "createdBy" },
|
|
99
|
+
{ table: "StoryFilterView", column: "userId" },
|
|
100
|
+
{ table: "Subscription", column: "userId" },
|
|
101
|
+
{ table: "AiUsageLog", column: "userId" },
|
|
102
|
+
{ table: "LicenseKey", column: "issuedById" },
|
|
103
|
+
{ table: "SsoMapping", column: "userId" },
|
|
104
|
+
{ table: "RoleAssignment", column: "userId" },
|
|
105
|
+
{ table: "UserAgent", column: "userId" },
|
|
106
|
+
{ table: "UserSkill", column: "userId" },
|
|
107
|
+
{ table: "Notification", column: "userId" },
|
|
108
|
+
{ table: "NotificationPreference", column: "userId" },
|
|
109
|
+
{ table: "Iteration", column: "createdById" },
|
|
110
|
+
{ table: "CodeSnippet", column: "userId" },
|
|
111
|
+
{ table: "HarnessWorkspace", column: "ownerId" },
|
|
112
|
+
{ table: "Blob", column: "userId" },
|
|
113
|
+
{ table: "Bug", column: "assigneeId" },
|
|
114
|
+
{ table: "Bug", column: "createdById" },
|
|
115
|
+
{ table: "Bug", column: "updatedById" },
|
|
116
|
+
{ table: "BugActivity", column: "userId" },
|
|
117
|
+
{ table: "BugComment", column: "userId" },
|
|
118
|
+
{ table: "BugCommentReaction", column: "userId" },
|
|
119
|
+
{ table: "ProjectAccessToken", column: "createdById" },
|
|
120
|
+
{ table: "BugReproRun", column: "createdById" },
|
|
121
|
+
{ table: "BugFilterView", column: "userId" },
|
|
122
|
+
{ table: "TestCase", column: "assigneeId" },
|
|
123
|
+
{ table: "TestCase", column: "createdById" },
|
|
124
|
+
{ table: "TestCaseFilterView", column: "userId" },
|
|
125
|
+
{ table: "TestGenerationRun", column: "createdById" },
|
|
126
|
+
{ table: "TestRunCase", column: "executedById" },
|
|
127
|
+
{ table: "TestAutomationArtifact", column: "createdById" },
|
|
128
|
+
{ table: "AiTask", column: "createdById" },
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
/** 转移目标云身份(昵称/头像由调用方传入;User 表无独立 name 列,昵称落 username) */
|
|
132
|
+
export interface TransferToUser {
|
|
133
|
+
id: string
|
|
134
|
+
name: string
|
|
135
|
+
email?: string | null
|
|
136
|
+
avatarConfig?: Record<string, unknown> | null
|
|
137
|
+
bgShape?: string | null
|
|
138
|
+
bgColor?: string | null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type OwnerDelegate = {
|
|
142
|
+
updateMany(args: {
|
|
143
|
+
where: Record<string, string>
|
|
144
|
+
data: Record<string, string>
|
|
145
|
+
}): Promise<{ count: number }>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Prisma 模型名 → client delegate 键(AiProviderConfig → aiProviderConfig) */
|
|
149
|
+
function delegateKey(model: string): string {
|
|
150
|
+
return model.charAt(0).toLowerCase() + model.slice(1)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 归属转移(单事务,纯 DB;停写快照由调用方保证)。
|
|
155
|
+
*
|
|
156
|
+
* 返回 `tables`:转移的属主列数(表.列对,即 updateMany 次数,恒等于
|
|
157
|
+
* OWNERSHIP_COLUMNS 长度);`rows`:Σ updateMany.count(实际翻转的属主行数,
|
|
158
|
+
* 幂等二跑为 0)。
|
|
159
|
+
*
|
|
160
|
+
* 步序(事务内):① upsert 云身份行(必须先于属主翻转——后续 FK 写入的合法
|
|
161
|
+
* 目标;update 分支留空保证幂等不覆盖既有资料,password 随机占位与
|
|
162
|
+
* desktop-bootstrap 同惯例:本地行凭据登录必失败);② 全量属主列 UPDATE;
|
|
163
|
+
* ③ 删除 guest 行(Restrict 列已全部翻转,删除不被 FK 阻塞)。
|
|
164
|
+
*/
|
|
165
|
+
export async function transferOwnership(
|
|
166
|
+
prisma: PrismaClient,
|
|
167
|
+
fromUserId: string,
|
|
168
|
+
toUser: TransferToUser,
|
|
169
|
+
): Promise<{ tables: number; rows: number }> {
|
|
170
|
+
if (fromUserId === toUser.id) {
|
|
171
|
+
throw new Error("[ownership-transfer] 转移源与目标相同,拒绝执行")
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const toUserId = toUser.id
|
|
175
|
+
const columns = OWNERSHIP_COLUMNS
|
|
176
|
+
|
|
177
|
+
return prisma.$transaction(async (tx) => {
|
|
178
|
+
// ① 云身份行(先于属主翻转:FK 目标必须先存在)
|
|
179
|
+
await tx.user.upsert({
|
|
180
|
+
where: { id: toUserId },
|
|
181
|
+
create: {
|
|
182
|
+
id: toUserId,
|
|
183
|
+
email: toUser.email?.trim() || `${toUserId}@cloud.local`,
|
|
184
|
+
username: toUser.name?.trim() || toUserId,
|
|
185
|
+
password: randomUUID(),
|
|
186
|
+
avatarConfig: (toUser.avatarConfig ?? undefined) as object | undefined,
|
|
187
|
+
bgShape: toUser.bgShape ?? undefined,
|
|
188
|
+
bgColor: toUser.bgColor ?? undefined,
|
|
189
|
+
status: "ACTIVE",
|
|
190
|
+
},
|
|
191
|
+
// update 留空(同 desktop-bootstrap 惯例):云身份行已存在时 upsert no-op,
|
|
192
|
+
// 不覆盖用户后续改名/改头像(幂等)
|
|
193
|
+
update: {},
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ② 全量属主列翻转(清单纯 server 侧数据;配置类 TRANSFER_EXCLUDED 不在内)
|
|
197
|
+
let rows = 0
|
|
198
|
+
const delegates = tx as unknown as Record<string, OwnerDelegate | undefined>
|
|
199
|
+
for (const { table, column } of columns) {
|
|
200
|
+
const delegate = delegates[delegateKey(table)]
|
|
201
|
+
if (!delegate?.updateMany) {
|
|
202
|
+
// 清单与 PrismaClient 漂移(模型改名/删除):立刻失败回滚,绝不带病翻转
|
|
203
|
+
throw new Error(
|
|
204
|
+
`[ownership-transfer] 属主清单与 PrismaClient 不符:缺 ${table} delegate(schema 与客户端漂移?)`,
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
const result = await delegate.updateMany({
|
|
208
|
+
where: { [column]: fromUserId },
|
|
209
|
+
data: { [column]: toUserId },
|
|
210
|
+
})
|
|
211
|
+
rows += result.count
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ③ 删除 guest 行(翻转后无任何 FK 指向它:Cascade 无从级联、Restrict 不阻塞)。
|
|
215
|
+
// 必须用 deleteMany 而非 delete:delete 对已删行抛 P2025 并回滚整个二跑事务
|
|
216
|
+
// (幂等二跑变确定性失败,renderer 超时重试即误入 bind:fail)——deleteMany
|
|
217
|
+
// count=0 即 no-op,幂等三要素才全部为真(审查轮 1 修复)。
|
|
218
|
+
await tx.user.deleteMany({ where: { id: fromUserId } })
|
|
219
|
+
|
|
220
|
+
return { tables: columns.length, rows }
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 归属反向转移(桌面本地优先 P2-A9,登出镜像):云账号登出时把云身份名下全部
|
|
226
|
+
* 业务数据一次性翻回 guest(local-guest)名下,随后删除云身份行、重建 guest
|
|
227
|
+
* 影子行——数据保留在本机且 guest 会话立即可访问。与正向 transferOwnership
|
|
228
|
+
* 同构(同属主清单方向翻转、单事务、deleteMany 幂等),两处正向没有的差异:
|
|
229
|
+
*
|
|
230
|
+
* ① guest 影子行**重建**而非删除:正向末尾删了 guest 行(或首启尚未 seed),
|
|
231
|
+
* 反向翻转的 FK 目标必须先存在——事务内先调 ensureLocalShadowUserWith(tx)
|
|
232
|
+
* 重建(抽自 desktop-bootstrap,原无参包装直用模块级 prisma 单例会绕过 tx)。
|
|
233
|
+
* ② sync_outbox / sync_row_state **同事务清空**(正向零触达——正向的 outbox 语义
|
|
234
|
+
* 由触发器自然产出;反向必须清:属主翻转触发的 upsert ops 若留在 outbox,登出
|
|
235
|
+
* 后引擎一旦带云会话 tick 就会把「cloud→guest 属主翻转」推上云,污染云端数据
|
|
236
|
+
* ——审查重点「防反向 op 推上云」)。两表是 sync-core 客户端原生 SQL 表(无
|
|
237
|
+
* Prisma delegate),用 $executeRaw 静态 SQL(表名写死非用户输入,无注入面),
|
|
238
|
+
* 且与属主 UPDATE 同一 $transaction 闭包(原子化,无 tick 窗口);行状态重建
|
|
239
|
+
* 由下次 pull bootstrap 回填。
|
|
240
|
+
* ③ 云身份行删除后不做快照/恢复编排:单事务自身原子,失败整体回滚即留在云态,
|
|
241
|
+
* 调用方(reverseTransferOwnershipFn + renderer 登出流程)解锁后可重试。
|
|
242
|
+
*
|
|
243
|
+
* 幂等语义与正向一致:二跑 UPDATE 匹配 0 行、guest 行 upsert update 留空、云身份
|
|
244
|
+
* 行 deleteMany 计 0 no-op、raw 清表照常(DELETE 全表天然幂等)。
|
|
245
|
+
*/
|
|
246
|
+
export async function reverseTransferOwnership(
|
|
247
|
+
prisma: PrismaClient,
|
|
248
|
+
fromUserId: string,
|
|
249
|
+
toUserId: string,
|
|
250
|
+
): Promise<{ tables: number; rows: number }> {
|
|
251
|
+
if (fromUserId === toUserId) {
|
|
252
|
+
throw new Error("[ownership-transfer] 转移源与目标相同,拒绝执行")
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const columns = OWNERSHIP_COLUMNS
|
|
256
|
+
|
|
257
|
+
return prisma.$transaction(async (tx) => {
|
|
258
|
+
// ① guest 影子行重建(先于属主翻转:FK 目标必须先存在;update 留空幂等,
|
|
259
|
+
// 不覆盖既有影子行资料)
|
|
260
|
+
await ensureLocalShadowUserWith(tx)
|
|
261
|
+
|
|
262
|
+
// ② 全量属主列翻转 cloud → guest(与正向同清单反向;TRANSFER_EXCLUDED 同样不在内)
|
|
263
|
+
let rows = 0
|
|
264
|
+
const delegates = tx as unknown as Record<string, OwnerDelegate | undefined>
|
|
265
|
+
for (const { table, column } of columns) {
|
|
266
|
+
const delegate = delegates[delegateKey(table)]
|
|
267
|
+
if (!delegate?.updateMany) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[ownership-transfer] 属主清单与 PrismaClient 不符:缺 ${table} delegate(schema 与客户端漂移?)`,
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
const result = await delegate.updateMany({
|
|
273
|
+
where: { [column]: fromUserId },
|
|
274
|
+
data: { [column]: toUserId },
|
|
275
|
+
})
|
|
276
|
+
rows += result.count
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ③ 删除云身份行(翻转后无 FK 指向它;deleteMany 幂等——二跑已删计 0)
|
|
280
|
+
await tx.user.deleteMany({ where: { id: fromUserId } })
|
|
281
|
+
|
|
282
|
+
// ④ 同事务清空同步两表(防反向 op 推上云污染;表名静态写死无注入面)。
|
|
283
|
+
// 放在翻转之后:清空前的任何窗口内 outbox ops 都随事务回滚/覆盖,清空后
|
|
284
|
+
// 引擎 tick 无 op 可推。$executeRaw 仅经 tx(测试以「外层无此方法」钉死同
|
|
285
|
+
// 闭包语义)。
|
|
286
|
+
await tx.$executeRaw`DELETE FROM sync_outbox`
|
|
287
|
+
await tx.$executeRaw`DELETE FROM sync_row_state`
|
|
288
|
+
|
|
289
|
+
return { tables: columns.length, rows }
|
|
290
|
+
})
|
|
291
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// SaaS 云账号资料自取(桌面本地优先 P2-A8b):归属转移的云身份行需要昵称/头像
|
|
6
|
+
// 等资料,而 remote-auth.json 的 session 块只存 userId/planName/sessionToken
|
|
7
|
+
// (A10 契约),renderer 侧拿不到 profile——A8b 裁定:transferOwnershipFn 允许
|
|
8
|
+
// 省略身份字段,省略时 handler 以 remoteAuth.sessionToken 向 SaaS exchange 端点
|
|
9
|
+
// 自取 profile 补齐(字段全传则用传入值,入参向后兼容)。
|
|
10
|
+
// 独立模块原因:serverFn 文件禁止 createServerFn 之外的值导出(purity 口径①),
|
|
11
|
+
// serverFns/auth/session.ts 的 saasProfileSchema 无法复用/导出——此处按 transfer
|
|
12
|
+
// 需要的字段子集重建 schema(zod 精确校验,形状漂移即拒绝)。
|
|
13
|
+
// fetch 形态照 exchangeRemoteTokenFn 既有先例(Cookie: auth.session-token=… +
|
|
14
|
+
// credentials: omit + 10s 超时;SAAS_URL 非用户可控,SSRF 守卫不适用)。
|
|
15
|
+
import { z } from "zod"
|
|
16
|
+
|
|
17
|
+
export const DESKTOP_SAAS_URL = process.env.DESKTOP_SAAS_URL || "https://allblue.litsex.cn"
|
|
18
|
+
|
|
19
|
+
const saasProfileSchema = z.object({
|
|
20
|
+
user: z.object({
|
|
21
|
+
id: z.string().min(1),
|
|
22
|
+
name: z.string().nullable().optional(),
|
|
23
|
+
email: z.string().nullable().optional(),
|
|
24
|
+
// 头像字段可选下发(与 serverFns/auth/session.ts 的 saasProfileSchema 同形)
|
|
25
|
+
avatarConfig: z.record(z.string(), z.number()).optional(),
|
|
26
|
+
bgShape: z.string().optional(),
|
|
27
|
+
bgColor: z.string().optional(),
|
|
28
|
+
}),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** 转移云身份行所需的资料子集(缺省字段由调用方兜底) */
|
|
32
|
+
export interface SaasProfileFields {
|
|
33
|
+
name: string | null
|
|
34
|
+
email: string | null
|
|
35
|
+
avatarConfig: Record<string, number> | null
|
|
36
|
+
bgShape: string | null
|
|
37
|
+
bgColor: string | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 以云会话 token 向 SaaS 换取账号资料(归属转移云身份行用)。
|
|
42
|
+
* 失败(网络/非 200/形状漂移)抛错——调用方(transferOwnershipFn)让 bind:fail
|
|
43
|
+
* 路径接管,绝不以空/错资料落云身份行。
|
|
44
|
+
*/
|
|
45
|
+
export async function fetchSaasProfile(sessionToken: string | undefined): Promise<SaasProfileFields> {
|
|
46
|
+
if (!sessionToken) {
|
|
47
|
+
throw new Error("[sync/transfer] 无云会话 token,无法自取云账号资料")
|
|
48
|
+
}
|
|
49
|
+
const res = await fetch(`${DESKTOP_SAAS_URL}/api/auth/desktop-callback?exchange=true`, {
|
|
50
|
+
headers: { Cookie: `auth.session-token=${sessionToken}` },
|
|
51
|
+
credentials: "omit",
|
|
52
|
+
signal: AbortSignal.timeout(10_000),
|
|
53
|
+
})
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new Error(`[sync/transfer] SaaS 云账号资料获取失败: ${res.status}`)
|
|
56
|
+
}
|
|
57
|
+
let profile: z.infer<typeof saasProfileSchema>
|
|
58
|
+
try {
|
|
59
|
+
profile = saasProfileSchema.parse(await res.json())
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error("[sync/transfer] SaaS 云账号资料响应格式异常")
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
name: profile.user.name ?? null,
|
|
65
|
+
email: profile.user.email ?? null,
|
|
66
|
+
avatarConfig: profile.user.avatarConfig ?? null,
|
|
67
|
+
bgShape: profile.user.bgShape ?? null,
|
|
68
|
+
bgColor: profile.user.bgColor ?? null,
|
|
69
|
+
}
|
|
70
|
+
}
|