@allbluecn/web-app 0.22.0 → 0.22.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -8
- package/public/logo/allblue-logo-app-icon.svg +1 -1
- package/src/components/changelog/changelog-page.tsx +24 -5
- package/src/components/desktop/cloud-login.tsx +91 -0
- package/src/components/desktop/conflict-center.tsx +148 -0
- package/src/components/desktop/renamed-toast.tsx +23 -0
- package/src/components/desktop/shell-widgets.tsx +19 -0
- package/src/components/desktop/sync-indicator.tsx +60 -0
- package/src/components/desktop/sync-status-card.tsx +60 -0
- package/src/components/layout/sidebar-03/app-sidebar.tsx +38 -3
- package/src/components/shared-kit/avatars/__tests__/avatar-shape.test.ts +17 -1
- package/src/components/shared-kit/avatars/avatar-shape.ts +10 -0
- package/src/components/shared-kit/avatars/user-avatar.tsx +2 -2
- package/src/lib/auth/auth.config.ts +4 -1
- package/src/lib/changelog/sdk-versions.ts +33 -2
- package/src/lib/desktop-bridge-types.ts +30 -0
- package/src/lib/desktop-detect.ts +2 -0
- package/src/lib/vector-store/__tests__/factory.test.ts +0 -7
- package/src/lib/vector-store/index.ts +2 -4
- package/src/paraglide/messages/_index.d.ts +1 -1
- package/src/paraglide/messages/_index.js +32 -16
- package/src/paraglide/messages/common_sidebar_usermenu_loginsaas3.d.ts +1 -1
- package/src/paraglide/messages/common_sidebar_usermenu_loginsaas3.js +3 -3
- package/src/paraglide/messages/en.js +11 -6
- package/src/paraglide/messages/{tasks_ai_desktop_notice.d.ts → settings_tabs_sync.d.ts} +5 -5
- package/src/paraglide/messages/settings_tabs_sync.js +29 -0
- package/src/paraglide/messages/zh.js +11 -6
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +107 -49
- package/src/routes/_nav/route.lazy.tsx +12 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +1 -1
- package/src/routes/_nav/settings/sync/route.lazy.tsx +40 -0
- package/src/routes/_nav/settings/sync/route.tsx +15 -0
- package/src/routes/_nav/tasks/index.lazy.tsx +1 -11
- package/src/routes/api/billing-webhook.ts +6 -2
- package/src/routes/api/sync/bootstrap.ts +38 -0
- package/src/routes/api/sync/pull.ts +96 -0
- package/src/routes/api/sync/push.ts +169 -0
- package/src/routes/api/sync/snapshot.ts +104 -0
- package/src/server/__tests__/fetch-guard.test.ts +2 -2
- package/src/server/__tests__/resource-registry.test.ts +4 -2
- package/src/server/__tests__/sandbox-entitlement.test.ts +5 -0
- package/src/server/__tests__/security-headers.test.ts +25 -0
- package/src/server/__tests__/task-assigned-query.test.ts +0 -11
- package/src/server/__tests__/task-serverfn-crud.test.ts +1 -22
- package/src/server/__tests__/task-serverfn-queries.test.ts +1 -9
- package/src/server/ai-gateway/__tests__/chat-stream-builder-memory.test.ts +0 -11
- package/src/server/ai-gateway/__tests__/embedding-request.test.ts +1 -1
- package/src/server/ai-gateway/__tests__/provider-adapter.test.ts +2 -2
- package/src/server/ai-gateway/__tests__/run-store-durable.test.ts +1 -1
- package/src/server/ai-gateway/durability/__tests__/pg-stream.test.ts +1 -1
- package/src/server/ai-gateway/durability/__tests__/reaper-real-db.test.ts +1 -1
- package/src/server/ai-gateway/memory/index.ts +2 -2
- package/src/server/ai-gateway/sandbox/__tests__/instance-store.test.ts +1 -1
- package/src/server/ai-task/assigned-query.ts +4 -15
- package/src/server/desktop-guest.ts +12 -0
- package/src/server/fetch-guard.ts +3 -3
- package/src/server/middlewares/desktop-token.ts +23 -0
- package/src/server/remote-auth.ts +6 -0
- package/src/server/serverFns/__tests__/billing.test.ts +58 -1
- package/src/server/serverFns/__tests__/update-seats.test.ts +8 -8
- package/src/server/serverFns/agents/agent-crud.ts +3 -1
- package/src/server/serverFns/auth/session.ts +9 -0
- package/src/server/serverFns/billing.ts +10 -4
- package/src/server/serverFns/bug/bug-crud.ts +7 -2
- package/src/server/serverFns/story/__tests__/labels.test.ts +37 -15
- package/src/server/serverFns/story/story-comments.ts +2 -1
- package/src/server/serverFns/story/story-crud/activities.ts +1 -1
- package/src/server/serverFns/story/story-crud/user-brief.ts +1 -1
- package/src/server/serverFns/story/story-crud.ts +2 -1
- package/src/server/serverFns/story/story-transfer.ts +2 -1
- package/src/server/serverFns/tasks/task-crud.ts +0 -13
- package/src/server/serverFns/tasks/task-queries.ts +1 -12
- package/src/server/serverFns/testcase/plans.ts +1 -1
- package/src/server/serverFns/testcase/run-cases.ts +1 -1
- package/src/server/serverFns/testcase/runs.ts +1 -1
- package/src/server/serverFns/testcase/testcase-activities.ts +1 -1
- package/src/server/serverFns/testcase/testcase-board.ts +1 -1
- package/src/server/serverFns/testcase/testcase-crud.ts +1 -1
- package/src/server/serverFns/workspace.ts +1 -1
- package/src/server/story/types.ts +7 -22
- package/src/server/story/user-brief.ts +48 -0
- package/src/server/sync/scopes.ts +29 -0
- package/src/start.ts +10 -2
- package/src/styles/globals.css +10 -0
- package/src/__tests__/desktop-connection-validate.test.ts +0 -66
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +0 -143
- package/src/lib/vector-store/sqlite-vec-store.ts +0 -224
- package/src/paraglide/messages/tasks_ai_desktop_notice.js +0 -29
- package/src/routes/__desktop/onboarding/route.lazy.tsx +0 -175
- package/src/routes/__desktop/onboarding/route.tsx +0 -11
- package/src/routes/_nav/settings/connection/route.lazy.tsx +0 -64
- package/src/routes/_nav/settings/connection/route.tsx +0 -10
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// pull 端点(迁移设计 §3.2/§3.4):scopes 由服务端从会话解析(客户端只回显
|
|
6
|
+
// 游标,防越权枚举);change_log 按 seq 增量页读,行载荷按注册列白名单投影。
|
|
7
|
+
import { createFileRoute } from "@tanstack/react-router"
|
|
8
|
+
import { getRequest } from "@tanstack/react-start/server"
|
|
9
|
+
import { z } from "zod"
|
|
10
|
+
import { getPrisma } from "@allbluecn/database/get-prisma"
|
|
11
|
+
import { requireAuth } from "@/server/auth-helpers"
|
|
12
|
+
import { resolveSyncScopes } from "@/server/sync/scopes"
|
|
13
|
+
|
|
14
|
+
const pullSchema = z.object({
|
|
15
|
+
scopes: z.array(z.string().min(1).max(256)).max(500),
|
|
16
|
+
lastSeq: z.number().int().min(0),
|
|
17
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const PAGE_LIMIT = 200
|
|
21
|
+
|
|
22
|
+
export const Route = createFileRoute("/api/sync/pull")({
|
|
23
|
+
server: {
|
|
24
|
+
handlers: {
|
|
25
|
+
POST: async ({ request }) => {
|
|
26
|
+
const session = await requireAuth(getRequest())
|
|
27
|
+
const userId = session.user.id
|
|
28
|
+
const body = pullSchema.parse(await request.json())
|
|
29
|
+
|
|
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)
|
|
35
|
+
|
|
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
|
|
40
|
+
WHERE seq > $1 AND scope = ANY($2)
|
|
41
|
+
ORDER BY seq ASC
|
|
42
|
+
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
|
|
49
|
+
|
|
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`
|
|
67
|
+
} 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 ")
|
|
75
|
+
}
|
|
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
|
|
87
|
+
}
|
|
88
|
+
changes.push(change)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const nextSeq = page.length ? Number(page[page.length - 1].seq) : body.lastSeq
|
|
92
|
+
return Response.json({ changes, nextSeq, hasMore })
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 同步 API 三端点(迁移设计 §3.4/§3.8)。鉴权/校验/scopes 解析在服务端,
|
|
6
|
+
// 逐 op 应用走 @allbluecn/sync-core 的 applyOp(整批单事务,按 outbox 顺序
|
|
7
|
+
// 保证父表先于子表)。
|
|
8
|
+
//
|
|
9
|
+
// 编号重发号特例(迁移设计 §3.5,P6):离线双端可能发出同号 identifier——
|
|
10
|
+
// push 撞唯一键(uniqueViolation)且表为 stories/bugs 时,服务端用自家
|
|
11
|
+
// SequenceCounter 重发号并重试应用,结果返回 renamed { from, to }(客户端
|
|
12
|
+
// 提示「编号已调整为 X」)。仅 stories/bugs 支持;applyOp 幂等键不变。
|
|
13
|
+
import { createFileRoute } from "@tanstack/react-router"
|
|
14
|
+
import { getRequest } from "@tanstack/react-start/server"
|
|
15
|
+
import { z } from "zod"
|
|
16
|
+
import { getPrisma } from "@allbluecn/database/get-prisma"
|
|
17
|
+
import { requireAuth } from "@/server/auth-helpers"
|
|
18
|
+
import { resolveSyncScopes } from "@/server/sync/scopes"
|
|
19
|
+
|
|
20
|
+
const pushSchema = z.object({
|
|
21
|
+
ops: z
|
|
22
|
+
.array(
|
|
23
|
+
z.object({
|
|
24
|
+
opId: z.string().min(8).max(64),
|
|
25
|
+
table: z.string().regex(/^[a-z_][a-z0-9_]*$/),
|
|
26
|
+
pk: z.string().min(1).max(512),
|
|
27
|
+
op: z.enum(["upsert", "delete"]),
|
|
28
|
+
payload: z.record(z.string(), z.unknown()).optional(),
|
|
29
|
+
baseVersion: z.number().int().min(0),
|
|
30
|
+
lamport: z.number().int().min(0),
|
|
31
|
+
clientId: z.string().min(1).max(64),
|
|
32
|
+
schemaVersion: z.string().max(64),
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
.min(1)
|
|
36
|
+
.max(100),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
type SyncPayload = Record<string, unknown>
|
|
40
|
+
|
|
41
|
+
import type { Prisma } from "@allbluecn/database"
|
|
42
|
+
type SyncTx = Prisma.TransactionClient
|
|
43
|
+
|
|
44
|
+
/** stories/bugs 编号重发:SequenceCounter 自愈式发号(max 下界自愈),重建 identifier */
|
|
45
|
+
async function reissueIdentifier(
|
|
46
|
+
tx: SyncTx,
|
|
47
|
+
table: "stories" | "bugs",
|
|
48
|
+
payload: SyncPayload,
|
|
49
|
+
): Promise<string | null> {
|
|
50
|
+
const projectId = payload.projectId as string | undefined
|
|
51
|
+
if (!projectId) return null
|
|
52
|
+
const project = await tx.project.findUnique({
|
|
53
|
+
where: { id: projectId },
|
|
54
|
+
select: { displayId: true },
|
|
55
|
+
})
|
|
56
|
+
if (!project) return null
|
|
57
|
+
const prefix =
|
|
58
|
+
(project.displayId ?? "PRJ").replace(/[^a-zA-Z0-9]/g, "").toUpperCase() || "PRJ"
|
|
59
|
+
|
|
60
|
+
const counterScope = table === "bugs" ? `bug:${projectId}` : projectId
|
|
61
|
+
const counter = await tx.sequenceCounter.upsert({
|
|
62
|
+
where: { scope: counterScope },
|
|
63
|
+
create: { scope: counterScope, seq: 1 },
|
|
64
|
+
update: { seq: { increment: 1 } },
|
|
65
|
+
})
|
|
66
|
+
// max 下界自愈(与 createStoryInternal / bug-crud 同口径)
|
|
67
|
+
const maxExisting =
|
|
68
|
+
table === "bugs"
|
|
69
|
+
? ((await tx.bug.aggregate({ where: { projectId }, _max: { sequenceId: true } }))._max.sequenceId ?? 0)
|
|
70
|
+
: ((await tx.story.aggregate({ where: { projectId }, _max: { sequenceId: true } }))._max.sequenceId ?? 0)
|
|
71
|
+
const seq = Math.max(counter.seq, maxExisting + 1)
|
|
72
|
+
if (seq !== counter.seq) {
|
|
73
|
+
await tx.sequenceCounter.update({ where: { scope: counterScope }, data: { seq } })
|
|
74
|
+
}
|
|
75
|
+
const identifier = table === "bugs" ? `${prefix}-B${seq}` : `${prefix}-${seq}`
|
|
76
|
+
payload.identifier = identifier
|
|
77
|
+
payload.sequenceId = seq
|
|
78
|
+
return identifier
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const Route = createFileRoute("/api/sync/push")({
|
|
82
|
+
server: {
|
|
83
|
+
handlers: {
|
|
84
|
+
POST: async ({ request }) => {
|
|
85
|
+
const session = await requireAuth(getRequest())
|
|
86
|
+
const userId = session.user.id
|
|
87
|
+
const body = pushSchema.parse(await request.json())
|
|
88
|
+
|
|
89
|
+
const prisma = await getPrisma()
|
|
90
|
+
const { applyOp } = await import("@allbluecn/sync-core")
|
|
91
|
+
const { SYNC_TABLE_MAP } = await import("@allbluecn/sync-core/registrations")
|
|
92
|
+
const scopes = await resolveSyncScopes(prisma, userId)
|
|
93
|
+
|
|
94
|
+
// 整批单事务:逐 op 应用(依赖拓扑由客户端 outbox 顺序保证)
|
|
95
|
+
const results = await prisma.$transaction(async (tx) => {
|
|
96
|
+
const executor = {
|
|
97
|
+
query: <T = Record<string, unknown>>(sql: string, params: unknown[]) =>
|
|
98
|
+
tx.$queryRawUnsafe<T[]>(sql, ...params),
|
|
99
|
+
execute: (sql: string, params: unknown[]) =>
|
|
100
|
+
tx.$executeRawUnsafe(sql, ...params),
|
|
101
|
+
}
|
|
102
|
+
const out: Array<Record<string, unknown>> = []
|
|
103
|
+
for (const op of body.ops) {
|
|
104
|
+
const reg = SYNC_TABLE_MAP.get(op.table)
|
|
105
|
+
if (!reg) {
|
|
106
|
+
out.push({ opId: op.opId, status: "conflict", conflict: { reason: "unknownTable" } })
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
const payload = op.payload as SyncPayload | undefined
|
|
110
|
+
let outcome = await applyOp(executor, reg, { ...op, payload }, {
|
|
111
|
+
userId,
|
|
112
|
+
scopes,
|
|
113
|
+
now: new Date(),
|
|
114
|
+
})
|
|
115
|
+
// 编号重发号特例:stories/bugs 撞 identifier 唯一键 → 服务端重发号重试
|
|
116
|
+
if (outcome.kind === "conflict" && outcome.reason === "uniqueViolation" && (op.table === "stories" || op.table === "bugs") && payload) {
|
|
117
|
+
const oldIdentifier = payload.identifier as string | undefined
|
|
118
|
+
const newIdentifier = await reissueIdentifier(tx, op.table, payload)
|
|
119
|
+
if (newIdentifier && newIdentifier !== oldIdentifier) {
|
|
120
|
+
outcome = await applyOp(executor, reg, { ...op, payload }, {
|
|
121
|
+
userId,
|
|
122
|
+
scopes,
|
|
123
|
+
now: new Date(),
|
|
124
|
+
})
|
|
125
|
+
if (outcome.kind === "applied") {
|
|
126
|
+
out.push({
|
|
127
|
+
opId: op.opId,
|
|
128
|
+
status: "applied",
|
|
129
|
+
syncVersion: outcome.syncVersion,
|
|
130
|
+
row: outcome.row,
|
|
131
|
+
renamed: { from: oldIdentifier ?? "", to: newIdentifier },
|
|
132
|
+
})
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
out.push(mapOutcome(op.opId, outcome))
|
|
138
|
+
}
|
|
139
|
+
return out
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return Response.json({ results })
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
import type { ApplyOpOutcome } from "@allbluecn/sync-core"
|
|
149
|
+
|
|
150
|
+
function mapOutcome(opId: string, outcome: ApplyOpOutcome): Record<string, unknown> {
|
|
151
|
+
switch (outcome.kind) {
|
|
152
|
+
case "applied":
|
|
153
|
+
return { opId, status: "applied", syncVersion: outcome.syncVersion, row: outcome.row }
|
|
154
|
+
case "duplicate":
|
|
155
|
+
return { opId, status: "applied", syncVersion: outcome.syncVersion }
|
|
156
|
+
case "conflict":
|
|
157
|
+
return {
|
|
158
|
+
opId,
|
|
159
|
+
status: "conflict",
|
|
160
|
+
conflict: { reason: outcome.reason, local: outcome.local, remote: outcome.remote },
|
|
161
|
+
}
|
|
162
|
+
case "forbidden":
|
|
163
|
+
return { opId, status: "conflict", conflict: { reason: "forbidden" } }
|
|
164
|
+
case "unknownTable":
|
|
165
|
+
return { opId, status: "conflict", conflict: { reason: "unknownTable" } }
|
|
166
|
+
case "invalidPayload":
|
|
167
|
+
return { opId, status: "conflict", conflict: { reason: outcome.reason } }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
// 快照分页端点(迁移设计 §3.4 首登 bootstrap):按 (table, pk) 序分页返回
|
|
6
|
+
// 单表全量行(列白名单投影),scope 行级过滤。客户端逐表拉取后以
|
|
7
|
+
// watermark_seq 起步走增量 pull(不依赖 change_log 从头回放)。
|
|
8
|
+
import { createFileRoute } from "@tanstack/react-router"
|
|
9
|
+
import { getRequest } from "@tanstack/react-start/server"
|
|
10
|
+
import { z } from "zod"
|
|
11
|
+
import { getPrisma } from "@allbluecn/database/get-prisma"
|
|
12
|
+
import { requireAuth } from "@/server/auth-helpers"
|
|
13
|
+
import { resolveSyncScopes } from "@/server/sync/scopes"
|
|
14
|
+
import { SYNC_TABLE_MAP } from "@allbluecn/sync-core/registrations"
|
|
15
|
+
|
|
16
|
+
const snapshotSchema = z.object({
|
|
17
|
+
table: z.string().regex(/^[a-z_][a-z0-9_]*$/),
|
|
18
|
+
cursor: z.string().max(600).optional(), // 上一页末行 (pk);复合主键为 JSON
|
|
19
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const PAGE_LIMIT = 200
|
|
23
|
+
|
|
24
|
+
export const Route = createFileRoute("/api/sync/snapshot")({
|
|
25
|
+
server: {
|
|
26
|
+
handlers: {
|
|
27
|
+
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
|
+
})
|
|
36
|
+
|
|
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!
|
|
44
|
+
|
|
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})`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const limit = Math.min(body.limit ?? PAGE_LIMIT, PAGE_LIMIT)
|
|
70
|
+
const orderBy = [...reg.pkColumns].map((k) => `"${k}" ASC`).join(", ")
|
|
71
|
+
|
|
72
|
+
const rows = await prisma.$queryRawUnsafe<Record<string, unknown>[]>(
|
|
73
|
+
`SELECT ${reg.columns.map((c) => `"${c}"`).join(", ")}, sync_version FROM ${reg.table}
|
|
74
|
+
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
75
|
+
ORDER BY ${orderBy}
|
|
76
|
+
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
|
+
})
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
})
|
|
@@ -127,8 +127,8 @@ describe("assertFetchableUrl SSRF 主机防护", () => {
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
describe("isDesktopRuntime 桌面端判定", () => {
|
|
130
|
-
it("DATABASE_PROVIDER=
|
|
131
|
-
vi.stubEnv("DATABASE_PROVIDER", "
|
|
130
|
+
it("DATABASE_PROVIDER=pglite(桌面端 Electron)为 true;postgresql/缺省(云端 SaaS)恒 false", () => {
|
|
131
|
+
vi.stubEnv("DATABASE_PROVIDER", "pglite");
|
|
132
132
|
expect(isDesktopRuntime()).toBe(true);
|
|
133
133
|
vi.stubEnv("DATABASE_PROVIDER", "postgresql");
|
|
134
134
|
expect(isDesktopRuntime()).toBe(false);
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
} from '@allbluecn/kernel'
|
|
8
8
|
|
|
9
9
|
describe('ResourceRegistry', () => {
|
|
10
|
-
it('注册了 seats / guestSeats / aiQuotaMonthly / agentQuota / skillQuota / workspaces / sandboxMinutesMonthly / mediaGenerationsMonthly / galleryStorageMb
|
|
10
|
+
it('注册了 seats / guestSeats / aiQuotaMonthly / agentQuota / skillQuota / workspaces / sandboxMinutesMonthly / mediaGenerationsMonthly / galleryStorageMb / installQuota / blobStorageMb 十一个资源', () => {
|
|
11
11
|
expect(RESOURCE_KEYS).toContain('seats')
|
|
12
12
|
expect(RESOURCE_KEYS).toContain('guestSeats')
|
|
13
13
|
expect(RESOURCE_KEYS).toContain('aiQuotaMonthly')
|
|
@@ -17,7 +17,9 @@ describe('ResourceRegistry', () => {
|
|
|
17
17
|
expect(RESOURCE_KEYS).toContain('sandboxMinutesMonthly')
|
|
18
18
|
expect(RESOURCE_KEYS).toContain('mediaGenerationsMonthly')
|
|
19
19
|
expect(RESOURCE_KEYS).toContain('galleryStorageMb')
|
|
20
|
-
expect(RESOURCE_KEYS
|
|
20
|
+
expect(RESOURCE_KEYS).toContain('installQuota')
|
|
21
|
+
expect(RESOURCE_KEYS).toContain('blobStorageMb')
|
|
22
|
+
expect(RESOURCE_KEYS.length).toBe(11)
|
|
21
23
|
})
|
|
22
24
|
|
|
23
25
|
it('sandboxMinutesMonthly:按分钟计的时长配额,不可加购、步进 1 分钟', () => {
|
|
@@ -16,6 +16,10 @@ const db = vi.hoisted(() => ({
|
|
|
16
16
|
userAgent: { count: vi.fn() },
|
|
17
17
|
userSkill: { count: vi.fn() },
|
|
18
18
|
generationRun: { count: vi.fn().mockResolvedValue(0) },
|
|
19
|
+
// 资源快照 S6 扩展键(installQuota/blobStorageMb)——过期 mock 缺模型会以
|
|
20
|
+
// "Cannot read properties of undefined (reading 'count')" 炸穿 requireResource
|
|
21
|
+
anyHubApp: { count: vi.fn().mockResolvedValue(0) },
|
|
22
|
+
blob: { aggregate: vi.fn().mockResolvedValue({ _sum: { size: null } }) },
|
|
19
23
|
}))
|
|
20
24
|
|
|
21
25
|
vi.mock('@/server/pro-services', () => ({
|
|
@@ -75,6 +79,7 @@ describe('assertSandboxEntitled:feature + 分钟配额双闸门', () => {
|
|
|
75
79
|
db.userAgent.count.mockResolvedValue(0)
|
|
76
80
|
db.userSkill.count.mockResolvedValue(0)
|
|
77
81
|
db.aiUsageLog.aggregate.mockResolvedValue({ _sum: { durationMs: null } })
|
|
82
|
+
db.aiUsageLog.aggregate.mockResolvedValue({ _sum: { durationMs: null } })
|
|
78
83
|
mockRequireAuth.mockResolvedValue({ user: { id: 'user-1' } })
|
|
79
84
|
})
|
|
80
85
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { buildSecurityHeaders } from "../middlewares/security-headers";
|
|
7
|
+
|
|
8
|
+
// CSP script-src 必须含 'unsafe-inline':TanStack Start SSR 向 HTML 注入内联
|
|
9
|
+
// dehydration 脚本(window.$_TSR,随请求变化,hash/nonce 均不可用),收紧为
|
|
10
|
+
// 'self' 会拦截 hydration → 客户端崩溃整站按钮死(Pro 生产 2026-09-11 已复现,
|
|
11
|
+
// 见 security-headers.ts 头注;admin 生产 CSP 同型缺失 2026-10-30 修复)。
|
|
12
|
+
function csp(prod: boolean): string {
|
|
13
|
+
const headers = buildSecurityHeaders(prod);
|
|
14
|
+
return headers["Content-Security-Policy"] ?? "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("security-headers CSP", () => {
|
|
18
|
+
it("生产 script-src 含 'unsafe-inline'(dehydration 内联脚本必需)", () => {
|
|
19
|
+
expect(csp(true)).toMatch(/script-src[^;]*'unsafe-inline'/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("开发 script-src 含 'unsafe-inline'", () => {
|
|
23
|
+
expect(csp(false)).toMatch(/script-src[^;]*'unsafe-inline'/);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -194,17 +194,6 @@ describe("fetchAssignedToMe", () => {
|
|
|
194
194
|
})
|
|
195
195
|
})
|
|
196
196
|
|
|
197
|
-
it("DATABASE_PROVIDER=sqlite:只查 story,bug/testcase 降级", async () => {
|
|
198
|
-
vi.stubEnv("DATABASE_PROVIDER", "sqlite")
|
|
199
|
-
storyFindMany.mockResolvedValue([storyRow])
|
|
200
|
-
const result = await fetchAssignedToMe("u1")
|
|
201
|
-
expect(storyFindMany).toHaveBeenCalledTimes(1)
|
|
202
|
-
expect(bugFindMany).not.toHaveBeenCalled()
|
|
203
|
-
expect(testCaseFindMany).not.toHaveBeenCalled()
|
|
204
|
-
expect(result.degraded).toEqual(["bug", "testcase"])
|
|
205
|
-
expect(result.items).toHaveLength(1)
|
|
206
|
-
expect(result.items[0]?.source).toBe("story")
|
|
207
|
-
})
|
|
208
197
|
|
|
209
198
|
it("DATABASE_PROVIDER 未设置时默认 postgresql:三表全查", async () => {
|
|
210
199
|
vi.stubEnv("DATABASE_PROVIDER", undefined)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// 最小存在性校验(仅上下文提示、非成员边界,不存在拒绝;源型以源条目项目为准);
|
|
13
13
|
// runNow:true 内部复用 runTaskExecution 同款执行逻辑,返回 { id, runId };
|
|
14
14
|
// - deleteAiTaskFn:running 拒绝;非属主与不存在同抛「任务不存在」;删除走 schema
|
|
15
|
-
// Cascade(无手动删 runs
|
|
15
|
+
// Cascade(无手动删 runs)。历史 sqlite 降级用例已随 Tauri 清退移除(迁移 P1)。
|
|
16
16
|
// mock 惯例同 task-serverfn-queries.test.ts(伪造 createServerFn + getPrisma)。
|
|
17
17
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
18
18
|
|
|
@@ -429,14 +429,6 @@ describe("createAiTaskFn", () => {
|
|
|
429
429
|
})
|
|
430
430
|
})
|
|
431
431
|
|
|
432
|
-
it("sqlite 降级:拒绝且不触 prisma", async () => {
|
|
433
|
-
mocks.getDatabaseProvider.mockReturnValue("sqlite")
|
|
434
|
-
|
|
435
|
-
await expect(
|
|
436
|
-
createAiTaskFn({ data: { title: "t", instruction: "x" } }),
|
|
437
|
-
).rejects.toThrow()
|
|
438
|
-
expect(mocks.aiTaskCreate).not.toHaveBeenCalled()
|
|
439
|
-
})
|
|
440
432
|
|
|
441
433
|
it("未登录:requireAuth 抛错直接拒绝,不触限流与 DB", async () => {
|
|
442
434
|
mocks.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"))
|
|
@@ -570,12 +562,6 @@ describe("runAiTaskFn", () => {
|
|
|
570
562
|
expect(mocks.aiTaskFindFirst).not.toHaveBeenCalled()
|
|
571
563
|
})
|
|
572
564
|
|
|
573
|
-
it("sqlite 降级:拒绝且不触 prisma", async () => {
|
|
574
|
-
mocks.getDatabaseProvider.mockReturnValue("sqlite")
|
|
575
|
-
|
|
576
|
-
await expect(runAiTaskFn({ data: { id: "t1" } })).rejects.toThrow()
|
|
577
|
-
expect(mocks.aiTaskFindFirst).not.toHaveBeenCalled()
|
|
578
|
-
})
|
|
579
565
|
|
|
580
566
|
it("未登录:requireAuth 抛错直接拒绝,不触限流与 DB", async () => {
|
|
581
567
|
mocks.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"))
|
|
@@ -619,13 +605,6 @@ describe("deleteAiTaskFn", () => {
|
|
|
619
605
|
expect(mocks.aiTaskDelete).not.toHaveBeenCalled()
|
|
620
606
|
})
|
|
621
607
|
|
|
622
|
-
it("sqlite 降级:拒绝且不触 prisma", async () => {
|
|
623
|
-
mocks.getDatabaseProvider.mockReturnValue("sqlite")
|
|
624
|
-
|
|
625
|
-
await expect(deleteAiTaskFn({ data: { id: "t1" } })).rejects.toThrow()
|
|
626
|
-
expect(mocks.aiTaskFindFirst).not.toHaveBeenCalled()
|
|
627
|
-
expect(mocks.aiTaskDelete).not.toHaveBeenCalled()
|
|
628
|
-
})
|
|
629
608
|
|
|
630
609
|
it("未登录:requireAuth 抛错直接拒绝,不触 DB", async () => {
|
|
631
610
|
mocks.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"))
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 钉死口径:
|
|
3
3
|
// - 三个 serverFn 均 GET method;handler 首行 requireAuth,未登录直接拒绝不触 DB;
|
|
4
4
|
// - listAiTasksFn:where createdById + updatedAt 倒序 + 列表字段 select(含 instruction 供指令搜索);lastRunAt/updatedAt
|
|
5
|
-
// 转 ISO string
|
|
5
|
+
// 转 ISO string。历史 sqlite provider 降级用例已随 Tauri 清退移除(迁移 P1)。
|
|
6
6
|
// - getAiTaskFn:归属过滤放 where(非属主与不存在同样抛「任务不存在」,不泄露存在性);
|
|
7
7
|
// runs 按 startedAt 倒序;task 附关联项目名 projectName(projectId null 不查、查询
|
|
8
8
|
// 落空兜底 null);sourceExists 仅源型任务探测源表(null = 非源型)。
|
|
@@ -248,14 +248,6 @@ describe("listAiTasksFn", () => {
|
|
|
248
248
|
})
|
|
249
249
|
})
|
|
250
250
|
|
|
251
|
-
it("sqlite provider 降级:{ items: [], degraded: true } 且不触 prisma.aiTask", async () => {
|
|
252
|
-
mocks.getDatabaseProvider.mockReturnValue("sqlite")
|
|
253
|
-
|
|
254
|
-
const result = await listAiTasksFn()
|
|
255
|
-
|
|
256
|
-
expect(result).toEqual({ items: [], degraded: true })
|
|
257
|
-
expect(mocks.aiTaskFindMany).not.toHaveBeenCalled()
|
|
258
|
-
})
|
|
259
251
|
|
|
260
252
|
it("未登录直接拒绝,不触 DB", async () => {
|
|
261
253
|
mocks.requireAuth.mockRejectedValue(new Error("UNAUTHORIZED"))
|
|
@@ -137,17 +137,6 @@ describe("buildChatStreamWithTools 挂载记忆中间件", () => {
|
|
|
137
137
|
expect(mocks.memoryMiddleware.mock.calls[0]![0].role).toBe("save-only")
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
it("DATABASE_PROVIDER=sqlite 时完全不挂载", async () => {
|
|
141
|
-
vi.stubEnv("DATABASE_PROVIDER", "sqlite")
|
|
142
|
-
await buildChatStreamWithTools(BASE_PARAMS)
|
|
143
|
-
|
|
144
|
-
expect(mocks.memoryMiddleware).not.toHaveBeenCalled()
|
|
145
|
-
const config = mocks.chat.mock.calls[0]![0]
|
|
146
|
-
expect(config.middleware.map((m: { name: string }) => m.name)).not.toContain(
|
|
147
|
-
"memory:pgvector",
|
|
148
|
-
)
|
|
149
|
-
})
|
|
150
|
-
|
|
151
140
|
it("DEPLOY_TARGET=desktop 时完全不挂载", async () => {
|
|
152
141
|
vi.stubEnv("DEPLOY_TARGET", "desktop")
|
|
153
142
|
await buildChatStreamWithTools(BASE_PARAMS)
|
|
@@ -44,7 +44,7 @@ describe("generateEmbedding 真实请求路径(本地 mock OpenAI 兼容端点
|
|
|
44
44
|
// 本测试的 mock 端点跑在本机回环地址:SEC-08 出口守卫按云端语义会拒绝
|
|
45
45
|
// 127.0.0.1。stub 为桌面运行时(DESKTOP 语义 = 单机形态允许本地服务),
|
|
46
46
|
// 与「桌面端本地 embedding 服务」这条放行路径一致。
|
|
47
|
-
vi.stubEnv("DATABASE_PROVIDER", "
|
|
47
|
+
vi.stubEnv("DATABASE_PROVIDER", "pglite");
|
|
48
48
|
const mod = await import("../embedding");
|
|
49
49
|
generateEmbedding = mod.generateEmbedding;
|
|
50
50
|
EMBEDDING_MODEL = mod.EMBEDDING_MODEL;
|
|
@@ -183,9 +183,9 @@ describe("createProviderAdapter SEC-08 baseUrl 出口校验", () => {
|
|
|
183
183
|
).rejects.toThrow(/SSRF/)
|
|
184
184
|
})
|
|
185
185
|
|
|
186
|
-
it("desktop 运行时(DATABASE_PROVIDER=
|
|
186
|
+
it("desktop 运行时(DATABASE_PROVIDER=pglite)放行本地 baseUrl(本地 Ollama)", async () => {
|
|
187
187
|
const saved = process.env.DATABASE_PROVIDER
|
|
188
|
-
process.env.DATABASE_PROVIDER = "
|
|
188
|
+
process.env.DATABASE_PROVIDER = "pglite"
|
|
189
189
|
try {
|
|
190
190
|
// 用 mock 的 bedrock 分支断言(openaiText 真 factory 会读 env key)
|
|
191
191
|
mockFindUnique.mockResolvedValue(providerRow("bedrock") as never)
|
|
@@ -43,7 +43,7 @@ const RUN_ID = "fc-1";
|
|
|
43
43
|
const THREAD_ID = "fc-t";
|
|
44
44
|
|
|
45
45
|
describe.skipIf(
|
|
46
|
-
!process.env.DATABASE_URL
|
|
46
|
+
!process.env.DATABASE_URL,
|
|
47
47
|
)("chatRunStore 真库契约(durable 四列)", () => {
|
|
48
48
|
beforeAll(async () => {
|
|
49
49
|
// ChatThread.userId 是 FK,归属校验需要的 owner 必须是真用户行。
|
|
@@ -42,7 +42,7 @@ function mkChunk(delta: string): C {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
describe.skipIf(
|
|
45
|
-
!process.env.DATABASE_URL
|
|
45
|
+
!process.env.DATABASE_URL,
|
|
46
46
|
)("PG StreamDurability(真库)", () => {
|
|
47
47
|
afterAll(async () => {
|
|
48
48
|
if (usedRunIds.size > 0) {
|
|
@@ -67,7 +67,7 @@ const SANDBOX_KEY = `thread:${THREAD_ID}`;
|
|
|
67
67
|
const PROVIDER_NAME = "rrdb-mock";
|
|
68
68
|
|
|
69
69
|
describe.skipIf(
|
|
70
|
-
!process.env.DATABASE_URL
|
|
70
|
+
!process.env.DATABASE_URL,
|
|
71
71
|
)("runReaperCycle 真库装配(chatRunStore 全局变体 + PG durability)", () => {
|
|
72
72
|
// 全套用例共享一个确定性时钟:detachedSince 与 now 都是常量,TTL 判定不依赖墙上时间
|
|
73
73
|
const DETACHED_AT = 10_000;
|
|
@@ -32,10 +32,10 @@ function truncateError(error: string | undefined): string {
|
|
|
32
32
|
: error
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
//
|
|
35
|
+
// 桌面端不挂(与 unified-router 的 desktop 短路一致);历史 sqlite 无 pgvector
|
|
36
|
+
// 短路已随 Tauri 清退移除(迁移 P1):pglite 预载 pgvector,但 desktop 短路仍生效
|
|
36
37
|
export function isMemoryMiddlewareEnabled(): boolean {
|
|
37
38
|
if (process.env.DEPLOY_TARGET === "desktop") return false
|
|
38
|
-
if (process.env.DATABASE_PROVIDER === "sqlite") return false
|
|
39
39
|
return true
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -28,7 +28,7 @@ function makeRecord(key: string, extra: Partial<SandboxInstanceRecord> = {}) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
describe.skipIf(
|
|
31
|
-
!process.env.DATABASE_URL
|
|
31
|
+
!process.env.DATABASE_URL,
|
|
32
32
|
)("prismaInstanceStore(真库,三不变量)", () => {
|
|
33
33
|
const store = prismaInstanceStore()
|
|
34
34
|
|