@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.23.5",
3
+ "version": "0.23.6",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -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 懒取 appInfo 的
8
- // agentPort/agentToken(backend 重启换端口/令牌后下一问即用新值),引用稳定。
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.5",
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):appInfo 调用时懒取 agentPort/agentToken
6
- // → 真实 fetchServerSentEvents 直连 http://127.0.0.1:{port}/prompt。fetch 打桩、
7
- // SSE 解析走真实 @tanstack/ai-client 实现——请求线格式即与 Pro 端点的跨仓契约:
8
- // URL / X-Desktop-Token / X-Run-Id 头 / forwardedProps.cwd(端点 extractCwd 取值位)。
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
- const bridgeRef: { current: { appInfo: () => Promise<AppInfo> } | null } = { current: null }
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('每次 connect 都重取 appInfo(backend 重启换端口/令牌后下一问即用新值)', async () => {
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
- // desktopBridge.appInfo() 的 agentPort/agentToken 广播——backend 重启即换新值
10
- // (main 侧 backend exit 清空),所以**每次请求时懒取**,不在组件顶层静态拼 URL。
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 的 appInfo。包装只做「await appInfo → 构造 fetchServerSentEvents →
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
- * agent 未装配(DESKTOP_AGENT 门控未开 / bundle 装配失败 → agentPort null) */
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
- /** 调用时读取当前 agent 端点;不可用返回 null(端口/令牌非法同样视为不可用,不拼坏 URL) */
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
- // main 未就绪 / IPC 断开时 appInfo 会抛错:归「服务未就绪」(不可用 → 上层抛
45
- // DesktopAgentUnavailableError),不把原生错误原文当「运行失败:<原文>」透出
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
- const { agentPort, agentToken } = info
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
- const session = await requireAuth(getRequest())
20
- const prisma = await getPrisma()
20
+ try {
21
+ const session = await requireAuth(getRequest())
22
+ const prisma = await getPrisma()
21
23
 
22
- const [{ seq }] = await prisma.$queryRawUnsafe<Array<{ seq: bigint }>>(
23
- `SELECT COALESCE(MAX(seq), 0) AS seq FROM sync_change_log`,
24
- )
25
- const counts = await Promise.all(
26
- SYNC_TABLES.map(async (reg) => {
27
- const [r] = await prisma.$queryRawUnsafe<Array<{ c: bigint }>>(
28
- `SELECT COUNT(*)::bigint AS c FROM ${reg.table}`,
29
- )
30
- return { name: reg.table, count: Number(r.c) }
31
- }),
32
- )
33
- void resolveSyncScopes // scopes 解析在快照分页端点逐页执行(此处仅鉴权)
34
- return Response.json({ watermarkSeq: Number(seq), tables: counts })
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
- const session = await requireAuth(getRequest())
27
- const userId = session.user.id
28
- const body = pullSchema.parse(await request.json())
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
- const prisma = await getPrisma()
31
- const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
32
- // scopes 服务端权威解析(客户端回显值仅作对账提示,不参与过滤)
33
- const scopes = await resolveSyncScopes(prisma, userId)
34
- const limit = Math.min(body.limit ?? PAGE_LIMIT, PAGE_LIMIT)
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
- const logRows = await prisma.$queryRawUnsafe<
37
- Array<{ seq: bigint; table_name: string; pk: string; op: string }>
38
- >(
39
- `SELECT seq, table_name, pk, op FROM sync_change_log
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
- body.lastSeq,
44
- scopes,
45
- limit + 1,
46
- )
47
- const hasMore = logRows.length > limit
48
- const page = hasMore ? logRows.slice(0, limit) : logRows
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
- const changes: Array<Record<string, unknown>> = []
51
- for (const row of page) {
52
- const reg = SYNC_TABLE_MAP.get(row.table_name)
53
- if (!reg) continue // 未注册表(历史注册被移除)跳过
54
- const change: Record<string, unknown> = {
55
- table: row.table_name,
56
- pk: row.pk,
57
- op: row.op,
58
- }
59
- if (row.op === "upsert") {
60
- // pk 来自 change_log(客户端 push 时写入)——键必须经注册表 pkColumns
61
- // 白名单过滤(注入面收口),值参数化
62
- const params: unknown[] = []
63
- let where: string
64
- if (reg.pkColumns.length === 1) {
65
- params.push(row.pk)
66
- where = `"${reg.pkColumns[0]}" = $1`
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
- const parsed = JSON.parse(row.pk) as Record<string, string>
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
- const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
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
- const nextSeq = page.length ? Number(page[page.length - 1].seq) : body.lastSeq
92
- return Response.json({ changes, nextSeq, hasMore })
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
- const session = await requireAuth(getRequest())
86
- const userId = session.user.id
87
- const body = pushSchema.parse(await request.json())
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
- const prisma = await getPrisma()
90
- const { applyOp, admitScopeForOp } = await import("@allbluecn/sync-core")
91
- const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
92
- const scopes = await resolveSyncScopes(prisma, userId)
93
- // 首次上云的 scope 动态准入(C1 / 裁定 R5):空云账号的 scopes 只有
94
- // user:<id>,而 outbox 的容器行 scope = 自身 id、子行 scope = 父容器 id。
95
- // 逐 op 按「属主自证」累积准入集合(同一 Set 穿过整批 op,依赖 outbox
96
- // 的父先子后拓扑序),再交给 applyOp 做行级校验——准入只覆盖自证行,
97
- // 不放宽到整表(边界见 @allbluecn/sync-core 的 scope-admission 头注释)。
98
- const admittedScopes = new Set(scopes)
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
- // 整批单事务:逐 op 应用(依赖拓扑由客户端 outbox 顺序保证)
101
- const results = await prisma.$transaction(async (tx) => {
102
- const executor = {
103
- query: <T = Record<string, unknown>>(sql: string, params: unknown[]) =>
104
- tx.$queryRawUnsafe<T[]>(sql, ...params),
105
- execute: (sql: string, params: unknown[]) =>
106
- tx.$executeRawUnsafe(sql, ...params),
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 payload = op.payload as SyncPayload | undefined
116
- await admitScopeForOp(executor, reg, { pk: op.pk, op: op.op, payload }, userId, admittedScopes)
117
- const ctxScopes = [...admittedScopes]
118
- let outcome = await applyOp(executor, reg, { ...op, payload }, {
119
- userId,
120
- scopes: ctxScopes,
121
- now: new Date(),
122
- })
123
- // 编号重发号特例:stories/bugs 撞 identifier 唯一键 → 服务端重发号重试
124
- if (outcome.kind === "conflict" && outcome.reason === "uniqueViolation" && (op.table === "stories" || op.table === "bugs") && payload) {
125
- const oldIdentifier = payload.identifier as string | undefined
126
- const newIdentifier = await reissueIdentifier(tx, op.table, payload)
127
- if (newIdentifier && newIdentifier !== oldIdentifier) {
128
- outcome = await applyOp(executor, reg, { ...op, payload }, {
129
- userId,
130
- scopes: ctxScopes,
131
- now: new Date(),
132
- })
133
- if (outcome.kind === "applied") {
134
- out.push({
135
- opId: op.opId,
136
- status: "applied",
137
- syncVersion: outcome.syncVersion,
138
- row: outcome.row,
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
- continue
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.push(mapOutcome(op.opId, outcome))
146
- }
147
- return out
148
- })
149
+ return out
150
+ })
149
151
 
150
- return Response.json({ results })
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
- const session = await requireAuth(getRequest())
29
- const userId = session.user.id
30
- const url = new URL(request.url)
31
- const body = snapshotSchema.parse({
32
- table: url.searchParams.get("table") ?? undefined,
33
- cursor: url.searchParams.get("cursor") ?? undefined,
34
- limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : undefined,
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
- const reg = SYNC_TABLE_MAP.get(body.table)
38
- if (!reg) {
39
- return Response.json({ error: "unknown table" }, { status: 400 })
40
- }
41
- const prisma = await getPrisma()
42
- const scopes = await resolveSyncScopes(prisma, userId)
43
- const scopeColumn = reg.scope.kind === "self" ? reg.scope.column ?? "id" : reg.scope.column!
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
- // 行级 scope 过滤 + (pk) 序分页;参数化拼接(列名来自注册表白名单)
46
- const params: unknown[] = []
47
- const where: string[] = []
48
- if (reg.scope.kind !== "self") {
49
- params.push(scopes.map((s) => s.replace(/^(ws|proj):/, "")))
50
- where.push(`"${reg.scope.column!}" = ANY($${params.length})`)
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
- const limit = Math.min(body.limit ?? PAGE_LIMIT, PAGE_LIMIT)
70
- const orderBy = [...reg.pkColumns].map((k) => `"${k}" ASC`).join(", ")
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
- const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
73
- `SELECT ${reg.columns.map((c) => `"${c}"`).join(", ")}, sync_version FROM ${reg.table}
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
- ...params,
78
- )
79
- const hasMore = rows.length > limit
80
- const page = hasMore ? rows.slice(0, limit) : rows
81
- const items = page.map((r) => {
82
- const payload: Record<string, unknown> = {}
83
- for (const c of reg.columns) payload[c] = r[c] ?? null
84
- const pk =
85
- reg.pkColumns.length === 1
86
- ? String(r[reg.pkColumns[0]])
87
- : JSON.stringify(Object.fromEntries(reg.pkColumns.map((k) => [k, String(r[k])])))
88
- return { pk, payload, syncVersion: Number((r as { sync_version?: number }).sync_version ?? 1) }
89
- })
90
- const nextCursor = page.length
91
- ? reg.pkColumns.length === 1
92
- ? String(page[page.length - 1][reg.pkColumns[0]])
93
- : JSON.stringify(Object.fromEntries(reg.pkColumns.map((k) => [k, String(page[page.length - 1][k])])))
94
- : null
95
- // nextCursor 包 JSON 时以 base64 走 query 参数(复合键含花括号引号)
96
- return Response.json({
97
- rows: items,
98
- nextCursor: nextCursor === null ? null : reg.pkColumns.length === 1 ? nextCursor : Buffer.from(nextCursor).toString("base64"),
99
- hasMore,
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
+ }