@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.
Files changed (93) hide show
  1. package/package.json +6 -8
  2. package/public/logo/allblue-logo-app-icon.svg +1 -1
  3. package/src/components/changelog/changelog-page.tsx +24 -5
  4. package/src/components/desktop/cloud-login.tsx +91 -0
  5. package/src/components/desktop/conflict-center.tsx +148 -0
  6. package/src/components/desktop/renamed-toast.tsx +23 -0
  7. package/src/components/desktop/shell-widgets.tsx +19 -0
  8. package/src/components/desktop/sync-indicator.tsx +60 -0
  9. package/src/components/desktop/sync-status-card.tsx +60 -0
  10. package/src/components/layout/sidebar-03/app-sidebar.tsx +38 -3
  11. package/src/components/shared-kit/avatars/__tests__/avatar-shape.test.ts +17 -1
  12. package/src/components/shared-kit/avatars/avatar-shape.ts +10 -0
  13. package/src/components/shared-kit/avatars/user-avatar.tsx +2 -2
  14. package/src/lib/auth/auth.config.ts +4 -1
  15. package/src/lib/changelog/sdk-versions.ts +33 -2
  16. package/src/lib/desktop-bridge-types.ts +30 -0
  17. package/src/lib/desktop-detect.ts +2 -0
  18. package/src/lib/vector-store/__tests__/factory.test.ts +0 -7
  19. package/src/lib/vector-store/index.ts +2 -4
  20. package/src/paraglide/messages/_index.d.ts +1 -1
  21. package/src/paraglide/messages/_index.js +32 -16
  22. package/src/paraglide/messages/common_sidebar_usermenu_loginsaas3.d.ts +1 -1
  23. package/src/paraglide/messages/common_sidebar_usermenu_loginsaas3.js +3 -3
  24. package/src/paraglide/messages/en.js +11 -6
  25. package/src/paraglide/messages/{tasks_ai_desktop_notice.d.ts → settings_tabs_sync.d.ts} +5 -5
  26. package/src/paraglide/messages/settings_tabs_sync.js +29 -0
  27. package/src/paraglide/messages/zh.js +11 -6
  28. package/src/providers/index.tsx +6 -0
  29. package/src/routeTree.gen.ts +107 -49
  30. package/src/routes/_nav/route.lazy.tsx +12 -0
  31. package/src/routes/_nav/settings/-settings-tabs.tsx +1 -1
  32. package/src/routes/_nav/settings/sync/route.lazy.tsx +40 -0
  33. package/src/routes/_nav/settings/sync/route.tsx +15 -0
  34. package/src/routes/_nav/tasks/index.lazy.tsx +1 -11
  35. package/src/routes/api/billing-webhook.ts +6 -2
  36. package/src/routes/api/sync/bootstrap.ts +38 -0
  37. package/src/routes/api/sync/pull.ts +96 -0
  38. package/src/routes/api/sync/push.ts +169 -0
  39. package/src/routes/api/sync/snapshot.ts +104 -0
  40. package/src/server/__tests__/fetch-guard.test.ts +2 -2
  41. package/src/server/__tests__/resource-registry.test.ts +4 -2
  42. package/src/server/__tests__/sandbox-entitlement.test.ts +5 -0
  43. package/src/server/__tests__/security-headers.test.ts +25 -0
  44. package/src/server/__tests__/task-assigned-query.test.ts +0 -11
  45. package/src/server/__tests__/task-serverfn-crud.test.ts +1 -22
  46. package/src/server/__tests__/task-serverfn-queries.test.ts +1 -9
  47. package/src/server/ai-gateway/__tests__/chat-stream-builder-memory.test.ts +0 -11
  48. package/src/server/ai-gateway/__tests__/embedding-request.test.ts +1 -1
  49. package/src/server/ai-gateway/__tests__/provider-adapter.test.ts +2 -2
  50. package/src/server/ai-gateway/__tests__/run-store-durable.test.ts +1 -1
  51. package/src/server/ai-gateway/durability/__tests__/pg-stream.test.ts +1 -1
  52. package/src/server/ai-gateway/durability/__tests__/reaper-real-db.test.ts +1 -1
  53. package/src/server/ai-gateway/memory/index.ts +2 -2
  54. package/src/server/ai-gateway/sandbox/__tests__/instance-store.test.ts +1 -1
  55. package/src/server/ai-task/assigned-query.ts +4 -15
  56. package/src/server/desktop-guest.ts +12 -0
  57. package/src/server/fetch-guard.ts +3 -3
  58. package/src/server/middlewares/desktop-token.ts +23 -0
  59. package/src/server/remote-auth.ts +6 -0
  60. package/src/server/serverFns/__tests__/billing.test.ts +58 -1
  61. package/src/server/serverFns/__tests__/update-seats.test.ts +8 -8
  62. package/src/server/serverFns/agents/agent-crud.ts +3 -1
  63. package/src/server/serverFns/auth/session.ts +9 -0
  64. package/src/server/serverFns/billing.ts +10 -4
  65. package/src/server/serverFns/bug/bug-crud.ts +7 -2
  66. package/src/server/serverFns/story/__tests__/labels.test.ts +37 -15
  67. package/src/server/serverFns/story/story-comments.ts +2 -1
  68. package/src/server/serverFns/story/story-crud/activities.ts +1 -1
  69. package/src/server/serverFns/story/story-crud/user-brief.ts +1 -1
  70. package/src/server/serverFns/story/story-crud.ts +2 -1
  71. package/src/server/serverFns/story/story-transfer.ts +2 -1
  72. package/src/server/serverFns/tasks/task-crud.ts +0 -13
  73. package/src/server/serverFns/tasks/task-queries.ts +1 -12
  74. package/src/server/serverFns/testcase/plans.ts +1 -1
  75. package/src/server/serverFns/testcase/run-cases.ts +1 -1
  76. package/src/server/serverFns/testcase/runs.ts +1 -1
  77. package/src/server/serverFns/testcase/testcase-activities.ts +1 -1
  78. package/src/server/serverFns/testcase/testcase-board.ts +1 -1
  79. package/src/server/serverFns/testcase/testcase-crud.ts +1 -1
  80. package/src/server/serverFns/workspace.ts +1 -1
  81. package/src/server/story/types.ts +7 -22
  82. package/src/server/story/user-brief.ts +48 -0
  83. package/src/server/sync/scopes.ts +29 -0
  84. package/src/start.ts +10 -2
  85. package/src/styles/globals.css +10 -0
  86. package/src/__tests__/desktop-connection-validate.test.ts +0 -66
  87. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +0 -143
  88. package/src/lib/vector-store/sqlite-vec-store.ts +0 -224
  89. package/src/paraglide/messages/tasks_ai_desktop_notice.js +0 -29
  90. package/src/routes/__desktop/onboarding/route.lazy.tsx +0 -175
  91. package/src/routes/__desktop/onboarding/route.tsx +0 -11
  92. package/src/routes/_nav/settings/connection/route.lazy.tsx +0 -64
  93. 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=sqlite(桌面端 sidecar)为 true;postgresql/缺省(云端 SaaS)恒 false", () => {
131
- vi.stubEnv("DATABASE_PROVIDER", "sqlite");
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.length).toBe(9)
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);sqlite 降级三入口均拒绝不触 prisma。
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;sqlite provider 降级 { items: [], degraded: true } 且不触 prisma.aiTask;
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", "sqlite");
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=sqlite)放行本地 baseUrl(本地 Ollama)", async () => {
186
+ it("desktop 运行时(DATABASE_PROVIDER=pglite)放行本地 baseUrl(本地 Ollama)", async () => {
187
187
  const saved = process.env.DATABASE_PROVIDER
188
- process.env.DATABASE_PROVIDER = "sqlite"
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 || process.env.DATABASE_PROVIDER === "sqlite",
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 || process.env.DATABASE_PROVIDER === "sqlite",
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 || process.env.DATABASE_PROVIDER === "sqlite",
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
- // 桌面端 SQLite 无 pgvector,直接不挂(与 unified-router 的 desktop 短路一致)
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 || process.env.DATABASE_PROVIDER === "sqlite",
31
+ !process.env.DATABASE_URL,
32
32
  )("prismaInstanceStore(真库,三不变量)", () => {
33
33
  const store = prismaInstanceStore()
34
34