@allbluecn/web-app 0.23.5 → 0.23.6
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 +1 -1
- package/src/components/desktop-agent/agent-chat.tsx +3 -2
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-agent/__tests__/connection.test.ts +93 -6
- package/src/lib/desktop-agent/connection.ts +36 -15
- package/src/lib/desktop-bridge-types.ts +12 -1
- package/src/routes/api/sync/__tests__/bootstrap.test.ts +67 -0
- package/src/routes/api/sync/__tests__/pull.test.ts +68 -0
- package/src/routes/api/sync/__tests__/push.test.ts +69 -0
- package/src/routes/api/sync/__tests__/snapshot.test.ts +66 -0
- package/src/routes/api/sync/bootstrap.ts +20 -15
- package/src/routes/api/sync/pull.ts +62 -57
- package/src/routes/api/sync/push.ts +63 -58
- package/src/routes/api/sync/snapshot.ts +71 -66
- package/src/server/sync/http-errors.ts +43 -0
package/package.json
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
// 本地文件助手 · 对话区(桌面本地优先 P3-B5):useChat 直连 Pro desktop 域
|
|
6
6
|
// AgentService 的 `POST /prompt`(AG-UI SSE)。
|
|
7
|
-
// - connection:lib/desktop-agent/connection 包装——每次 connect
|
|
8
|
-
//
|
|
7
|
+
// - connection:lib/desktop-agent/connection 包装——每次 connect 懒取桌面桥的 agent
|
|
8
|
+
// 端点(新壳 agentCredentials 按需取 {port, token},旧壳回落 appInfo 的
|
|
9
|
+
// agentPort/agentToken;backend 重启换端口/令牌后下一问即用新值),引用稳定。
|
|
9
10
|
// - threadId:mount 级稳定值(useChat 1.0 起必填,变化即重建 ChatClient)。B4 端点
|
|
10
11
|
// 每次 POST 新建 pi 会话,threadId 仅作 run 归属回显——一期不保留多轮上下文,
|
|
11
12
|
// 输入区常驻提示告知。
|
|
@@ -112,7 +112,7 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
112
112
|
"items": [
|
|
113
113
|
{
|
|
114
114
|
"name": "@allbluecn/web-app",
|
|
115
|
-
"version": "v0.23.
|
|
115
|
+
"version": "v0.23.6",
|
|
116
116
|
"lastUpdate": "Nov 1, 2026",
|
|
117
117
|
"source": "apps/web/package.json",
|
|
118
118
|
"docsUrl": "https://github.com/allbluecn/allblue"
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// AllBlue - 理想之海
|
|
3
3
|
// Copyright (C) 2026 AllBlue Contributors
|
|
4
4
|
|
|
5
|
-
// desktop-agent 连接(桌面本地优先 P3-B5
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// desktop-agent 连接(桌面本地优先 P3-B5):调用时懒取 agent 端点 → 真实
|
|
6
|
+
// fetchServerSentEvents 直连 http://127.0.0.1:{port}/prompt。fetch 打桩、SSE 解析
|
|
7
|
+
// 走真实 @tanstack/ai-client 实现——请求线格式即与 Pro 端点的跨仓契约:URL /
|
|
8
|
+
// X-Desktop-Token / X-Run-Id 头 / forwardedProps.cwd(端点 extractCwd 取值位)。
|
|
9
|
+
// 两种壳形态各覆盖:新壳经 agentCredentials 按需取凭据(appInfo.agentToken 恒 null,
|
|
10
|
+
// 不参与连接),旧壳回落 appInfo 的 agentPort/agentToken。
|
|
9
11
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
12
|
import type { UIMessage } from '@tanstack/ai-client'
|
|
11
13
|
|
|
@@ -18,7 +20,15 @@ type AppInfo = {
|
|
|
18
20
|
agentToken?: string | null
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
type AgentCredentials = { port: number; token: string }
|
|
24
|
+
|
|
25
|
+
type Bridge = {
|
|
26
|
+
appInfo: () => Promise<AppInfo>
|
|
27
|
+
/** 新壳按需取凭据通道;旧壳无此方法(undefined) */
|
|
28
|
+
agentCredentials?: () => Promise<AgentCredentials | null>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bridgeRef: { current: Bridge | null } = { current: null }
|
|
22
32
|
|
|
23
33
|
vi.mock('@/lib/desktop-bridge-types', () => ({
|
|
24
34
|
getDesktopBridge: () => bridgeRef.current,
|
|
@@ -36,6 +46,14 @@ function bridgeWith(info: Partial<AppInfo>) {
|
|
|
36
46
|
return { appInfo: vi.fn(async () => ({ ...BASE_INFO, ...info })) }
|
|
37
47
|
}
|
|
38
48
|
|
|
49
|
+
/** 新壳桥:appInfo 里 agentPort 仍广播(门控用)、agentToken 恒 null;凭据走按需 IPC */
|
|
50
|
+
function newShellBridge(credentials: AgentCredentials | null, info: Partial<AppInfo> = {}) {
|
|
51
|
+
return {
|
|
52
|
+
appInfo: vi.fn(async () => ({ ...BASE_INFO, agentPort: null, agentToken: null, ...info })),
|
|
53
|
+
agentCredentials: vi.fn(async () => credentials),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
const encoder = new TextEncoder()
|
|
40
58
|
function sse(event: Record<string, unknown>): Uint8Array {
|
|
41
59
|
return encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
|
|
@@ -119,6 +137,50 @@ describe('resolveDesktopAgentEndpoint(appInfo 懒取)', () => {
|
|
|
119
137
|
})
|
|
120
138
|
})
|
|
121
139
|
|
|
140
|
+
describe('resolveDesktopAgentEndpoint(新壳 agentCredentials 优先 / 旧壳回落 appInfo)', () => {
|
|
141
|
+
it('新壳:用 agentCredentials 的 {port, token} 构造端点,不读 appInfo 的令牌', async () => {
|
|
142
|
+
// appInfo 故意给一个不同的端口 + 陈旧令牌:端点必须只来自 agentCredentials
|
|
143
|
+
const bridge = newShellBridge({ port: 2468, token: 'tok-cred' }, { agentPort: 4567, agentToken: 'ok' })
|
|
144
|
+
bridgeRef.current = bridge
|
|
145
|
+
await expect(resolveDesktopAgentEndpoint()).resolves.toEqual({
|
|
146
|
+
url: 'http://127.0.0.1:2468/prompt',
|
|
147
|
+
token: 'tok-cred',
|
|
148
|
+
})
|
|
149
|
+
expect(bridge.agentCredentials).toHaveBeenCalledTimes(1)
|
|
150
|
+
expect(bridge.appInfo).not.toHaveBeenCalled()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('新壳:agentCredentials 返回 null(未装配)或抛错(IPC 断开)→ null,不回落 appInfo', async () => {
|
|
154
|
+
const bridge = newShellBridge(null)
|
|
155
|
+
bridgeRef.current = bridge
|
|
156
|
+
await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
|
|
157
|
+
expect(bridge.appInfo).not.toHaveBeenCalled()
|
|
158
|
+
|
|
159
|
+
bridgeRef.current = {
|
|
160
|
+
appInfo: vi.fn(async () => ({ ...BASE_INFO, agentPort: 4567, agentToken: null })),
|
|
161
|
+
agentCredentials: vi.fn(async () => {
|
|
162
|
+
throw new Error('ipc closed')
|
|
163
|
+
}),
|
|
164
|
+
}
|
|
165
|
+
await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('新壳:agentCredentials 产出的端口/令牌非法 → null(与 appInfo 路径同口径)', async () => {
|
|
169
|
+
bridgeRef.current = newShellBridge({ port: 0, token: 'tok' })
|
|
170
|
+
await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
|
|
171
|
+
bridgeRef.current = newShellBridge({ port: 4567, token: '' })
|
|
172
|
+
await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('旧壳(无 agentCredentials)→ 回落 appInfo 的 agentPort + agentToken', async () => {
|
|
176
|
+
bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
|
|
177
|
+
await expect(resolveDesktopAgentEndpoint()).resolves.toEqual({
|
|
178
|
+
url: 'http://127.0.0.1:4567/prompt',
|
|
179
|
+
token: 'tok-abc',
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
122
184
|
describe('createDesktopAgentConnection(fetchServerSentEvents 包装)', () => {
|
|
123
185
|
it('端点不可用 → 抛 DesktopAgentUnavailableError,且不发起 fetch', async () => {
|
|
124
186
|
const fetchMock = stubSseFetch(RUN_EVENTS)
|
|
@@ -173,6 +235,31 @@ describe('createDesktopAgentConnection(fetchServerSentEvents 包装)', () =>
|
|
|
173
235
|
expect(body.messages).toEqual([expect.objectContaining({ role: 'user', content: '列出目录' })])
|
|
174
236
|
})
|
|
175
237
|
|
|
238
|
+
it('新壳:URL / X-Desktop-Token 取自 agentCredentials(appInfo 的 agentToken 恒 null 不参与)', async () => {
|
|
239
|
+
const fetchMock = stubSseFetch(RUN_EVENTS)
|
|
240
|
+
const bridge = newShellBridge({ port: 2468, token: 'tok-cred' }, { agentPort: 4567, agentToken: null })
|
|
241
|
+
bridgeRef.current = bridge
|
|
242
|
+
await drain(
|
|
243
|
+
createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, { threadId: 't-1', runId: 'r-1' }),
|
|
244
|
+
)
|
|
245
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
246
|
+
const [url, init] = fetchMock.mock.calls[0]!
|
|
247
|
+
expect(url).toBe('http://127.0.0.1:2468/prompt')
|
|
248
|
+
expect((init?.headers as Record<string, string>)['X-Desktop-Token']).toBe('tok-cred')
|
|
249
|
+
// 每次 connect 都重新按需取一次凭据(backend 重启换端口/令牌后下一问即用新值)
|
|
250
|
+
expect(bridge.agentCredentials).toHaveBeenCalledTimes(1)
|
|
251
|
+
expect(bridge.appInfo).not.toHaveBeenCalled()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('新壳未装配(agentCredentials → null)→ 抛 DesktopAgentUnavailableError,不发 fetch', async () => {
|
|
255
|
+
const fetchMock = stubSseFetch(RUN_EVENTS)
|
|
256
|
+
bridgeRef.current = newShellBridge(null)
|
|
257
|
+
await expect(
|
|
258
|
+
drain(createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, { threadId: 't', runId: 'r' })),
|
|
259
|
+
).rejects.toBeInstanceOf(DesktopAgentUnavailableError)
|
|
260
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
261
|
+
})
|
|
262
|
+
|
|
176
263
|
it('SSE 帧原样产出为 AG-UI chunk(RUN_STARTED 起、RUN_FINISHED 止)', async () => {
|
|
177
264
|
stubSseFetch(RUN_EVENTS)
|
|
178
265
|
bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
|
|
@@ -191,7 +278,7 @@ describe('createDesktopAgentConnection(fetchServerSentEvents 包装)', () =>
|
|
|
191
278
|
])
|
|
192
279
|
})
|
|
193
280
|
|
|
194
|
-
it('
|
|
281
|
+
it('旧壳:每次 connect 都重取 appInfo(backend 重启换端口/令牌后下一问即用新值)', async () => {
|
|
195
282
|
const fetchMock = stubSseFetch(RUN_EVENTS)
|
|
196
283
|
const appInfo = vi
|
|
197
284
|
.fn<() => Promise<AppInfo>>()
|
|
@@ -5,13 +5,18 @@
|
|
|
5
5
|
// 本地文件助手的 agent 连接(桌面本地优先 P3-B5):useChat 的 connection 适配。
|
|
6
6
|
//
|
|
7
7
|
// 端点 = Pro desktop 域 AgentService(B4):backend utilityProcess 内 127.0.0.1
|
|
8
|
-
// 随机端口 `POST /prompt`(AG-UI SSE),须带 `X-Desktop-Token
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// 随机端口 `POST /prompt`(AG-UI SSE),须带 `X-Desktop-Token`。端口与令牌经桌面桥
|
|
9
|
+
// 取得,两种壳形态:
|
|
10
|
+
// - 新壳:`desktopBridge.agentCredentials()` 按需 IPC 返回 { port, token }(令牌不再
|
|
11
|
+
// 随 appInfo 常驻广播——常驻信息面会让任意 renderer JS(含 XSS)随时拿到令牌去读
|
|
12
|
+
// 已授权目录;appInfo.agentToken 在新壳恒为 null 占位)。
|
|
13
|
+
// - 旧壳(无 agentCredentials):回落 `desktopBridge.appInfo()` 的 agentPort/agentToken。
|
|
14
|
+
// backend 重启即换新值(main 侧 backend exit 清空),所以**每次请求时懒取**,不在组件
|
|
15
|
+
// 顶层静态拼 URL。
|
|
11
16
|
//
|
|
12
17
|
// 为什么包一层而不是直接 fetchServerSentEvents(url, options):其 url 参数只接受
|
|
13
18
|
// 同步 getter,且在 connect() 内先于(可异步的)options 求值——URL 无法来自同一次
|
|
14
|
-
// 调用里 await
|
|
19
|
+
// 调用里 await 的桥调用。包装只做「await 桥 → 构造 fetchServerSentEvents →
|
|
15
20
|
// 原样委托 connect」,SSE 解析/AG-UI 协议/abort 全部是官方实现,无自定义协议面。
|
|
16
21
|
//
|
|
17
22
|
// 请求线格式(与 Pro 端点的跨仓契约,见 agent-service.ts extractCwd/extractPrompt):
|
|
@@ -28,8 +33,8 @@ export interface DesktopAgentEndpoint {
|
|
|
28
33
|
token: string
|
|
29
34
|
}
|
|
30
35
|
|
|
31
|
-
/** agent 端点不可用:无桥(云 Web / OSS)、旧壳(appInfo 无 agent
|
|
32
|
-
*
|
|
36
|
+
/** agent 端点不可用:无桥(云 Web / OSS)、旧壳(appInfo 无 agent 字段)、新壳未装配
|
|
37
|
+
* (agentCredentials 返回 null)、或 DESKTOP_AGENT 门控未开 / bundle 装配失败 */
|
|
33
38
|
export class DesktopAgentUnavailableError extends Error {
|
|
34
39
|
constructor() {
|
|
35
40
|
super('desktop agent endpoint unavailable')
|
|
@@ -37,20 +42,36 @@ export class DesktopAgentUnavailableError extends Error {
|
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
/**
|
|
45
|
+
/** 端口合法性(1..65535 整数):非法即「不可用」,不拼出坏 URL */
|
|
46
|
+
function isValidAgentPort(port: unknown): port is number {
|
|
47
|
+
return typeof port === 'number' && Number.isInteger(port) && port >= 1 && port <= 65535
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 由端口 + 令牌构造端点;任一非法返回 null(不吞半截凭据)。令牌空串按不可用处理
|
|
51
|
+
* ——新壳 appInfo.agentToken 恒 null,回落路径若拿到空串说明壳未装配 */
|
|
52
|
+
function buildEndpoint(port: unknown, token: unknown): DesktopAgentEndpoint | null {
|
|
53
|
+
if (!isValidAgentPort(port)) return null
|
|
54
|
+
if (typeof token !== 'string' || token === '') return null
|
|
55
|
+
return { url: `http://127.0.0.1:${port}/prompt`, token }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 调用时读取当前 agent 端点;不可用返回 null(端口/令牌非法同样视为不可用,不拼坏 URL)。
|
|
59
|
+
* 取端点优先 agentCredentials(新壳按需取凭证),旧壳回落 appInfo 的 agentPort/agentToken。 */
|
|
41
60
|
export async function resolveDesktopAgentEndpoint(): Promise<DesktopAgentEndpoint | null> {
|
|
42
61
|
const bridge = getDesktopBridge()
|
|
43
62
|
if (!bridge) return null
|
|
44
|
-
//
|
|
45
|
-
//
|
|
63
|
+
// 新壳独占使用 agentCredentials:其存在即表示令牌不再经 appInfo 广播,回落拿到
|
|
64
|
+
// 的只会是恒 null 占位,无收益。IPC 断开/main 未就绪时抛错与 appInfo 同口径归
|
|
65
|
+
// 「服务未就绪」(不可用),不把原生错误原文透出成「运行失败:<原文>」
|
|
66
|
+
if (typeof bridge.agentCredentials === 'function') {
|
|
67
|
+
const credentials = await bridge.agentCredentials().catch(() => null)
|
|
68
|
+
if (!credentials) return null
|
|
69
|
+
return buildEndpoint(credentials.port, credentials.token)
|
|
70
|
+
}
|
|
71
|
+
// 旧壳:端口/令牌同随 appInfo 广播
|
|
46
72
|
const info = await bridge.appInfo().catch(() => null)
|
|
47
73
|
if (!info) return null
|
|
48
|
-
|
|
49
|
-
if (typeof agentPort !== 'number' || !Number.isInteger(agentPort) || agentPort < 1 || agentPort > 65535) {
|
|
50
|
-
return null
|
|
51
|
-
}
|
|
52
|
-
if (typeof agentToken !== 'string' || agentToken === '') return null
|
|
53
|
-
return { url: `http://127.0.0.1:${agentPort}/prompt`, token: agentToken }
|
|
74
|
+
return buildEndpoint(info.agentPort, info.agentToken)
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
/**
|
|
@@ -9,15 +9,26 @@ export interface DesktopBridgeApi {
|
|
|
9
9
|
* 字段):backend utilityProcess 装配 AgentService 后随 appInfo 广播——
|
|
10
10
|
* agentPort = 127.0.0.1 随机端口,agentToken = 启动时生成的访问令牌
|
|
11
11
|
* (请求须带 X-Desktop-Token 头)。null = agent 未装配(旧壳,或 DESKTOP_AGENT
|
|
12
|
-
* 门控未开/bundle 装配失败降级),renderer 据此隐藏 agent 入口。
|
|
12
|
+
* 门控未开/bundle 装配失败降级),renderer 据此隐藏 agent 入口。
|
|
13
|
+
* ⚠ agentToken 仅旧壳有值:新壳把它移出 appInfo 常驻广播(任意 renderer JS
|
|
14
|
+
* 含 XSS 都能随时取到令牌去读已授权目录),改由 agentCredentials 按需取一次,
|
|
15
|
+
* 保留下来的该字段**恒为 null(纯兼容占位,勿再消费)**。agentPort 仍随
|
|
16
|
+
* appInfo 广播(仅端口、不可用于取文件,供入口门控)。 */
|
|
13
17
|
appInfo: () => Promise<{
|
|
14
18
|
version: string
|
|
15
19
|
clientId: string
|
|
16
20
|
platform: string
|
|
17
21
|
backendPort: number | null
|
|
18
22
|
agentPort?: number | null
|
|
23
|
+
/** @deprecated 新壳恒 null(令牌不再广播);请改用 agentCredentials() */
|
|
19
24
|
agentToken?: string | null
|
|
20
25
|
}>
|
|
26
|
+
/** 本地文件助手 agent 凭据按需获取(Pro 侧新增 IPC `desktopBridge:agentCredentials`):
|
|
27
|
+
* 仅在建立 agent 连接时调用一次,返回 { port, token }(port = 127.0.0.1 随机端口,
|
|
28
|
+
* token = 请求头 X-Desktop-Token 的值);agent 未装配 → null。与 appInfo 不同,
|
|
29
|
+
* 本方法**不是**常驻可查询的信息面,令牌不进入任何长期可读的全局面。
|
|
30
|
+
* 旧壳无此方法 → undefined,调用方回落到 appInfo 的 agentPort/agentToken。 */
|
|
31
|
+
agentCredentials?: () => Promise<{ port: number; token: string } | null>
|
|
21
32
|
cloudLogin: (email: string, password: string) => Promise<{ ok: boolean; userId?: string; error?: string }>
|
|
22
33
|
/** A10 轻量云身份激活:登录成功后取 cloud-session.json 解出的纯 session
|
|
23
34
|
* token(renderer 再走 exchangeRemoteTokenFn 换权益并写 remote-auth)。 */
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// /api/sync/bootstrap 未登录态回归哨兵(sync 族状态码对齐):requireAuth 抛
|
|
6
|
+
// UnauthorizedError 必须映射 401 + { error: 'unauthorized' }(此前裸调直上抛
|
|
7
|
+
// → 500)。非鉴权异常必须原样上抛(不吞)。
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({ requireAuth: vi.fn(), getPrisma: vi.fn() }))
|
|
11
|
+
|
|
12
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
13
|
+
getRequest: () => ({ url: 'https://allblue.test/api/sync/bootstrap' }),
|
|
14
|
+
}))
|
|
15
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
16
|
+
requireAuth: mocks.requireAuth,
|
|
17
|
+
UnauthorizedError: class UnauthorizedError extends Error {
|
|
18
|
+
constructor(message = 'UNAUTHORIZED') {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = 'UnauthorizedError'
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}))
|
|
24
|
+
vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: mocks.getPrisma }))
|
|
25
|
+
|
|
26
|
+
import { Route } from '../bootstrap'
|
|
27
|
+
import { UnauthorizedError } from '@/server/auth-helpers'
|
|
28
|
+
|
|
29
|
+
type Handler = () => Promise<Response>
|
|
30
|
+
const GET = (Route.options.server as { handlers?: { GET?: Handler } } | undefined)?.handlers?.GET
|
|
31
|
+
if (!GET) throw new Error('GET handler missing')
|
|
32
|
+
|
|
33
|
+
const call = () => GET()
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.clearAllMocks()
|
|
37
|
+
mocks.requireAuth.mockResolvedValue({ user: { id: 'u1' } })
|
|
38
|
+
mocks.getPrisma.mockResolvedValue({})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('/api/sync/bootstrap', () => {
|
|
42
|
+
it('未登录(UnauthorizedError)→ 401 + { error: "unauthorized" }', async () => {
|
|
43
|
+
mocks.requireAuth.mockRejectedValue(new UnauthorizedError())
|
|
44
|
+
|
|
45
|
+
const res = await call()
|
|
46
|
+
|
|
47
|
+
expect(res.status).toBe(401)
|
|
48
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' })
|
|
49
|
+
// 鉴权失败不得触达数据库
|
|
50
|
+
expect(mocks.getPrisma).not.toHaveBeenCalled()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('未登录(裸 Error("UNAUTHORIZED"),跨实例兜底)→ 401', async () => {
|
|
54
|
+
mocks.requireAuth.mockRejectedValue(new Error('UNAUTHORIZED'))
|
|
55
|
+
|
|
56
|
+
const res = await call()
|
|
57
|
+
|
|
58
|
+
expect(res.status).toBe(401)
|
|
59
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('非鉴权异常(取 Prisma 失败)→ 原样上抛,不吞成响应', async () => {
|
|
63
|
+
mocks.getPrisma.mockRejectedValue(new Error('db down'))
|
|
64
|
+
|
|
65
|
+
await expect(call()).rejects.toThrow('db down')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// /api/sync/pull 未登录态回归哨兵(sync 族状态码对齐):requireAuth 抛
|
|
6
|
+
// UnauthorizedError 必须映射 401 + { error: 'unauthorized' }(此前裸调直上抛
|
|
7
|
+
// → 500)。非鉴权异常必须原样上抛(不吞)。
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({ requireAuth: vi.fn() }))
|
|
11
|
+
|
|
12
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
13
|
+
getRequest: () => ({ url: 'https://allblue.test/api/sync/pull' }),
|
|
14
|
+
}))
|
|
15
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
16
|
+
requireAuth: mocks.requireAuth,
|
|
17
|
+
UnauthorizedError: class UnauthorizedError extends Error {
|
|
18
|
+
constructor(message = 'UNAUTHORIZED') {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = 'UnauthorizedError'
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}))
|
|
24
|
+
vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: async () => ({}) }))
|
|
25
|
+
|
|
26
|
+
import { Route } from '../pull'
|
|
27
|
+
import { UnauthorizedError } from '@/server/auth-helpers'
|
|
28
|
+
|
|
29
|
+
type Handler = (ctx: { request: Request }) => Promise<Response>
|
|
30
|
+
const POST = (Route.options.server as { handlers?: { POST?: Handler } } | undefined)?.handlers?.POST
|
|
31
|
+
if (!POST) throw new Error('POST handler missing')
|
|
32
|
+
|
|
33
|
+
const post = (body: unknown = { scopes: [], lastSeq: 0 }) =>
|
|
34
|
+
POST({
|
|
35
|
+
request: new Request('https://allblue.test/api/sync/pull', {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
body: JSON.stringify(body),
|
|
38
|
+
}),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.clearAllMocks()
|
|
43
|
+
mocks.requireAuth.mockResolvedValue({ user: { id: 'u1' } })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('/api/sync/pull', () => {
|
|
47
|
+
it('未登录(UnauthorizedError)→ 401 + { error: "unauthorized" }', async () => {
|
|
48
|
+
mocks.requireAuth.mockRejectedValue(new UnauthorizedError())
|
|
49
|
+
|
|
50
|
+
const res = await post()
|
|
51
|
+
|
|
52
|
+
expect(res.status).toBe(401)
|
|
53
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' })
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('未登录(裸 Error("UNAUTHORIZED"),跨实例兜底)→ 401', async () => {
|
|
57
|
+
mocks.requireAuth.mockRejectedValue(new Error('UNAUTHORIZED'))
|
|
58
|
+
|
|
59
|
+
const res = await post()
|
|
60
|
+
|
|
61
|
+
expect(res.status).toBe(401)
|
|
62
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('非鉴权异常(scopes/lastSeq 缺失,schema 校验失败)→ 原样上抛,不吞成响应', async () => {
|
|
66
|
+
await expect(post({})).rejects.toMatchObject({ name: 'ZodError' })
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// /api/sync/push 未登录态回归哨兵(sync 族状态码对齐):requireAuth 抛
|
|
6
|
+
// UnauthorizedError 必须映射 401 + { error: 'unauthorized' }——此前 handler 内
|
|
7
|
+
// 裸调 requireAuth,异常直上抛由框架统一回 500,客户端把「会话过期」误判成
|
|
8
|
+
// 服务端故障并在同步循环里反复重试。非鉴权异常必须原样上抛(不吞)。
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
const mocks = vi.hoisted(() => ({ requireAuth: vi.fn() }))
|
|
12
|
+
|
|
13
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
14
|
+
getRequest: () => ({ url: 'https://allblue.test/api/sync/push' }),
|
|
15
|
+
}))
|
|
16
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
17
|
+
requireAuth: mocks.requireAuth,
|
|
18
|
+
UnauthorizedError: class UnauthorizedError extends Error {
|
|
19
|
+
constructor(message = 'UNAUTHORIZED') {
|
|
20
|
+
super(message)
|
|
21
|
+
this.name = 'UnauthorizedError'
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
}))
|
|
25
|
+
vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: async () => ({}) }))
|
|
26
|
+
|
|
27
|
+
import { Route } from '../push'
|
|
28
|
+
import { UnauthorizedError } from '@/server/auth-helpers'
|
|
29
|
+
|
|
30
|
+
type Handler = (ctx: { request: Request }) => Promise<Response>
|
|
31
|
+
const POST = (Route.options.server as { handlers?: { POST?: Handler } } | undefined)?.handlers?.POST
|
|
32
|
+
if (!POST) throw new Error('POST handler missing')
|
|
33
|
+
|
|
34
|
+
const post = (body: unknown = { ops: [] }) =>
|
|
35
|
+
POST({
|
|
36
|
+
request: new Request('https://allblue.test/api/sync/push', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
body: JSON.stringify(body),
|
|
39
|
+
}),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks()
|
|
44
|
+
mocks.requireAuth.mockResolvedValue({ user: { id: 'u1' } })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('/api/sync/push', () => {
|
|
48
|
+
it('未登录(UnauthorizedError)→ 401 + { error: "unauthorized" }', async () => {
|
|
49
|
+
mocks.requireAuth.mockRejectedValue(new UnauthorizedError())
|
|
50
|
+
|
|
51
|
+
const res = await post()
|
|
52
|
+
|
|
53
|
+
expect(res.status).toBe(401)
|
|
54
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('未登录(裸 Error("UNAUTHORIZED"),跨实例兜底)→ 401', async () => {
|
|
58
|
+
mocks.requireAuth.mockRejectedValue(new Error('UNAUTHORIZED'))
|
|
59
|
+
|
|
60
|
+
const res = await post()
|
|
61
|
+
|
|
62
|
+
expect(res.status).toBe(401)
|
|
63
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' })
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('非鉴权异常(ops 缺失,schema 校验失败)→ 原样上抛,不吞成响应', async () => {
|
|
67
|
+
await expect(post({})).rejects.toMatchObject({ name: 'ZodError' })
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// /api/sync/snapshot 未登录态回归哨兵(sync 族状态码对齐):requireAuth 抛
|
|
6
|
+
// UnauthorizedError 必须映射 401 + { error: 'unauthorized' }(此前裸调直上抛
|
|
7
|
+
// → 500)。非鉴权异常必须原样上抛(不吞)。
|
|
8
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({ requireAuth: vi.fn() }))
|
|
11
|
+
|
|
12
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
13
|
+
getRequest: () => ({ url: 'https://allblue.test/api/sync/snapshot' }),
|
|
14
|
+
}))
|
|
15
|
+
vi.mock('@/server/auth-helpers', () => ({
|
|
16
|
+
requireAuth: mocks.requireAuth,
|
|
17
|
+
UnauthorizedError: class UnauthorizedError extends Error {
|
|
18
|
+
constructor(message = 'UNAUTHORIZED') {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = 'UnauthorizedError'
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
}))
|
|
24
|
+
vi.mock('@allbluecn/database/get-prisma', () => ({ getPrisma: async () => ({}) }))
|
|
25
|
+
|
|
26
|
+
import { Route } from '../snapshot'
|
|
27
|
+
import { UnauthorizedError } from '@/server/auth-helpers'
|
|
28
|
+
|
|
29
|
+
type Handler = (ctx: { request: Request }) => Promise<Response>
|
|
30
|
+
const GET = (Route.options.server as { handlers?: { GET?: Handler } } | undefined)?.handlers?.GET
|
|
31
|
+
if (!GET) throw new Error('GET handler missing')
|
|
32
|
+
|
|
33
|
+
/** table 合法时才会走到业务查询;未登录用例用合法 URL 确保失败点只在鉴权 */
|
|
34
|
+
const call = (table = 'projects') =>
|
|
35
|
+
GET({
|
|
36
|
+
request: new Request(`https://allblue.test/api/sync/snapshot?table=${table}`),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks()
|
|
41
|
+
mocks.requireAuth.mockResolvedValue({ user: { id: 'u1' } })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('/api/sync/snapshot', () => {
|
|
45
|
+
it('未登录(UnauthorizedError)→ 401 + { error: "unauthorized" }', async () => {
|
|
46
|
+
mocks.requireAuth.mockRejectedValue(new UnauthorizedError())
|
|
47
|
+
|
|
48
|
+
const res = await call()
|
|
49
|
+
|
|
50
|
+
expect(res.status).toBe(401)
|
|
51
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' })
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('未登录(裸 Error("UNAUTHORIZED"),跨实例兜底)→ 401', async () => {
|
|
55
|
+
mocks.requireAuth.mockRejectedValue(new Error('UNAUTHORIZED'))
|
|
56
|
+
|
|
57
|
+
const res = await call()
|
|
58
|
+
|
|
59
|
+
expect(res.status).toBe(401)
|
|
60
|
+
expect(await res.json()).toEqual({ error: 'unauthorized' })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('非鉴权异常(缺少 table 参数,schema 校验失败)→ 原样上抛,不吞成响应', async () => {
|
|
64
|
+
await expect(call('')).rejects.toMatchObject({ name: 'ZodError' })
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -9,6 +9,7 @@ import { createFileRoute } from "@tanstack/react-router"
|
|
|
9
9
|
import { getRequest } from "@tanstack/react-start/server"
|
|
10
10
|
import { getPrisma } from "@allbluecn/database/get-prisma"
|
|
11
11
|
import { requireAuth } from "@/server/auth-helpers"
|
|
12
|
+
import { mapSyncAuthError } from "@/server/sync/http-errors"
|
|
12
13
|
import { resolveSyncScopes } from "@/server/sync/scopes"
|
|
13
14
|
import { SYNC_TABLES } from "@allbluecn/sync-core/registrations"
|
|
14
15
|
|
|
@@ -16,22 +17,26 @@ export const Route = createFileRoute("/api/sync/bootstrap")({
|
|
|
16
17
|
server: {
|
|
17
18
|
handlers: {
|
|
18
19
|
GET: async () => {
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
try {
|
|
21
|
+
const session = await requireAuth(getRequest())
|
|
22
|
+
const prisma = await getPrisma()
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
const [{ seq }] = await prisma.$queryRawUnsafe<Array<{ seq: bigint }>>(
|
|
25
|
+
`SELECT COALESCE(MAX(seq), 0) AS seq FROM sync_change_log`,
|
|
26
|
+
)
|
|
27
|
+
const counts = await Promise.all(
|
|
28
|
+
SYNC_TABLES.map(async (reg) => {
|
|
29
|
+
const [r] = await prisma.$queryRawUnsafe<Array<{ c: bigint }>>(
|
|
30
|
+
`SELECT COUNT(*)::bigint AS c FROM ${reg.table}`,
|
|
31
|
+
)
|
|
32
|
+
return { name: reg.table, count: Number(r.c) }
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
void resolveSyncScopes // scopes 解析在快照分页端点逐页执行(此处仅鉴权)
|
|
36
|
+
return Response.json({ watermarkSeq: Number(seq), tables: counts })
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return mapSyncAuthError(e)
|
|
39
|
+
}
|
|
35
40
|
},
|
|
36
41
|
},
|
|
37
42
|
},
|
|
@@ -9,6 +9,7 @@ import { getRequest } from "@tanstack/react-start/server"
|
|
|
9
9
|
import { z } from "zod"
|
|
10
10
|
import { getPrisma } from "@allbluecn/database/get-prisma"
|
|
11
11
|
import { requireAuth } from "@/server/auth-helpers"
|
|
12
|
+
import { mapSyncAuthError } from "@/server/sync/http-errors"
|
|
12
13
|
import { resolveSyncScopes } from "@/server/sync/scopes"
|
|
13
14
|
|
|
14
15
|
const pullSchema = z.object({
|
|
@@ -23,73 +24,77 @@ export const Route = createFileRoute("/api/sync/pull")({
|
|
|
23
24
|
server: {
|
|
24
25
|
handlers: {
|
|
25
26
|
POST: async ({ request }) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
try {
|
|
28
|
+
const session = await requireAuth(getRequest())
|
|
29
|
+
const userId = session.user.id
|
|
30
|
+
const body = pullSchema.parse(await request.json())
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
const prisma = await getPrisma()
|
|
33
|
+
const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
|
|
34
|
+
// scopes 服务端权威解析(客户端回显值仅作对账提示,不参与过滤)
|
|
35
|
+
const scopes = await resolveSyncScopes(prisma, userId)
|
|
36
|
+
const limit = Math.min(body.limit ?? PAGE_LIMIT, PAGE_LIMIT)
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const logRows = await prisma.$queryRawUnsafe<
|
|
39
|
+
Array<{ seq: bigint; table_name: string; pk: string; op: string }>
|
|
40
|
+
>(
|
|
41
|
+
`SELECT seq, table_name, pk, op FROM sync_change_log
|
|
40
42
|
WHERE seq > $1 AND scope = ANY($2)
|
|
41
43
|
ORDER BY seq ASC
|
|
42
44
|
LIMIT $3`,
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
body.lastSeq,
|
|
46
|
+
scopes,
|
|
47
|
+
limit + 1,
|
|
48
|
+
)
|
|
49
|
+
const hasMore = logRows.length > limit
|
|
50
|
+
const page = hasMore ? logRows.slice(0, limit) : logRows
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
const changes: Array<Record<string, unknown>> = []
|
|
53
|
+
for (const row of page) {
|
|
54
|
+
const reg = SYNC_TABLE_MAP.get(row.table_name)
|
|
55
|
+
if (!reg) continue // 未注册表(历史注册被移除)跳过
|
|
56
|
+
const change: Record<string, unknown> = {
|
|
57
|
+
table: row.table_name,
|
|
58
|
+
pk: row.pk,
|
|
59
|
+
op: row.op,
|
|
60
|
+
}
|
|
61
|
+
if (row.op === "upsert") {
|
|
62
|
+
// pk 来自 change_log(客户端 push 时写入)——键必须经注册表 pkColumns
|
|
63
|
+
// 白名单过滤(注入面收口),值参数化
|
|
64
|
+
const params: unknown[] = []
|
|
65
|
+
let where: string
|
|
66
|
+
if (reg.pkColumns.length === 1) {
|
|
67
|
+
params.push(row.pk)
|
|
68
|
+
where = `"${reg.pkColumns[0]}" = $1`
|
|
69
|
+
} else {
|
|
70
|
+
const parsed = JSON.parse(row.pk) as Record<string, string>
|
|
71
|
+
where = reg.pkColumns
|
|
72
|
+
.map((k) => {
|
|
73
|
+
params.push(parsed[k])
|
|
74
|
+
return `"${k}" = $${params.length}`
|
|
75
|
+
})
|
|
76
|
+
.join(" AND ")
|
|
77
|
+
}
|
|
78
|
+
const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
|
|
79
|
+
`SELECT ${reg.columns.map((c) => `"${c}"`).join(", ")}, sync_version FROM ${reg.table} WHERE ${where}`,
|
|
80
|
+
...params,
|
|
81
|
+
)
|
|
82
|
+
if (rows.length === 0) continue // 行已删(change_log 略滞后),下轮 delete 事件收敛
|
|
83
|
+
const payload: Record<string, unknown> = {}
|
|
84
|
+
for (const c of reg.columns) payload[c] = rows[0][c] ?? null
|
|
85
|
+
change.payload = payload
|
|
86
|
+
change.syncVersion = Number(rows[0].sync_version ?? 0)
|
|
67
87
|
} else {
|
|
68
|
-
|
|
69
|
-
where = reg.pkColumns
|
|
70
|
-
.map((k) => {
|
|
71
|
-
params.push(parsed[k])
|
|
72
|
-
return `"${k}" = $${params.length}`
|
|
73
|
-
})
|
|
74
|
-
.join(" AND ")
|
|
88
|
+
change.syncVersion = 0
|
|
75
89
|
}
|
|
76
|
-
|
|
77
|
-
`SELECT ${reg.columns.map((c) => `"${c}"`).join(", ")}, sync_version FROM ${reg.table} WHERE ${where}`,
|
|
78
|
-
...params,
|
|
79
|
-
)
|
|
80
|
-
if (rows.length === 0) continue // 行已删(change_log 略滞后),下轮 delete 事件收敛
|
|
81
|
-
const payload: Record<string, unknown> = {}
|
|
82
|
-
for (const c of reg.columns) payload[c] = rows[0][c] ?? null
|
|
83
|
-
change.payload = payload
|
|
84
|
-
change.syncVersion = Number(rows[0].sync_version ?? 0)
|
|
85
|
-
} else {
|
|
86
|
-
change.syncVersion = 0
|
|
90
|
+
changes.push(change)
|
|
87
91
|
}
|
|
88
|
-
changes.push(change)
|
|
89
|
-
}
|
|
90
92
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
const nextSeq = page.length ? Number(page[page.length - 1].seq) : body.lastSeq
|
|
94
|
+
return Response.json({ changes, nextSeq, hasMore })
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return mapSyncAuthError(e)
|
|
97
|
+
}
|
|
93
98
|
},
|
|
94
99
|
},
|
|
95
100
|
},
|
|
@@ -15,6 +15,7 @@ import { getRequest } from "@tanstack/react-start/server"
|
|
|
15
15
|
import { z } from "zod"
|
|
16
16
|
import { getPrisma } from "@allbluecn/database/get-prisma"
|
|
17
17
|
import { requireAuth } from "@/server/auth-helpers"
|
|
18
|
+
import { mapSyncAuthError } from "@/server/sync/http-errors"
|
|
18
19
|
import { resolveSyncScopes } from "@/server/sync/scopes"
|
|
19
20
|
|
|
20
21
|
const pushSchema = z.object({
|
|
@@ -82,72 +83,76 @@ export const Route = createFileRoute("/api/sync/push")({
|
|
|
82
83
|
server: {
|
|
83
84
|
handlers: {
|
|
84
85
|
POST: async ({ request }) => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
try {
|
|
87
|
+
const session = await requireAuth(getRequest())
|
|
88
|
+
const userId = session.user.id
|
|
89
|
+
const body = pushSchema.parse(await request.json())
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
91
|
+
const prisma = await getPrisma()
|
|
92
|
+
const { applyOp, admitScopeForOp } = await import("@allbluecn/sync-core")
|
|
93
|
+
const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
|
|
94
|
+
const scopes = await resolveSyncScopes(prisma, userId)
|
|
95
|
+
// 首次上云的 scope 动态准入(C1 / 裁定 R5):空云账号的 scopes 只有
|
|
96
|
+
// user:<id>,而 outbox 的容器行 scope = 自身 id、子行 scope = 父容器 id。
|
|
97
|
+
// 逐 op 按「属主自证」累积准入集合(同一 Set 穿过整批 op,依赖 outbox
|
|
98
|
+
// 的父先子后拓扑序),再交给 applyOp 做行级校验——准入只覆盖自证行,
|
|
99
|
+
// 不放宽到整表(边界见 @allbluecn/sync-core 的 scope-admission 头注释)。
|
|
100
|
+
const admittedScopes = new Set(scopes)
|
|
99
101
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
const out: Array<Record<string, unknown>> = []
|
|
109
|
-
for (const op of body.ops) {
|
|
110
|
-
const reg = SYNC_TABLE_MAP.get(op.table)
|
|
111
|
-
if (!reg) {
|
|
112
|
-
out.push({ opId: op.opId, status: "conflict", conflict: { reason: "unknownTable" } })
|
|
113
|
-
continue
|
|
102
|
+
// 整批单事务:逐 op 应用(依赖拓扑由客户端 outbox 顺序保证)
|
|
103
|
+
const results = await prisma.$transaction(async (tx) => {
|
|
104
|
+
const executor = {
|
|
105
|
+
query: <T = Record<string, unknown>>(sql: string, params: unknown[]) =>
|
|
106
|
+
tx.$queryRawUnsafe<T[]>(sql, ...params),
|
|
107
|
+
execute: (sql: string, params: unknown[]) =>
|
|
108
|
+
tx.$executeRawUnsafe(sql, ...params),
|
|
114
109
|
}
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
renamed: { from: oldIdentifier ?? "", to: newIdentifier },
|
|
110
|
+
const out: Array<Record<string, unknown>> = []
|
|
111
|
+
for (const op of body.ops) {
|
|
112
|
+
const reg = SYNC_TABLE_MAP.get(op.table)
|
|
113
|
+
if (!reg) {
|
|
114
|
+
out.push({ opId: op.opId, status: "conflict", conflict: { reason: "unknownTable" } })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
const payload = op.payload as SyncPayload | undefined
|
|
118
|
+
await admitScopeForOp(executor, reg, { pk: op.pk, op: op.op, payload }, userId, admittedScopes)
|
|
119
|
+
const ctxScopes = [...admittedScopes]
|
|
120
|
+
let outcome = await applyOp(executor, reg, { ...op, payload }, {
|
|
121
|
+
userId,
|
|
122
|
+
scopes: ctxScopes,
|
|
123
|
+
now: new Date(),
|
|
124
|
+
})
|
|
125
|
+
// 编号重发号特例:stories/bugs 撞 identifier 唯一键 → 服务端重发号重试
|
|
126
|
+
if (outcome.kind === "conflict" && outcome.reason === "uniqueViolation" && (op.table === "stories" || op.table === "bugs") && payload) {
|
|
127
|
+
const oldIdentifier = payload.identifier as string | undefined
|
|
128
|
+
const newIdentifier = await reissueIdentifier(tx, op.table, payload)
|
|
129
|
+
if (newIdentifier && newIdentifier !== oldIdentifier) {
|
|
130
|
+
outcome = await applyOp(executor, reg, { ...op, payload }, {
|
|
131
|
+
userId,
|
|
132
|
+
scopes: ctxScopes,
|
|
133
|
+
now: new Date(),
|
|
140
134
|
})
|
|
141
|
-
|
|
135
|
+
if (outcome.kind === "applied") {
|
|
136
|
+
out.push({
|
|
137
|
+
opId: op.opId,
|
|
138
|
+
status: "applied",
|
|
139
|
+
syncVersion: outcome.syncVersion,
|
|
140
|
+
row: outcome.row,
|
|
141
|
+
renamed: { from: oldIdentifier ?? "", to: newIdentifier },
|
|
142
|
+
})
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
142
145
|
}
|
|
143
146
|
}
|
|
147
|
+
out.push(mapOutcome(op.opId, outcome))
|
|
144
148
|
}
|
|
145
|
-
out
|
|
146
|
-
}
|
|
147
|
-
return out
|
|
148
|
-
})
|
|
149
|
+
return out
|
|
150
|
+
})
|
|
149
151
|
|
|
150
|
-
|
|
152
|
+
return Response.json({ results })
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return mapSyncAuthError(e)
|
|
155
|
+
}
|
|
151
156
|
},
|
|
152
157
|
},
|
|
153
158
|
},
|
|
@@ -10,6 +10,7 @@ import { getRequest } from "@tanstack/react-start/server"
|
|
|
10
10
|
import { z } from "zod"
|
|
11
11
|
import { getPrisma } from "@allbluecn/database/get-prisma"
|
|
12
12
|
import { requireAuth } from "@/server/auth-helpers"
|
|
13
|
+
import { mapSyncAuthError } from "@/server/sync/http-errors"
|
|
13
14
|
import { resolveSyncScopes } from "@/server/sync/scopes"
|
|
14
15
|
import { SYNC_TABLE_MAP } from "@allbluecn/sync-core/registrations"
|
|
15
16
|
|
|
@@ -25,79 +26,83 @@ export const Route = createFileRoute("/api/sync/snapshot")({
|
|
|
25
26
|
server: {
|
|
26
27
|
handlers: {
|
|
27
28
|
GET: async ({ request }) => {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
try {
|
|
30
|
+
const session = await requireAuth(getRequest())
|
|
31
|
+
const userId = session.user.id
|
|
32
|
+
const url = new URL(request.url)
|
|
33
|
+
const body = snapshotSchema.parse({
|
|
34
|
+
table: url.searchParams.get("table") ?? undefined,
|
|
35
|
+
cursor: url.searchParams.get("cursor") ?? undefined,
|
|
36
|
+
limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
|
37
|
+
})
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
const reg = SYNC_TABLE_MAP.get(body.table)
|
|
40
|
+
if (!reg) {
|
|
41
|
+
return Response.json({ error: "unknown table" }, { status: 400 })
|
|
42
|
+
}
|
|
43
|
+
const prisma = await getPrisma()
|
|
44
|
+
const scopes = await resolveSyncScopes(prisma, userId)
|
|
45
|
+
const scopeColumn = reg.scope.kind === "self" ? reg.scope.column ?? "id" : reg.scope.column!
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
if (body.cursor) {
|
|
53
|
-
const cursorKeys = JSON.parse(body.cursor) as Record<string, string>
|
|
54
|
-
// (pk) > (cursor) 行比较——单列与复合分别构造
|
|
55
|
-
if (reg.pkColumns.length === 1) {
|
|
56
|
-
params.push(cursorKeys[reg.pkColumns[0]])
|
|
57
|
-
where.push(`"${reg.pkColumns[0]}" > $${params.length}`)
|
|
58
|
-
} else {
|
|
59
|
-
const tuples = reg.pkColumns
|
|
60
|
-
.map((k) => {
|
|
61
|
-
params.push(cursorKeys[k])
|
|
62
|
-
return `$${params.length}`
|
|
63
|
-
})
|
|
64
|
-
.join(", ")
|
|
65
|
-
const cols = reg.pkColumns.map((k) => `"${k}"`).join(", ")
|
|
66
|
-
where.push(`(${cols}) > (${tuples})`)
|
|
47
|
+
// 行级 scope 过滤 + (pk) 序分页;参数化拼接(列名来自注册表白名单)
|
|
48
|
+
const params: unknown[] = []
|
|
49
|
+
const where: string[] = []
|
|
50
|
+
if (reg.scope.kind !== "self") {
|
|
51
|
+
params.push(scopes.map((s) => s.replace(/^(ws|proj):/, "")))
|
|
52
|
+
where.push(`"${reg.scope.column!}" = ANY($${params.length})`)
|
|
67
53
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
54
|
+
if (body.cursor) {
|
|
55
|
+
const cursorKeys = JSON.parse(body.cursor) as Record<string, string>
|
|
56
|
+
// (pk) > (cursor) 行比较——单列与复合分别构造
|
|
57
|
+
if (reg.pkColumns.length === 1) {
|
|
58
|
+
params.push(cursorKeys[reg.pkColumns[0]])
|
|
59
|
+
where.push(`"${reg.pkColumns[0]}" > $${params.length}`)
|
|
60
|
+
} else {
|
|
61
|
+
const tuples = reg.pkColumns
|
|
62
|
+
.map((k) => {
|
|
63
|
+
params.push(cursorKeys[k])
|
|
64
|
+
return `$${params.length}`
|
|
65
|
+
})
|
|
66
|
+
.join(", ")
|
|
67
|
+
const cols = reg.pkColumns.map((k) => `"${k}"`).join(", ")
|
|
68
|
+
where.push(`(${cols}) > (${tuples})`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const limit = Math.min(body.limit ?? PAGE_LIMIT, PAGE_LIMIT)
|
|
72
|
+
const orderBy = [...reg.pkColumns].map((k) => `"${k}" ASC`).join(", ")
|
|
71
73
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
|
|
75
|
+
`SELECT ${reg.columns.map((c) => `"${c}"`).join(", ")}, sync_version FROM ${reg.table}
|
|
74
76
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
75
77
|
ORDER BY ${orderBy}
|
|
76
78
|
LIMIT ${limit + 1}`,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
79
|
+
...params,
|
|
80
|
+
)
|
|
81
|
+
const hasMore = rows.length > limit
|
|
82
|
+
const page = hasMore ? rows.slice(0, limit) : rows
|
|
83
|
+
const items = page.map((r) => {
|
|
84
|
+
const payload: Record<string, unknown> = {}
|
|
85
|
+
for (const c of reg.columns) payload[c] = r[c] ?? null
|
|
86
|
+
const pk =
|
|
87
|
+
reg.pkColumns.length === 1
|
|
88
|
+
? String(r[reg.pkColumns[0]])
|
|
89
|
+
: JSON.stringify(Object.fromEntries(reg.pkColumns.map((k) => [k, String(r[k])])))
|
|
90
|
+
return { pk, payload, syncVersion: Number((r as { sync_version?: number }).sync_version ?? 1) }
|
|
91
|
+
})
|
|
92
|
+
const nextCursor = page.length
|
|
93
|
+
? reg.pkColumns.length === 1
|
|
94
|
+
? String(page[page.length - 1][reg.pkColumns[0]])
|
|
95
|
+
: JSON.stringify(Object.fromEntries(reg.pkColumns.map((k) => [k, String(page[page.length - 1][k])])))
|
|
96
|
+
: null
|
|
97
|
+
// nextCursor 包 JSON 时以 base64 走 query 参数(复合键含花括号引号)
|
|
98
|
+
return Response.json({
|
|
99
|
+
rows: items,
|
|
100
|
+
nextCursor: nextCursor === null ? null : reg.pkColumns.length === 1 ? nextCursor : Buffer.from(nextCursor).toString("base64"),
|
|
101
|
+
hasMore,
|
|
102
|
+
})
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return mapSyncAuthError(e)
|
|
105
|
+
}
|
|
101
106
|
},
|
|
102
107
|
},
|
|
103
108
|
},
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// sync 族 HTTP 路由(server.handlers)未登录错误的**单一映射来源**。
|
|
6
|
+
//
|
|
7
|
+
// 背景:server.handlers 不走 serverFn 的 authContextMiddleware 链,requireAuth 抛的
|
|
8
|
+
// UnauthorizedError 若在 handler 内未捕获会直上抛 → 框架统一回 500。同族的
|
|
9
|
+
// account-status 与 attachments/content 已把未登录映射为 401,而 push/pull/
|
|
10
|
+
// bootstrap/snapshot 长期裸调 requireAuth(族内状态码不一致),客户端会把
|
|
11
|
+
// 「会话过期」误判为服务端故障并在同步循环里反复重试。
|
|
12
|
+
//
|
|
13
|
+
// 判定含 message 兜底:跨模块实例(双份 module / 测试 mock 的 UnauthorizedError)
|
|
14
|
+
// 与裸 `new Error('UNAUTHORIZED')` 抛点都不满足 instanceof,但语义相同——与
|
|
15
|
+
// account-status / attachments content 的判定口径逐字一致。
|
|
16
|
+
import { UnauthorizedError } from "@/server/auth-helpers"
|
|
17
|
+
|
|
18
|
+
/** 是否「未登录」错误(instanceof 优先,UNAUTHORIZED 消息兜底防实例不一致漏判) */
|
|
19
|
+
export function isUnauthorizedError(e: unknown): boolean {
|
|
20
|
+
return (
|
|
21
|
+
e instanceof UnauthorizedError ||
|
|
22
|
+
(e instanceof Error && e.message === "UNAUTHORIZED")
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 未登录统一响应:401 + JSON 信封 `{ error: 'unauthorized' }` */
|
|
27
|
+
export function unauthorizedResponse(): Response {
|
|
28
|
+
return Response.json({ error: "unauthorized" }, { status: 401 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* sync 路由错误收口:未登录 → 401 响应;**其余异常原样上抛**(不吞、不降级为
|
|
33
|
+
* 500 响应,保留原始堆栈交由框架/上层日志处理)。
|
|
34
|
+
*
|
|
35
|
+
* 用法(handler 体整段包住,深层抛出的 UnauthorizedError 同样被映射):
|
|
36
|
+
* ```ts
|
|
37
|
+
* try { ... } catch (e) { return mapSyncAuthError(e) }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function mapSyncAuthError(e: unknown): Response {
|
|
41
|
+
if (isUnauthorizedError(e)) return unauthorizedResponse()
|
|
42
|
+
throw e
|
|
43
|
+
}
|