@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.
Files changed (60) hide show
  1. package/package.json +3 -3
  2. package/src/components/desktop/__tests__/desktop-state-listener.test.tsx +183 -0
  3. package/src/components/desktop/__tests__/sync-indicator.test.tsx +130 -0
  4. package/src/components/desktop/cloud-login.tsx +108 -6
  5. package/src/components/desktop/desktop-state-listener.tsx +76 -0
  6. package/src/components/desktop/shell-widgets.tsx +12 -5
  7. package/src/components/desktop/sync-indicator.tsx +62 -13
  8. package/src/components/layout/sidebar-03/__tests__/user-menu-anonymous.test.tsx +4 -0
  9. package/src/components/layout/sidebar-03/user-menu.tsx +63 -0
  10. package/src/lib/__tests__/session.test.ts +95 -0
  11. package/src/lib/auth/auth.config.ts +13 -0
  12. package/src/lib/changelog/sdk-versions.ts +1 -1
  13. package/src/lib/desktop-bridge-types.ts +36 -0
  14. package/src/lib/session.ts +15 -1
  15. package/src/paraglide/messages/_index.d.ts +11 -0
  16. package/src/paraglide/messages/_index.js +11 -0
  17. package/src/paraglide/messages/desktop_cloud_exchange_failed.d.ts +16 -0
  18. package/src/paraglide/messages/desktop_cloud_exchange_failed.js +29 -0
  19. package/src/paraglide/messages/desktop_state_cloud.d.ts +16 -0
  20. package/src/paraglide/messages/desktop_state_cloud.js +29 -0
  21. package/src/paraglide/messages/desktop_state_guest.d.ts +16 -0
  22. package/src/paraglide/messages/desktop_state_guest.js +29 -0
  23. package/src/paraglide/messages/desktop_state_transferring.d.ts +16 -0
  24. package/src/paraglide/messages/desktop_state_transferring.js +29 -0
  25. package/src/paraglide/messages/sync_bind_failed.d.ts +16 -0
  26. package/src/paraglide/messages/sync_bind_failed.js +29 -0
  27. package/src/paraglide/messages/sync_bind_in_progress.d.ts +16 -0
  28. package/src/paraglide/messages/sync_bind_in_progress.js +29 -0
  29. package/src/paraglide/messages/sync_bind_nonempty.d.ts +16 -0
  30. package/src/paraglide/messages/sync_bind_nonempty.js +29 -0
  31. package/src/paraglide/messages/sync_bind_probe_unreachable.d.ts +16 -0
  32. package/src/paraglide/messages/sync_bind_probe_unreachable.js +29 -0
  33. package/src/paraglide/messages/sync_bind_shell_unsupported.d.ts +16 -0
  34. package/src/paraglide/messages/sync_bind_shell_unsupported.js +29 -0
  35. package/src/paraglide/messages/sync_logout_failed.d.ts +16 -0
  36. package/src/paraglide/messages/sync_logout_failed.js +29 -0
  37. package/src/paraglide/messages/sync_logout_in_progress.d.ts +16 -0
  38. package/src/paraglide/messages/sync_logout_in_progress.js +29 -0
  39. package/src/routeTree.gen.ts +21 -0
  40. package/src/routes/api/sync/__tests__/account-status.test.ts +109 -0
  41. package/src/routes/api/sync/account-status.ts +39 -0
  42. package/src/server/__tests__/exchange-remote-token.test.ts +113 -0
  43. package/src/server/__tests__/remote-auth.test.ts +131 -1
  44. package/src/server/ai-gateway/__tests__/tools-authorization.test.ts +4 -2
  45. package/src/server/ai-gateway/tools/server.ts +7 -0
  46. package/src/server/desktop-bootstrap.ts +19 -3
  47. package/src/server/desktop-session-merge.ts +51 -0
  48. package/src/server/remote-auth.ts +61 -3
  49. package/src/server/serverFns/__tests__/knowledge-readable-kb.test.ts +21 -0
  50. package/src/server/serverFns/__tests__/workspace.test.ts +46 -0
  51. package/src/server/serverFns/auth/session.ts +28 -28
  52. package/src/server/serverFns/harness/attach-to-kb.ts +3 -1
  53. package/src/server/serverFns/knowledge.ts +4 -1
  54. package/src/server/serverFns/sync/transfer.ts +168 -0
  55. package/src/server/serverFns/workspace.ts +15 -0
  56. package/src/server/serverfn-public-allowlist.ts +4 -0
  57. package/src/server/sync/__tests__/ownership-transfer.test.ts +896 -0
  58. package/src/server/sync/account-status.ts +36 -0
  59. package/src/server/sync/ownership-transfer.ts +291 -0
  60. 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.user.upsert({
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
- 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
 
@@ -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);