@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,113 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// exchangeRemoteTokenFn 双路径落盘测试(A10 修复,控制者核实缺口):
|
|
6
|
+
// session 块(remote-auth.json 的 session.userId/planName,readRemoteAuth 登录态
|
|
7
|
+
// 唯一依据)必须两条成功路径都写——原先仅降级路径写,生产 SaaS 恒发 signedToken
|
|
8
|
+
// → signedToken 路径云身份 100% 不激活 + 云态写入属主错为 guest。
|
|
9
|
+
// mock 惯例照 desktop-bootstrap.test.ts(csv-export 样板):createServerFn 捕获器
|
|
10
|
+
// 直调 handler;SaaS fetch 用 vi.stubGlobal 拦截,remote-auth/entitlements-token
|
|
11
|
+
// /rate-limit 全 mock(不真写盘、不真限流)。
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
|
|
14
|
+
const H = vi.hoisted(() => ({
|
|
15
|
+
writeRemoteAuth: vi.fn(),
|
|
16
|
+
writeRemoteSession: vi.fn(),
|
|
17
|
+
generateEntitlementsToken: vi.fn(),
|
|
18
|
+
checkAuthRateLimit: vi.fn(),
|
|
19
|
+
fetch: vi.fn(),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
vi.mock('@tanstack/react-start', () => {
|
|
23
|
+
// 每个 serverFn 实例独立 capture(session.ts 有三个 serverFn,防串号)
|
|
24
|
+
const createServerFn = () => {
|
|
25
|
+
const capture: { validator?: unknown; handler?: unknown } = {}
|
|
26
|
+
const api = {
|
|
27
|
+
validator: (v: unknown) => {
|
|
28
|
+
capture.validator = v
|
|
29
|
+
return api
|
|
30
|
+
},
|
|
31
|
+
middleware: () => api,
|
|
32
|
+
handler: (h: unknown) => {
|
|
33
|
+
capture.handler = h
|
|
34
|
+
return async (args?: { data?: unknown }) => {
|
|
35
|
+
const v = capture.validator as { parse?: (d: unknown) => unknown } | undefined
|
|
36
|
+
const data = v && typeof v.parse === 'function' ? v.parse(args?.data) : args?.data
|
|
37
|
+
return (capture.handler as (p: { data?: unknown }) => unknown)({ data })
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
return api
|
|
42
|
+
}
|
|
43
|
+
return { createServerFn }
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
vi.mock('@tanstack/react-start/server', () => ({ getRequest: () => ({}) }))
|
|
47
|
+
|
|
48
|
+
vi.mock('@/lib/session', () => ({ getCurrentSession: vi.fn(async () => null) }))
|
|
49
|
+
|
|
50
|
+
vi.mock('@/server/desktop-bootstrap', () => ({ ensureLocalShadowUser: vi.fn() }))
|
|
51
|
+
|
|
52
|
+
vi.mock('@/server/remote-auth', () => ({
|
|
53
|
+
readRemoteAuth: vi.fn(async () => null),
|
|
54
|
+
writeRemoteAuth: H.writeRemoteAuth,
|
|
55
|
+
writeRemoteSession: H.writeRemoteSession,
|
|
56
|
+
clearRemoteAuth: vi.fn(),
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
vi.mock('@/server/entitlements-token', () => ({
|
|
60
|
+
generateEntitlementsToken: H.generateEntitlementsToken,
|
|
61
|
+
}))
|
|
62
|
+
|
|
63
|
+
vi.mock('@/server/middlewares/rate-limit', () => ({ checkAuthRateLimit: H.checkAuthRateLimit }))
|
|
64
|
+
|
|
65
|
+
import { exchangeRemoteTokenFn } from '../serverFns/auth/session'
|
|
66
|
+
|
|
67
|
+
describe('exchangeRemoteTokenFn 双路径落盘(A10 修复)', () => {
|
|
68
|
+
const saved = process.env.DEPLOY_TARGET
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.resetAllMocks()
|
|
72
|
+
vi.stubGlobal('fetch', H.fetch)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.unstubAllEnvs()
|
|
77
|
+
vi.unstubAllGlobals()
|
|
78
|
+
if (saved === undefined) delete process.env.DEPLOY_TARGET
|
|
79
|
+
else process.env.DEPLOY_TARGET = saved
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('signedToken 路径(生产 SaaS 恒发):session 块 + token 双写', async () => {
|
|
83
|
+
process.env.DEPLOY_TARGET = 'desktop'
|
|
84
|
+
H.fetch.mockResolvedValue({
|
|
85
|
+
ok: true,
|
|
86
|
+
json: async () => ({
|
|
87
|
+
user: { id: 'cloud-1', name: 'Cloud 用户', email: 'cloud@example.com' },
|
|
88
|
+
planName: 'pro',
|
|
89
|
+
features: { sync: true },
|
|
90
|
+
signedToken: 'signed-abc',
|
|
91
|
+
}),
|
|
92
|
+
})
|
|
93
|
+
const res = await exchangeRemoteTokenFn({ data: { token: 'tok-123' } })
|
|
94
|
+
expect(res).toEqual({ success: true, userId: 'cloud-1', planName: 'pro' })
|
|
95
|
+
// 修复断言:session 块必须落盘(缺它 readRemoteAuth 恒 null → 云身份不激活)
|
|
96
|
+
expect(H.writeRemoteSession).toHaveBeenCalledWith('cloud-1', 'pro', 'tok-123')
|
|
97
|
+
expect(H.writeRemoteAuth).toHaveBeenCalledWith('signed-abc', 'tok-123')
|
|
98
|
+
expect(H.generateEntitlementsToken).not.toHaveBeenCalled()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('degraded 路径(本地无签名 secret):仅写 session 块,不写 writeRemoteAuth', async () => {
|
|
102
|
+
process.env.DEPLOY_TARGET = 'desktop'
|
|
103
|
+
H.fetch.mockResolvedValue({
|
|
104
|
+
ok: true,
|
|
105
|
+
json: async () => ({ user: { id: 'cloud-2' }, planName: 'free' }),
|
|
106
|
+
})
|
|
107
|
+
H.generateEntitlementsToken.mockResolvedValue(null)
|
|
108
|
+
const res = await exchangeRemoteTokenFn({ data: { token: 'tok-456' } })
|
|
109
|
+
expect(res).toEqual({ success: true, userId: 'cloud-2', planName: 'free', degraded: true })
|
|
110
|
+
expect(H.writeRemoteSession).toHaveBeenCalledWith('cloud-2', 'free', 'tok-456')
|
|
111
|
+
expect(H.writeRemoteAuth).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
|
|
2
2
|
import { promises as fs } from "node:fs"
|
|
3
3
|
import os from "node:os"
|
|
4
4
|
import path from "node:path"
|
|
@@ -70,3 +70,133 @@ describe("remote-auth(Electron fs 持久层)", () => {
|
|
|
70
70
|
expect(await readRemoteAuth()).toBeNull()
|
|
71
71
|
})
|
|
72
72
|
})
|
|
73
|
+
|
|
74
|
+
// ---------- A12 desktop codec 接缝(safeStorage 透明加解密) ----------
|
|
75
|
+
|
|
76
|
+
// stub codec(Pro 域 entry.cjs/main.ts 注入的 globalThis.__allblueDesktopCodec
|
|
77
|
+
// 同契约形态:XOR 对称可逆,密文经 enc:v1: 前缀 + base64 落盘,与真实现等价)
|
|
78
|
+
function stubCodec() {
|
|
79
|
+
const KEY = 0x5a
|
|
80
|
+
const xor = (b: Buffer) => Buffer.from([...b].map((x) => x ^ KEY))
|
|
81
|
+
return {
|
|
82
|
+
encrypt: async (plain: string) => `enc:v1:${xor(Buffer.from(plain, "utf8")).toString("base64")}`,
|
|
83
|
+
decrypt: async (raw: string) => {
|
|
84
|
+
if (!raw.startsWith("enc:v1:")) return raw
|
|
85
|
+
return xor(Buffer.from(raw.slice("enc:v1:".length), "base64")).toString("utf8")
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("remote-auth desktop codec 接缝(A12)", () => {
|
|
91
|
+
let dir: string
|
|
92
|
+
const saved: Record<string, string | undefined> = {}
|
|
93
|
+
const savedCodec = { value: undefined as unknown }
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k]
|
|
97
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), "remote-auth-codec-test-"))
|
|
98
|
+
process.env.DESKTOP_USER_DATA_DIR = dir
|
|
99
|
+
process.env.DEPLOY_TARGET = "desktop"
|
|
100
|
+
savedCodec.value = globalThis.__allblueDesktopCodec
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
globalThis.__allblueDesktopCodec = savedCodec.value as typeof globalThis.__allblueDesktopCodec
|
|
105
|
+
for (const k of ENV_KEYS) {
|
|
106
|
+
if (saved[k] === undefined) delete process.env[k]
|
|
107
|
+
else process.env[k] = saved[k]
|
|
108
|
+
}
|
|
109
|
+
await fs.rm(dir, { recursive: true, force: true })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("codec 注入:密文落盘(磁盘字节不含明文 token)且读回一致", async () => {
|
|
113
|
+
globalThis.__allblueDesktopCodec = stubCodec()
|
|
114
|
+
await writeRemoteAuth("tok-1", "stale-st")
|
|
115
|
+
await writeRemoteSession("user-1", "pro", "fresh-st")
|
|
116
|
+
const raw = await fs.readFile(path.join(dir, "remote-auth.json"), "utf8")
|
|
117
|
+
expect(raw.startsWith("enc:v1:")).toBe(true)
|
|
118
|
+
expect(raw).not.toContain("fresh-st")
|
|
119
|
+
expect(raw).not.toContain("stale-st")
|
|
120
|
+
expect(raw).not.toContain("tok-1")
|
|
121
|
+
expect(raw).not.toContain("sessionToken")
|
|
122
|
+
expect(() => JSON.parse(raw)).toThrow()
|
|
123
|
+
// roundtrip:读回一致
|
|
124
|
+
expect(await readRemoteAuth()).toMatchObject({
|
|
125
|
+
userId: "user-1",
|
|
126
|
+
planName: "pro",
|
|
127
|
+
sessionToken: "fresh-st",
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("codec 注入但磁盘为旧明文:按明文读取(平滑迁移,无前缀直通)", async () => {
|
|
132
|
+
globalThis.__allblueDesktopCodec = stubCodec()
|
|
133
|
+
const legacy = JSON.stringify({
|
|
134
|
+
session: { userId: "user-1", planName: "pro", sessionToken: "legacy-st" },
|
|
135
|
+
})
|
|
136
|
+
await fs.writeFile(path.join(dir, "remote-auth.json"), legacy, "utf8")
|
|
137
|
+
expect(await readRemoteAuth()).toMatchObject({ sessionToken: "legacy-st" })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("无 codec:行为不变——明文落盘(现状回归锚,逐字节兼容)", async () => {
|
|
141
|
+
globalThis.__allblueDesktopCodec = undefined
|
|
142
|
+
await writeRemoteAuth("tok-1")
|
|
143
|
+
await writeRemoteSession("user-1", "pro", "fresh-st")
|
|
144
|
+
const raw = await fs.readFile(path.join(dir, "remote-auth.json"), "utf8")
|
|
145
|
+
const parsed = JSON.parse(raw) as { session?: { sessionToken?: string } }
|
|
146
|
+
expect(parsed.session?.sessionToken).toBe("fresh-st") // 明文形态 = 现状
|
|
147
|
+
expect(await readRemoteAuth()).toMatchObject({ sessionToken: "fresh-st" })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("codec 降级形态(encrypt 返回无前缀明文):不二次加前缀、明文落盘可读回", async () => {
|
|
151
|
+
// safeStorage 不可用降级由 Pro 域算子收敛为「原样明文」输出——接缝必须
|
|
152
|
+
// 原样落盘,不得对降级明文再补前缀(否则读回解密必败)
|
|
153
|
+
globalThis.__allblueDesktopCodec = {
|
|
154
|
+
encrypt: async (p: string) => p,
|
|
155
|
+
decrypt: async (r: string) => r,
|
|
156
|
+
}
|
|
157
|
+
await writeRemoteSession("user-1", "pro", "fresh-st")
|
|
158
|
+
const raw = await fs.readFile(path.join(dir, "remote-auth.json"), "utf8")
|
|
159
|
+
expect(raw.startsWith("enc:v1:")).toBe(false)
|
|
160
|
+
expect((JSON.parse(raw) as { session?: { sessionToken?: string } }).session?.sessionToken).toBe(
|
|
161
|
+
"fresh-st",
|
|
162
|
+
)
|
|
163
|
+
expect(await readRemoteAuth()).toMatchObject({ sessionToken: "fresh-st" })
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("密文文件但 codec 缺失(独立预检分支):warn + null(视为未登录,下次写入自愈)", async () => {
|
|
167
|
+
// 旧壳读到新格式 / 异常态:磁盘密文在场、globalThis codec 未注入——
|
|
168
|
+
// readFile 显式预检 + warn + null,不抛错反噬调用方。warn 断言钉死独立
|
|
169
|
+
// 预检分支(防空转:无预检时 JSON.parse 抛错同样落 catch → null,结果
|
|
170
|
+
// 相同但丢诊断信号)
|
|
171
|
+
globalThis.__allblueDesktopCodec = undefined
|
|
172
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
173
|
+
try {
|
|
174
|
+
await fs.writeFile(
|
|
175
|
+
path.join(dir, "remote-auth.json"),
|
|
176
|
+
`enc:v1:${Buffer.from('{"session":{"userId":"user-1"}}').toString("base64")}`,
|
|
177
|
+
"utf8",
|
|
178
|
+
)
|
|
179
|
+
expect(await readRemoteAuth()).toBeNull()
|
|
180
|
+
expect(
|
|
181
|
+
warnSpy.mock.calls.some((args) => String(args[0]).includes("codec 未注入")),
|
|
182
|
+
).toBe(true)
|
|
183
|
+
} finally {
|
|
184
|
+
warnSpy.mockRestore()
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("密文在场但 decrypt 抛错(IPC 失败/密文损坏):catch 吞错 → null", async () => {
|
|
189
|
+
globalThis.__allblueDesktopCodec = {
|
|
190
|
+
encrypt: async (p: string) => `enc:v1:${Buffer.from(p).toString("base64")}`,
|
|
191
|
+
decrypt: async () => {
|
|
192
|
+
throw new Error("codec rpc 超时")
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
await fs.writeFile(
|
|
196
|
+
path.join(dir, "remote-auth.json"),
|
|
197
|
+
`enc:v1:${Buffer.from("garbage-not-json").toString("base64")}`,
|
|
198
|
+
"utf8",
|
|
199
|
+
)
|
|
200
|
+
expect(await readRemoteAuth()).toBeNull()
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -22,7 +22,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
|
22
22
|
|
|
23
23
|
const mocks = vi.hoisted(() => ({
|
|
24
24
|
user: { findUnique: vi.fn() },
|
|
25
|
-
kb: { findFirst: vi.fn() },
|
|
25
|
+
kb: { findFirst: vi.fn(), findUnique: vi.fn().mockResolvedValue(null) },
|
|
26
26
|
doc: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() },
|
|
27
27
|
ragSearch: vi.fn(),
|
|
28
28
|
resolveKnowledgeAiConfig: vi.fn(async () => ({})),
|
|
@@ -147,11 +147,13 @@ describe("document_action 归属", () => {
|
|
|
147
147
|
|
|
148
148
|
it("create:属主 + 绑定内 → 写入并带 createdBy", async () => {
|
|
149
149
|
mocks.kb.findFirst.mockResolvedValue({ id: "kb-1" })
|
|
150
|
+
mocks.kb.findUnique.mockResolvedValue({ workspaceId: "ws-9" })
|
|
150
151
|
mocks.doc.create.mockResolvedValue({ id: "d-new", kbId: "kb-1", title: "t", content: "" })
|
|
151
152
|
const out = await run({ action: "create", knowledgeBaseId: "kb-1", title: "t" }, { context: ctxWith(["kb-1"]) })
|
|
152
153
|
expect(out).toEqual({ success: true, documentId: "d-new" })
|
|
154
|
+
// P2 A7:冗余同步 scope 列写时快照取 kb.workspaceId
|
|
153
155
|
expect(mocks.doc.create).toHaveBeenCalledWith({
|
|
154
|
-
data: expect.objectContaining({ kbId: "kb-1", createdBy: USER }),
|
|
156
|
+
data: expect.objectContaining({ kbId: "kb-1", createdBy: USER, syncWorkspaceId: "ws-9" }),
|
|
155
157
|
})
|
|
156
158
|
})
|
|
157
159
|
|
|
@@ -187,6 +187,11 @@ const documentCrudTool = documentCrudDef.server<AppContext>(
|
|
|
187
187
|
if (action === "create") {
|
|
188
188
|
if (!knowledgeBaseId) return { success: false, error: "knowledgeBaseId is required" }
|
|
189
189
|
await assertToolKbWriteAccess(knowledgeBaseId, context)
|
|
190
|
+
// 冗余同步 scope 列来源(P2 A7):kb.workspaceId 写时快照
|
|
191
|
+
const kbRow = await prisma.knowledgeBase.findUnique({
|
|
192
|
+
where: { id: knowledgeBaseId },
|
|
193
|
+
select: { workspaceId: true },
|
|
194
|
+
})
|
|
190
195
|
if (parentId) {
|
|
191
196
|
// 挂载点必须存在且属于同一 KB(防把文档挂到他人 KB 节点下)
|
|
192
197
|
const parent = await prisma.knowledgeDocument.findFirst({
|
|
@@ -202,6 +207,8 @@ const documentCrudTool = documentCrudDef.server<AppContext>(
|
|
|
202
207
|
kbId: knowledgeBaseId,
|
|
203
208
|
parentId: parentId || null,
|
|
204
209
|
createdBy: context.userId,
|
|
210
|
+
// null(个人库)= 一期不进同步面(stay local)
|
|
211
|
+
syncWorkspaceId: kbRow?.workspaceId ?? null,
|
|
205
212
|
},
|
|
206
213
|
})
|
|
207
214
|
const aiConfig = await getPorts().infra.resolveKnowledgeAiConfig(knowledgeBaseId)
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { randomUUID } from 'node:crypto'
|
|
27
27
|
import { prisma } from '@allbluecn/database'
|
|
28
|
+
import type { Prisma } from '@allbluecn/database'
|
|
28
29
|
import { ensureBuiltinIndependentProject } from '@/server/builtin-project'
|
|
29
30
|
import {
|
|
30
31
|
GUEST_EMAIL,
|
|
@@ -54,7 +55,24 @@ function shadowBgColor(seed: string): string {
|
|
|
54
55
|
export async function ensureLocalShadowUser(): Promise<string> {
|
|
55
56
|
if (!isDesktopServer()) return GUEST_USER_ID
|
|
56
57
|
|
|
57
|
-
await prisma
|
|
58
|
+
await ensureLocalShadowUserWith(prisma)
|
|
59
|
+
|
|
60
|
+
await ensureBuiltinIndependentProject(GUEST_USER_ID)
|
|
61
|
+
return GUEST_USER_ID
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 影子用户 upsert 主体(可传事务客户端,A9 抽取):公开无参包装
|
|
66
|
+
* (ensureLocalShadowUser)的语义不变,反向转移(ownership-transfer 的
|
|
67
|
+
* reverseTransferOwnership)在**同一事务内**调本函数重建 guest 行——原实现
|
|
68
|
+
* 直用模块级 prisma 单例,事务内调用会绕过 tx(属主翻转引用的 FK 目标必须与
|
|
69
|
+
* 翻转同事务存在,否则并发删除/回滚会留孤儿)。
|
|
70
|
+
* 只含 users 行 upsert:内置独立项目走 ensureBuiltinIndependentProject(模块级
|
|
71
|
+
* prisma),由登出后的 getSessionFn guest 分支懒补(项目行经反向转移已回 guest
|
|
72
|
+
* 属主,通常已存在)。
|
|
73
|
+
*/
|
|
74
|
+
export async function ensureLocalShadowUserWith(tx: Prisma.TransactionClient): Promise<string> {
|
|
75
|
+
await tx.user.upsert({
|
|
58
76
|
where: { id: GUEST_USER_ID },
|
|
59
77
|
create: {
|
|
60
78
|
id: GUEST_USER_ID,
|
|
@@ -68,7 +86,5 @@ export async function ensureLocalShadowUser(): Promise<string> {
|
|
|
68
86
|
},
|
|
69
87
|
update: {},
|
|
70
88
|
})
|
|
71
|
-
|
|
72
|
-
await ensureBuiltinIndependentProject(GUEST_USER_ID)
|
|
73
89
|
return GUEST_USER_ID
|
|
74
90
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 桌面轻量云身份组装(桌面本地优先 P2 / A10):RemoteAuthData → Session 的
|
|
6
|
+
// 单一组装点 + getCurrentSession 的桌面回落入口。独立模块原因:lib/session
|
|
7
|
+
// 的回落需要组装函数,而 lib/session 不得 import serverFns/**(serverFns/
|
|
8
|
+
// auth/session 反向 import lib/session,会成环)——故从 serverFns/auth/
|
|
9
|
+
// session.ts 抽出 buildRemoteSession 共享(字段逐字一致,两处不再漂移)。
|
|
10
|
+
// 本模块保持纯数据组装 + remote-auth 纯 fs 读取(node:fs + env 守卫),
|
|
11
|
+
// 无 prisma/server 重依赖链;lib/session 对本模块的单条 import 经 eslint
|
|
12
|
+
// 文件级豁免放行(apps/web/eslint.config.mjs)。
|
|
13
|
+
import { DEFAULT_AI_MODEL, DEFAULT_AI_PROVIDER } from '@/lib/ai/default-model'
|
|
14
|
+
import { isDesktopServer } from '@/server/desktop-guest'
|
|
15
|
+
import { readRemoteAuth } from '@/server/remote-auth'
|
|
16
|
+
import type { RemoteAuthData } from '@/server/remote-auth'
|
|
17
|
+
import type { Session } from '@/lib/session'
|
|
18
|
+
|
|
19
|
+
export function buildRemoteSession(data: RemoteAuthData): Session {
|
|
20
|
+
return {
|
|
21
|
+
user: {
|
|
22
|
+
id: data.userId,
|
|
23
|
+
name: data.username ?? data.userId,
|
|
24
|
+
email: data.email,
|
|
25
|
+
role: (data.role ?? 'USER') as 'USER' | 'ADMIN' | 'SUPER_ADMIN',
|
|
26
|
+
isGuest: false,
|
|
27
|
+
// 云端头像透传(SaaS desktop-callback 下发;缺省回落首字母,修复桌面端
|
|
28
|
+
// 当前用户头像不显示)
|
|
29
|
+
avatarConfig: data.avatarConfig,
|
|
30
|
+
bgShape: data.bgShape,
|
|
31
|
+
bgColor: data.bgColor,
|
|
32
|
+
aiProvider: DEFAULT_AI_PROVIDER,
|
|
33
|
+
aiModel: DEFAULT_AI_MODEL,
|
|
34
|
+
hasAiKey: false,
|
|
35
|
+
},
|
|
36
|
+
expires: 'never',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* getCurrentSession 的桌面回落段(优先级链:authjs session → desktop
|
|
42
|
+
* remoteAuth → null):authjs session 无效且为 desktop 部署时,读
|
|
43
|
+
* remote-auth.json 组装轻量云身份(user.id = 云 userId)。desktop 判定
|
|
44
|
+
* 先于 fs 读取(非 desktop 恒 null、零 IO),调用方无需重复判定。
|
|
45
|
+
*/
|
|
46
|
+
export async function getDesktopRemoteSession(): Promise<Session | null> {
|
|
47
|
+
if (!isDesktopServer()) return null
|
|
48
|
+
const remoteAuth = await readRemoteAuth()
|
|
49
|
+
if (!remoteAuth) return null
|
|
50
|
+
return buildRemoteSession(remoteAuth)
|
|
51
|
+
}
|
|
@@ -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
|
-
|
|
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,
|
|
116
|
+
await fs.writeFile(tmp, content, { mode: 0o600 })
|
|
59
117
|
await fs.rename(tmp, file)
|
|
60
118
|
}
|
|
61
119
|
|
|
@@ -223,4 +223,25 @@ describe('createDocumentFn:parentId 同库校验 + maxSort 同事务(审计
|
|
|
223
223
|
expect.objectContaining({ data: expect.objectContaining({ sortOrder: 4, parentId: 'doc-parent', kbId: 'kb-1' }) }),
|
|
224
224
|
)
|
|
225
225
|
})
|
|
226
|
+
|
|
227
|
+
// P2 A7:冗余同步 scope 列写时快照——文档创建即取 kb.workspaceId
|
|
228
|
+
it('syncWorkspaceId 取 kb.workspaceId(工作区库)', async () => {
|
|
229
|
+
m.knowledgeBase.findFirst.mockResolvedValue({ id: 'kb-1', workspaceId: 'ws-1' })
|
|
230
|
+
m.knowledgeDocument.aggregate.mockResolvedValue({ _max: { sortOrder: -1 } })
|
|
231
|
+
m.knowledgeDocument.create.mockResolvedValue({ id: 'doc-new', title: '新文档', type: 'doc', parentId: null })
|
|
232
|
+
await createDocumentFn({ data: { kbId: 'kb-1', title: '无父文档' } })
|
|
233
|
+
expect(m.knowledgeDocument.create).toHaveBeenCalledWith(
|
|
234
|
+
expect.objectContaining({ data: expect.objectContaining({ syncWorkspaceId: 'ws-1' }) }),
|
|
235
|
+
)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('syncWorkspaceId 为 null(个人库)——stay local 不同步', async () => {
|
|
239
|
+
m.knowledgeBase.findFirst.mockResolvedValue({ id: 'kb-1', workspaceId: null })
|
|
240
|
+
m.knowledgeDocument.aggregate.mockResolvedValue({ _max: { sortOrder: -1 } })
|
|
241
|
+
m.knowledgeDocument.create.mockResolvedValue({ id: 'doc-new', title: '新文档', type: 'doc', parentId: null })
|
|
242
|
+
await createDocumentFn({ data: { kbId: 'kb-1', title: '无父文档' } })
|
|
243
|
+
expect(m.knowledgeDocument.create).toHaveBeenCalledWith(
|
|
244
|
+
expect.objectContaining({ data: expect.objectContaining({ syncWorkspaceId: null }) }),
|
|
245
|
+
)
|
|
246
|
+
})
|
|
226
247
|
})
|
|
@@ -100,7 +100,9 @@ const mocks = vi.hoisted(() => ({
|
|
|
100
100
|
auditLogCreate: vi.fn(),
|
|
101
101
|
knowledgeBaseDeleteMany: vi.fn(),
|
|
102
102
|
knowledgeBaseUpdateMany: vi.fn(),
|
|
103
|
+
knowledgeBaseFindMany: vi.fn().mockResolvedValue([]),
|
|
103
104
|
knowledgeBaseCreate: vi.fn(),
|
|
105
|
+
knowledgeDocumentUpdateMany: vi.fn(),
|
|
104
106
|
wkbCreate: vi.fn(),
|
|
105
107
|
usageRecordDeleteMany: vi.fn(),
|
|
106
108
|
aiUsageLogDeleteMany: vi.fn(),
|
|
@@ -165,6 +167,10 @@ vi.mock("@allbluecn/database", () => ({
|
|
|
165
167
|
create: mocks.knowledgeBaseCreate,
|
|
166
168
|
deleteMany: mocks.knowledgeBaseDeleteMany,
|
|
167
169
|
updateMany: mocks.knowledgeBaseUpdateMany,
|
|
170
|
+
findMany: mocks.knowledgeBaseFindMany,
|
|
171
|
+
},
|
|
172
|
+
knowledgeDocument: {
|
|
173
|
+
updateMany: mocks.knowledgeDocumentUpdateMany,
|
|
168
174
|
},
|
|
169
175
|
workspaceKnowledgeBase: {
|
|
170
176
|
create: mocks.wkbCreate,
|
|
@@ -198,6 +204,10 @@ vi.mock("@allbluecn/database", () => ({
|
|
|
198
204
|
create: mocks.knowledgeBaseCreate,
|
|
199
205
|
deleteMany: mocks.knowledgeBaseDeleteMany,
|
|
200
206
|
updateMany: mocks.knowledgeBaseUpdateMany,
|
|
207
|
+
findMany: mocks.knowledgeBaseFindMany,
|
|
208
|
+
},
|
|
209
|
+
knowledgeDocument: {
|
|
210
|
+
updateMany: mocks.knowledgeDocumentUpdateMany,
|
|
201
211
|
},
|
|
202
212
|
workspaceKnowledgeBase: {
|
|
203
213
|
create: mocks.wkbCreate,
|
|
@@ -447,6 +457,42 @@ describe("deleteWorkspaceFn", () => {
|
|
|
447
457
|
});
|
|
448
458
|
});
|
|
449
459
|
|
|
460
|
+
it("解绑模式(keep)级联清空文档冗余 scope 列(P2 A7)", async () => {
|
|
461
|
+
mocks.workspaceFindUnique.mockResolvedValue({ ownerId: "u1" });
|
|
462
|
+
mocks.projectCount.mockResolvedValue(0);
|
|
463
|
+
mocks.workspaceDelete.mockResolvedValue({ id: "ws1" });
|
|
464
|
+
mocks.knowledgeBaseFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]);
|
|
465
|
+
|
|
466
|
+
const result = await deleteWorkspaceFn({
|
|
467
|
+
data: { workspaceId: "ws1", knowledgeBaseMode: "keep" },
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
expect(result).toEqual({ success: true });
|
|
471
|
+
expect(mocks.knowledgeBaseUpdateMany).toHaveBeenCalledWith({
|
|
472
|
+
where: { workspaceId: "ws1" },
|
|
473
|
+
data: { workspaceId: null },
|
|
474
|
+
});
|
|
475
|
+
expect(mocks.knowledgeDocumentUpdateMany).toHaveBeenCalledWith({
|
|
476
|
+
where: { kbId: { in: ["kb1", "kb2"] } },
|
|
477
|
+
data: { syncWorkspaceId: null },
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("delete 模式硬删知识库,文档随 FK 级联硬删、不动 scope 列", async () => {
|
|
482
|
+
mocks.workspaceFindUnique.mockResolvedValue({ ownerId: "u1" });
|
|
483
|
+
mocks.projectCount.mockResolvedValue(0);
|
|
484
|
+
mocks.workspaceDelete.mockResolvedValue({ id: "ws1" });
|
|
485
|
+
|
|
486
|
+
const result = await deleteWorkspaceFn({
|
|
487
|
+
data: { workspaceId: "ws1", knowledgeBaseMode: "delete" },
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
expect(result).toEqual({ success: true });
|
|
491
|
+
expect(mocks.knowledgeBaseDeleteMany).toHaveBeenCalledWith({ where: { workspaceId: "ws1" } });
|
|
492
|
+
expect(mocks.knowledgeBaseFindMany).not.toHaveBeenCalled();
|
|
493
|
+
expect(mocks.knowledgeDocumentUpdateMany).not.toHaveBeenCalled();
|
|
494
|
+
});
|
|
495
|
+
|
|
450
496
|
it("clear 模式删除所有项目后删除工作空间", async () => {
|
|
451
497
|
mocks.workspaceFindUnique.mockResolvedValue({ ownerId: "u1" });
|
|
452
498
|
mocks.projectCount.mockResolvedValue(3);
|