@allbluecn/web-app 0.23.5 → 0.23.7
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/components/layout/sidebar-03/__tests__/user-menu-cloud-logout.test.tsx +185 -0
- package/src/components/layout/sidebar-03/user-menu.tsx +27 -50
- package/src/components/shared-kit/auth/auth-client.ts +4 -0
- package/src/lib/__tests__/desktop-logout.test.ts +239 -0
- package/src/lib/__tests__/session.test.ts +85 -2
- package/src/lib/auth/__tests__/page-gate.test.ts +37 -1
- package/src/lib/auth/anonymous-safe.ts +5 -0
- package/src/lib/auth/page-gate.ts +11 -1
- 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/lib/desktop-env.ts +25 -0
- package/src/lib/desktop-logout.ts +117 -0
- package/src/lib/session.ts +70 -0
- package/src/routes/__tests__/-edition-routing.test.tsx +48 -7
- package/src/routes/__tests__/-login-admin-redirect.test.ts +99 -0
- package/src/routes/_login/login/route.tsx +18 -3
- package/src/routes/_nav/design/canvas/$id/route.tsx +5 -0
- package/src/routes/_nav/knowledge/$kbId/route.tsx +6 -2
- package/src/routes/_nav/route.tsx +5 -1
- package/src/routes/_nav/settings/account/route.tsx +5 -0
- package/src/routes/_nav/settings/team/route.tsx +5 -0
- package/src/routes/api/auth/$.ts +87 -2
- package/src/routes/api/auth/__tests__/auth-session-desktop.test.ts +194 -0
- 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/desktop-guest.ts +5 -1
- package/src/server/serverFns/auth/session.ts +19 -10
- package/src/server/sync/http-errors.ts +43 -0
|
@@ -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
|
},
|
|
@@ -38,5 +38,9 @@ export function buildGuestSession(): Session {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export function isDesktopServer(): boolean {
|
|
41
|
-
|
|
41
|
+
// 本模块被路由模块(如 _nav/route.tsx 的 beforeLoad)在客户端一并加载:
|
|
42
|
+
// 浏览器 bundle 无 Node process(或 env 被静态替换为空对象)时必须安全
|
|
43
|
+
// 返回 false——客户端侧的桌面判定走 lib/desktop-env.ts 的 isDesktopClient()
|
|
44
|
+
// (desktopBridge 运行时事实),两者在 beforeLoad 里合并使用。
|
|
45
|
+
return typeof process !== 'undefined' && process.env?.DEPLOY_TARGET === 'desktop'
|
|
42
46
|
}
|
|
@@ -22,7 +22,7 @@ import type { FeatureKey } from '@allbluecn/kernel'
|
|
|
22
22
|
import { getPrisma } from '@allbluecn/database/get-prisma'
|
|
23
23
|
import { getCurrentSession } from '@/lib/session'
|
|
24
24
|
import { resetProfileCache } from '@/lib/auth/auth.config'
|
|
25
|
-
import { buildGuestSession, isDesktopServer } from '@/server/desktop-guest'
|
|
25
|
+
import { buildGuestSession, isDesktopServer, isGuestSession } from '@/server/desktop-guest'
|
|
26
26
|
import { ensureLocalShadowUser } from '@/server/desktop-bootstrap'
|
|
27
27
|
import { ensureCloudIdentity } from '@/server/desktop-cloud-identity'
|
|
28
28
|
import { readRemoteAuth, writeRemoteAuth, writeRemoteSession, clearRemoteAuth } from '@/server/remote-auth'
|
|
@@ -53,22 +53,31 @@ const saasProfileSchema = z.object({
|
|
|
53
53
|
// getCurrentSession 桌面回落需复用同一组装,且 lib/session 不得 import
|
|
54
54
|
// serverFns/**(循环依赖),组装点移至 server 域纯模块。
|
|
55
55
|
|
|
56
|
+
/** 桌面 guest 懒 seed 影子用户:失败仅 warn 不阻断会话发放(首启体验优先,规格 §3.1) */
|
|
57
|
+
async function ensureGuestSeeded(): Promise<void> {
|
|
58
|
+
try {
|
|
59
|
+
await ensureLocalShadowUser()
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn('[getSessionFn] 本地影子用户落库失败(不阻断 guest 会话):', err)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
export const getSessionFn = createServerFn({ method: 'GET' })
|
|
57
66
|
.handler(async () => {
|
|
58
67
|
const request = getRequest()
|
|
59
68
|
const session = await getCurrentSession(request)
|
|
60
|
-
if (session?.user?.id)
|
|
69
|
+
if (session?.user?.id) {
|
|
70
|
+
// getCurrentSession 已含桌面 guest 回落(鉴权单点):guest 首启仍需懒 seed
|
|
71
|
+
// 本地影子用户(本地优先 P1),使 assertOwned/requireUserInDb/FK 全链可写。
|
|
72
|
+
// 失败仅 warn 不阻断会话发放——首启体验优先(规格 §3.1),下一请求再重试。
|
|
73
|
+
if (isGuestSession(session)) await ensureGuestSeeded()
|
|
74
|
+
return session
|
|
75
|
+
}
|
|
61
76
|
if (!isDesktopServer()) return null
|
|
62
77
|
const remoteAuth = await readRemoteAuth()
|
|
63
78
|
if (remoteAuth) return buildRemoteSession(remoteAuth)
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
// ——首启体验优先(规格 §3.1),下一请求再重试。
|
|
67
|
-
try {
|
|
68
|
-
await ensureLocalShadowUser()
|
|
69
|
-
} catch (err) {
|
|
70
|
-
console.warn('[getSessionFn] 本地影子用户落库失败(不阻断 guest 会话):', err)
|
|
71
|
-
}
|
|
79
|
+
// 兜底分支(getCurrentSession 已覆盖 guest,此处防御未来改动):保持旧行为
|
|
80
|
+
await ensureGuestSeeded()
|
|
72
81
|
return buildGuestSession()
|
|
73
82
|
})
|
|
74
83
|
|
|
@@ -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
|
+
}
|