@allbluecn/web-app 0.10.3 → 0.11.1

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 (246) hide show
  1. package/package.json +24 -11
  2. package/public/favicon-dark.svg +1 -0
  3. package/public/favicon.ico +0 -0
  4. package/public/favicon.svg +1 -0
  5. package/public/logo/allblue-logo-app-icon.svg +7 -0
  6. package/public/logo/allblue-logo-dark-square.svg +4 -0
  7. package/public/logo/allblue-logo-dark.png +0 -0
  8. package/public/logo/allblue-logo-dark.svg +4 -0
  9. package/public/logo/allblue-logo-light-square.svg +4 -0
  10. package/public/logo/allblue-logo-light.png +0 -0
  11. package/public/logo/allblue-logo-light.svg +4 -0
  12. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  13. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  14. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  15. package/src/__tests__/integration/login-email-acceptance.test.ts +202 -0
  16. package/src/__tests__/integration/login-email-p2-acceptance.test.ts +142 -0
  17. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  18. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  19. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  20. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  21. package/src/__tests__/setup.ts +9 -0
  22. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  23. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  24. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  25. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  26. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  27. package/src/components/auth/icon-input.tsx +9 -1
  28. package/src/components/auth/password-input.tsx +9 -1
  29. package/src/components/changelog/changelog-page.tsx +44 -1
  30. package/src/components/error/global-error-boundary.tsx +2 -2
  31. package/src/components/forms/app-field.tsx +3 -1
  32. package/src/components/knowledge/general-groups.ts +15 -0
  33. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  34. package/src/components/knowledge/knowledge-toolbar.tsx +17 -21
  35. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  36. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  37. package/src/components/layout/sidebar-03/app-sidebar.tsx +4 -15
  38. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  39. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  40. package/src/components/notifications/settings-notifications.tsx +41 -1
  41. package/src/components/settings/personal-team/account/settings-profile.tsx +68 -1
  42. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  43. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  44. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  45. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  46. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  47. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  48. package/src/components/story/relations/general-groups.ts +1 -15
  49. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  50. package/src/components/story/story-list-view.tsx +7 -1
  51. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  52. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  53. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  54. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  55. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  56. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  57. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  58. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  59. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  60. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  61. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  62. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  63. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  64. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  65. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  66. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  67. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  68. package/src/lib/__tests__/edition-desktop.test.ts +23 -0
  69. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  70. package/src/lib/__tests__/pgvector.test.ts +2 -2
  71. package/src/lib/app-version.ts +1 -1
  72. package/src/lib/auth/auth.config.ts +123 -99
  73. package/src/lib/changelog/sdk-versions.ts +21 -21
  74. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  75. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  76. package/src/lib/edition-desktop.ts +22 -0
  77. package/src/lib/fetch-json.ts +23 -0
  78. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  79. package/src/lib/sanitize.ts +14 -1
  80. package/src/lib/session.ts +10 -2
  81. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  82. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  83. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  84. package/src/routeTree.gen.ts +114 -0
  85. package/src/routes/__root.tsx +20 -3
  86. package/src/routes/__tests__/-auth-schemas.test.ts +62 -0
  87. package/src/routes/__tests__/-edition-routing.test.tsx +18 -5
  88. package/src/routes/__tests__/route-tree-structure.test.ts +66 -0
  89. package/src/routes/_login/changelog/confirm/route.lazy.tsx +72 -0
  90. package/src/routes/_login/changelog/confirm/route.tsx +28 -0
  91. package/src/routes/_login/email-unsubscribe/route.lazy.tsx +76 -0
  92. package/src/routes/_login/email-unsubscribe/route.tsx +28 -0
  93. package/src/routes/_login/forgot-password/route.lazy.tsx +2 -1
  94. package/src/routes/_login/login/route.lazy.tsx +4 -2
  95. package/src/routes/_login/register/route.lazy.tsx +28 -17
  96. package/src/routes/_login/register/route.tsx +8 -1
  97. package/src/routes/_login/reset-password/route.lazy.tsx +4 -2
  98. package/src/routes/_login/schemas/-auth.ts +30 -7
  99. package/src/routes/_login/verify-email/route.lazy.tsx +82 -0
  100. package/src/routes/_login/verify-email/route.tsx +28 -0
  101. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  102. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  103. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  104. package/src/routes/_nav/route.tsx +26 -0
  105. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  106. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  107. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  108. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  109. package/src/routes/_public/index/route.tsx +2 -1
  110. package/src/routes/api/auth/desktop-callback.ts +2 -2
  111. package/src/routes/api/billing-webhook.ts +4 -0
  112. package/src/routes/api/cron/changelog-release.ts +100 -0
  113. package/src/routes/api/cron/subscription-reminders.ts +110 -0
  114. package/src/routes/api.auth.$.ts +3 -5
  115. package/src/server/__tests__/agent-crud.test.ts +4 -4
  116. package/src/server/__tests__/auth-registration.test.ts +60 -0
  117. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  118. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  119. package/src/server/__tests__/permissions.test.ts +1 -3
  120. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  121. package/src/server/__tests__/skill-crud.test.ts +3 -3
  122. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  123. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  124. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  125. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  126. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  127. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  128. package/src/server/ai-gateway/index.ts +5 -0
  129. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  130. package/src/server/ai-gateway/llm-fence.ts +82 -0
  131. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  132. package/src/server/ai-gateway/story-prompt.ts +4 -2
  133. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  134. package/src/server/ai-gateway/tools/server.ts +97 -54
  135. package/src/server/auth-registration.ts +40 -0
  136. package/src/server/billing-email.ts +39 -0
  137. package/src/server/builtin-project.ts +21 -0
  138. package/src/server/email/__tests__/deliverability.test.ts +68 -0
  139. package/src/server/email/__tests__/index.test.ts +32 -0
  140. package/src/server/email/__tests__/outbox.test.tsx +129 -0
  141. package/src/server/email/__tests__/product-templates.test.tsx +106 -0
  142. package/src/server/email/__tests__/registry.test.tsx +50 -0
  143. package/src/server/email/deliverability.ts +57 -0
  144. package/src/server/email/drivers/console.ts +11 -0
  145. package/src/server/email/drivers/resend.ts +31 -0
  146. package/src/server/email/drivers/smtp.ts +27 -0
  147. package/src/server/email/index.ts +56 -0
  148. package/src/server/email/outbox.ts +141 -0
  149. package/src/server/email/preferences.ts +20 -0
  150. package/src/server/email/registry.ts +38 -0
  151. package/src/server/email/sender.ts +36 -13
  152. package/src/server/email/templates/_base/button.tsx +12 -0
  153. package/src/server/email/templates/_base/email-layout.tsx +117 -0
  154. package/src/server/email/templates/_base/theme-fonts.tsx +43 -0
  155. package/src/server/email/templates/_base/theme.ts +22 -0
  156. package/src/server/email/templates/auth/reset-password.tsx +58 -0
  157. package/src/server/email/templates/auth/verify-email.tsx +59 -0
  158. package/src/server/email/templates/auth/welcome.tsx +49 -0
  159. package/src/server/email/templates/billing/payment-receipt.tsx +51 -0
  160. package/src/server/email/templates/billing/subscription-canceled.tsx +51 -0
  161. package/src/server/email/templates/billing/subscription-ending.tsx +51 -0
  162. package/src/server/email/templates/notification/mirror.tsx +50 -0
  163. package/src/server/email/templates/product/changelog-confirm.tsx +53 -0
  164. package/src/server/email/templates/product/changelog-release.tsx +65 -0
  165. package/src/server/email/templates/team/apply-notify.tsx +53 -0
  166. package/src/server/email/templates/team/approved.tsx +53 -0
  167. package/src/server/email/templates/team/invite.tsx +57 -0
  168. package/src/server/fetch-guard.ts +170 -0
  169. package/src/server/middlewares/security-headers.ts +122 -0
  170. package/src/server/notifications/__tests__/email-mirror.test.ts +193 -0
  171. package/src/server/notifications/__tests__/verify-reminder.test.ts +94 -0
  172. package/src/server/notifications/notification-service.ts +71 -1
  173. package/src/server/notifications/verify-reminder.ts +55 -0
  174. package/src/server/oauth-config.ts +59 -0
  175. package/src/server/serverFns/__tests__/auth-server-schemas.test.ts +168 -0
  176. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  177. package/src/server/serverFns/__tests__/email-verification.test.ts +149 -0
  178. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  179. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  180. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  181. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  182. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  183. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  184. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  185. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  186. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  187. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  188. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  189. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  190. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  191. package/src/server/serverFns/__tests__/team-invitation.test.ts +5 -5
  192. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  193. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  194. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  195. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  196. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  197. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  198. package/src/server/serverFns/auth/email-verification.ts +132 -0
  199. package/src/server/serverFns/auth/forgot-password.ts +22 -2
  200. package/src/server/serverFns/auth/providers.ts +4 -3
  201. package/src/server/serverFns/auth/register.ts +63 -19
  202. package/src/server/serverFns/auth/registration-mode.ts +23 -0
  203. package/src/server/serverFns/auth/reset-password.ts +1 -1
  204. package/src/server/serverFns/auth/{invitation.ts → verify-reminder.ts} +9 -26
  205. package/src/server/serverFns/changelog/__tests__/subscribe.test.ts +119 -0
  206. package/src/server/serverFns/changelog/subscribe.ts +89 -0
  207. package/src/server/serverFns/email-unsubscribe.ts +66 -0
  208. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  209. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  210. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  211. package/src/server/serverFns/kb-doc-search.ts +111 -0
  212. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  213. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  214. package/src/server/serverFns/notifications.ts +10 -0
  215. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  216. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  217. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  218. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  219. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  220. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  221. package/src/server/serverFns/story/kb-general.ts +30 -17
  222. package/src/server/serverFns/story/story-assoc.ts +13 -103
  223. package/src/server/serverFns/story/story-crud.ts +2 -5
  224. package/src/server/serverFns/story/story-groups.ts +4 -13
  225. package/src/server/serverFns/team.ts +50 -12
  226. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  227. package/src/server/serverFns/workspace-kb.ts +403 -0
  228. package/src/server/serverFns/workspace.ts +4 -1
  229. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  230. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  231. package/src/shared/ai-cli/adapter.ts +2 -2
  232. package/src/shared/ai-cli/cli-detector.ts +18 -9
  233. package/src/start.ts +2 -1
  234. package/src/styles/globals.css +114 -0
  235. package/src/types/jest-dom-vitest.d.ts +8 -7
  236. package/vite.shared.ts +105 -4
  237. package/public/file.svg +0 -1
  238. package/public/globe.svg +0 -1
  239. package/public/window.svg +0 -1
  240. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  241. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  242. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  243. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  244. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  245. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  246. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -21,6 +21,11 @@ import { useMemo } from 'react'
21
21
 
22
22
  import { m } from '@/paraglide/messages.js'
23
23
  import { isDesktopServer } from '@/server/desktop-guest'
24
+ import { listPinnedWorkspacesBriefFn } from '@/server/serverFns/workspace'
25
+ import { listKnowledgeFn } from '@/server/serverFns/knowledge'
26
+ import { ensureVerifyReminderFn } from '@/server/serverFns/auth/verify-reminder'
27
+ import { WORKSPACES_QUERY_KEY } from '@/lib/collections/reference/workspaces-collection'
28
+ import { KNOWLEDGE_BASES_QUERY_KEY } from '@/lib/collections/knowledge/knowledge-bases-collection'
24
29
  import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
25
30
  import { Button } from '@/components/ui/button'
26
31
  import { GlobalErrorBoundary } from '@/components/error/global-error-boundary'
@@ -157,6 +162,27 @@ export const Route = createFileRoute('/_nav')({
157
162
  })
158
163
  }
159
164
  },
165
+ loader: async ({ context }) => {
166
+ const { queryClient } = context
167
+ // SSR 预取侧边栏常驻菜单(工作空间/知识库)数据,刷新后 hydrate 首帧即有数据,
168
+ // 避免侧边栏菜单每次刷新重新加载
169
+ await Promise.all([
170
+ queryClient.ensureQueryData({
171
+ queryKey: WORKSPACES_QUERY_KEY,
172
+ queryFn: () => listPinnedWorkspacesBriefFn(),
173
+ }),
174
+ queryClient.ensureQueryData({
175
+ queryKey: KNOWLEDGE_BASES_QUERY_KEY,
176
+ queryFn: () => listKnowledgeFn(),
177
+ }),
178
+ ]).catch(() => {
179
+ // 预取失败降级为客户端 fetch(与旧行为一致),不阻塞页面
180
+ })
181
+
182
+ if (context.session?.user?.id) {
183
+ ensureVerifyReminderFn({ data: {} }).catch(() => undefined)
184
+ }
185
+ },
160
186
  notFoundComponent: NavNotFound,
161
187
  errorComponent: GlobalErrorBoundary,
162
188
  })
@@ -20,21 +20,22 @@ import { useEffect, useState } from "react";
20
20
  import { useTheme } from "next-themes";
21
21
  import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
22
22
  import { CreateProjectDialog } from "@/components/project/create-project-dialog";
23
- import { EditWorkspaceDialog } from "@/components/workspace/edit-workspace-dialog";
23
+ import { EditWorkspaceDialog } from "@/components/workspace/dialogs/edit-workspace-dialog";
24
24
  import { NameConfirmDialog } from "@/components/shared-kit/dialogs/name-confirm-dialog";
25
- import { DeleteWorkspaceOptions } from "@/components/workspace/delete-workspace-options";
25
+ import { DeleteWorkspaceOptions } from "@/components/workspace/dialogs/delete-workspace-options";
26
26
  import {
27
27
  deleteWorkspaceFn,
28
28
  listWorkspaceMembersFn,
29
29
  listWorkspacesFn,
30
30
  } from "@/server/serverFns/workspace";
31
31
  import { deleteProjectFn } from "@/server/serverFns/project";
32
- import { WorkspaceHeader } from "@/components/workspace/workspace-header";
33
- import { WorkspaceKbCard } from "@/components/workspace/workspace-kb-card";
34
- import { WorkspaceContributionCard } from "@/components/workspace/workspace-contribution-card";
35
- import { WorkspaceProjectList } from "@/components/workspace/workspace-project-list";
36
- import { WorkspaceActivityCard } from "@/components/workspace/workspace-activity-card";
37
- import { WorkspaceMembersManager } from "@/components/workspace/workspace-members-manager";
32
+ import { WorkspaceHeader } from "@/components/workspace/header/workspace-header";
33
+ import { WorkspaceKbCard } from "@/components/workspace/kb/workspace-kb-card";
34
+ import { WorkspaceContributionCard } from "@/components/workspace/contribution/workspace-contribution-card";
35
+ import { WorkspaceProjectList } from "@/components/workspace/projects/workspace-project-list";
36
+ import { WorkspaceActivityCard } from "@/components/workspace/activity/workspace-activity-card";
37
+ import { WorkspaceMembersManager } from "@/components/workspace/members/workspace-members-manager";
38
+ import type { WorkspaceKbCardData } from "@/server/serverFns/workspace-kb-utils";
38
39
  import { m } from "@/paraglide/messages.js";
39
40
 
40
41
  type DetailData = {
@@ -75,10 +76,11 @@ type DetailData = {
75
76
  joinedAt: Date;
76
77
  user?: { id: string; username: string; email: string; avatarConfig?: unknown; bgShape?: string | null; bgColor?: string | null };
77
78
  }[];
79
+ kbCard: WorkspaceKbCardData;
78
80
  };
79
81
 
80
82
  function WorkspaceDetailPage() {
81
- const { workspace, projects, activities, contributionData, members } = Route.useLoaderData() as DetailData;
83
+ const { workspace, projects, activities, contributionData, members, kbCard } = Route.useLoaderData() as DetailData;
82
84
  const [editOpen, setEditOpen] = useState(false);
83
85
  const [deleteOpen, setDeleteOpen] = useState(false);
84
86
  const [projectEditOpen, setProjectEditOpen] = useState(false);
@@ -150,7 +152,7 @@ function WorkspaceDetailPage() {
150
152
  onEdit={() => setEditOpen(true)}
151
153
  onDelete={() => setDeleteOpen(true)}
152
154
  />
153
- <WorkspaceKbCard />
155
+ <WorkspaceKbCard workspaceId={workspace.id} data={kbCard} />
154
156
  <div className="grid gap-6 lg:grid-cols-3">
155
157
  <div className="lg:col-span-2 space-y-6">
156
158
  <WorkspaceContributionCard data={contributionData} />
@@ -166,7 +168,7 @@ function WorkspaceDetailPage() {
166
168
  workspaceId={workspace.id}
167
169
  workspaceOwnerId={workspace.ownerId}
168
170
  members={members}
169
- onMembersUpdated={() => window.dispatchEvent(new CustomEvent("workspace-members-updated"))}
171
+ onMembersUpdated={() => { void router.invalidate(); }}
170
172
  />
171
173
  </div>
172
174
  </div>
@@ -23,17 +23,19 @@ import {
23
23
  getWorkspaceActivityDataFn,
24
24
  listWorkspaceMembersFn,
25
25
  } from "@/server/serverFns/workspace";
26
+ import { getWorkspaceKbCardFn } from "@/server/serverFns/workspace-kb";
26
27
 
27
28
  export const Route = createFileRoute("/_nav/workspaces/$workspaceId")({
28
29
  loader: async ({ params }) => {
29
- const [workspace, projects, activities, contributionData, members] = await Promise.all([
30
+ const [workspace, projects, activities, contributionData, members, kbCard] = await Promise.all([
30
31
  getWorkspaceDetailFn({ data: { workspaceId: params.workspaceId } }),
31
32
  listWorkspaceProjectsFn({ data: { workspaceId: params.workspaceId } }),
32
33
  listWorkspaceActivitiesFn({ data: { workspaceId: params.workspaceId, take: 5 } }),
33
34
  getWorkspaceActivityDataFn({ data: { workspaceId: params.workspaceId } }),
34
35
  listWorkspaceMembersFn({ data: { workspaceId: params.workspaceId } }),
36
+ getWorkspaceKbCardFn({ data: { workspaceId: params.workspaceId } }),
35
37
  ]);
36
- return { workspace, projects, activities, contributionData, members };
38
+ return { workspace, projects, activities, contributionData, members, kbCard };
37
39
  },
38
40
  pendingComponent: () => null,
39
41
  });
@@ -28,7 +28,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
28
28
  import { LucideIconPicker } from "@/components/shared-kit/icons/lucide-icon-picker";
29
29
 
30
30
  import { NameConfirmDialog } from "@/components/shared-kit/dialogs/name-confirm-dialog";
31
- import { DeleteWorkspaceOptions } from "@/components/workspace/delete-workspace-options";
31
+ import { DeleteWorkspaceOptions } from "@/components/workspace/dialogs/delete-workspace-options";
32
32
  import {
33
33
  updateWorkspaceFn,
34
34
  deleteWorkspaceFn,
@@ -30,9 +30,9 @@ import {
30
30
  import { Separator } from "@/components/ui/separator";
31
31
  import { GlyphMatrix } from "@/components/magicui/glyph-matrix";
32
32
  import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name";
33
- import { CreateWorkspaceDialog } from "@/components/workspace/create-workspace-dialog";
34
- import { DeleteWorkspaceOptions } from "@/components/workspace/delete-workspace-options";
35
- import { EditWorkspaceDialog } from "@/components/workspace/edit-workspace-dialog";
33
+ import { CreateWorkspaceDialog } from "@/components/workspace/dialogs/create-workspace-dialog";
34
+ import { DeleteWorkspaceOptions } from "@/components/workspace/dialogs/delete-workspace-options";
35
+ import { EditWorkspaceDialog } from "@/components/workspace/dialogs/edit-workspace-dialog";
36
36
  import { NameConfirmDialog } from "@/components/shared-kit/dialogs/name-confirm-dialog";
37
37
  import { toggleWorkspacePinFn, deleteWorkspaceFn } from "@/server/serverFns/workspace";
38
38
  import { toast } from "@/components/ui/sonner";
@@ -17,11 +17,12 @@
17
17
 
18
18
  import { createFileRoute, redirect } from '@tanstack/react-router'
19
19
  import { isSaasEdition } from '@/lib/edition'
20
+ import { isDesktopEdition } from '@/lib/edition-desktop'
20
21
  import AgencyHeroSection from '@/components/shadcn-space/blocks/hero-01'
21
22
 
22
23
  export const Route = createFileRoute('/_public/')({
23
24
  beforeLoad: () => {
24
- if (!isSaasEdition) throw redirect({ to: '/dashboard' })
25
+ if (!isSaasEdition || isDesktopEdition()) throw redirect({ to: '/dashboard' })
25
26
  },
26
27
  component: IndexPage,
27
28
  })
@@ -2,7 +2,7 @@ import { createFileRoute } from '@tanstack/react-router'
2
2
  import { getSession } from 'start-authjs'
3
3
  import { prisma } from '@allbluecn/database'
4
4
  import { getProService, isProEdition } from '@/server/pro-services'
5
- import { authConfig } from '@/lib/auth/auth.config'
5
+ import { getAuthConfig } from '@/lib/auth/auth.config'
6
6
  import { generateEntitlementsToken } from '@/server/entitlements-token'
7
7
  import type { FeatureKey } from '@allbluecn/kernel'
8
8
 
@@ -10,7 +10,7 @@ export const Route = createFileRoute('/api/auth/desktop-callback')({
10
10
  server: {
11
11
  handlers: {
12
12
  GET: async ({ request }) => {
13
- const session = await getSession(request, authConfig)
13
+ const session = await getSession(request, await getAuthConfig())
14
14
  if (!session?.user) {
15
15
  return new Response('未登录', { status: 401 })
16
16
  }
@@ -18,6 +18,7 @@
18
18
  import { createFileRoute } from '@tanstack/react-router'
19
19
  import { getProService } from '@/server/pro-services'
20
20
  import { WebhookSignatureError } from '@allbluecn/kernel'
21
+ import { sendBillingEmailForEvent } from '@/server/billing-email'
21
22
 
22
23
  export const Route = createFileRoute('/api/billing-webhook')({
23
24
  server: {
@@ -32,6 +33,9 @@ export const Route = createFileRoute('/api/billing-webhook')({
32
33
  const rawBody = await request.text()
33
34
  try {
34
35
  await (getProService('billing')!)?.handleWebhookEvent(rawBody, signature)
36
+ if (getProService('billing')) {
37
+ await sendBillingEmailForEvent(rawBody)
38
+ }
35
39
  return new Response(JSON.stringify({ received: true }), {
36
40
  status: 200,
37
41
  headers: { 'Content-Type': 'application/json' },
@@ -0,0 +1,100 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createFileRoute } from '@tanstack/react-router'
19
+ import { prisma } from '@allbluecn/database'
20
+ import { sendTemplateEmail } from '@/server/email'
21
+ import { APP_VERSION } from '@/lib/app-version'
22
+
23
+ const BATCH_SIZE = 50
24
+ const LAST_RELEASED_KEY = 'changelog.lastReleasedVersion'
25
+
26
+ async function isAuthorized(request: Request): Promise<boolean> {
27
+ const secret = process.env.CRON_SECRET
28
+ if (!secret) {
29
+ return process.env.NODE_ENV !== 'production'
30
+ }
31
+ return (request.headers.get('authorization') ?? '') === `Bearer ${secret}`
32
+ }
33
+
34
+ export const Route = createFileRoute('/api/cron/changelog-release')({
35
+ server: {
36
+ handlers: {
37
+ GET: async ({ request }) => {
38
+ if (!(await isAuthorized(request))) {
39
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
40
+ }
41
+
42
+ const current = APP_VERSION
43
+ const last = await prisma.systemSetting.findUnique({
44
+ where: { key: LAST_RELEASED_KEY },
45
+ })
46
+ if (last?.value === current) {
47
+ return Response.json({ done: true, reason: 'up-to-date' })
48
+ }
49
+
50
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
51
+
52
+ // Prisma 无 NOT EXISTS 便捷写法:用 emailLog.dedupeKey 前缀反查已发送 email,
53
+ // 再在 subscriber 查询层用 notIn 排除,避免「取 batch → filter」导致的死循环。
54
+ const sentEmails = await prisma.emailLog.findMany({
55
+ where: { dedupeKey: { startsWith: `release:${current}:` } },
56
+ select: { to: true },
57
+ })
58
+ const sentEmailSet = new Set(sentEmails.map((e) => e.to))
59
+
60
+ const subs = await prisma.emailSubscriber.findMany({
61
+ where: {
62
+ confirmedAt: { not: null },
63
+ unsubscribedAt: null,
64
+ email: { notIn: [...sentEmailSet] },
65
+ },
66
+ select: { email: true, locale: true, confirmToken: true },
67
+ take: BATCH_SIZE,
68
+ })
69
+
70
+ let sent = 0
71
+ for (const sub of subs) {
72
+ const dedupeKey = `release:${current}:${sub.email}`
73
+ const res = await sendTemplateEmail({
74
+ key: 'product.changelog-release',
75
+ to: sub.email,
76
+ locale: sub.locale,
77
+ dedupeKey,
78
+ unsubscribeUrl: `${appUrl}/email-unsubscribe?token=${sub.confirmToken}`,
79
+ vars: {
80
+ version: current,
81
+ highlights: '',
82
+ detailUrl: `${appUrl}/changelog`,
83
+ },
84
+ })
85
+ if (res.sent) sent++
86
+ }
87
+
88
+ if (subs.length < BATCH_SIZE) {
89
+ await prisma.systemSetting.upsert({
90
+ where: { key: LAST_RELEASED_KEY },
91
+ create: { key: LAST_RELEASED_KEY, value: current, desc: 'changelog 已发布版本号' },
92
+ update: { value: current },
93
+ })
94
+ return Response.json({ done: true, sent })
95
+ }
96
+ return Response.json({ done: false, sent })
97
+ },
98
+ },
99
+ },
100
+ })
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { createFileRoute } from '@tanstack/react-router'
19
+ import { prisma } from '@allbluecn/database'
20
+ import { sendTemplateEmail } from '@/server/email'
21
+
22
+ const DAY_MS = 24 * 60 * 60 * 1000
23
+
24
+ const WINDOWS = [
25
+ { days: 7, offsetMs: 7 * DAY_MS },
26
+ { days: 3, offsetMs: 3 * DAY_MS },
27
+ { days: 1, offsetMs: 1 * DAY_MS },
28
+ ] as const
29
+
30
+ function startOfDay(d: Date): Date {
31
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
32
+ }
33
+
34
+ function dateOnly(d: Date): string {
35
+ return d.toISOString().slice(0, 10)
36
+ }
37
+
38
+ async function isAuthorized(request: Request): Promise<boolean> {
39
+ const secret = process.env.CRON_SECRET
40
+ if (!secret) {
41
+ return process.env.NODE_ENV !== 'production'
42
+ }
43
+ return (request.headers.get('authorization') ?? '') === `Bearer ${secret}`
44
+ }
45
+
46
+ export const Route = createFileRoute('/api/cron/subscription-reminders')({
47
+ server: {
48
+ handlers: {
49
+ GET: async ({ request }) => {
50
+ if (!(await isAuthorized(request))) {
51
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
52
+ }
53
+
54
+ const todayStart = startOfDay(new Date())
55
+ const wideUntil = new Date(todayStart.getTime() + WINDOWS[0].offsetMs)
56
+
57
+ const subs = await prisma.subscription.findMany({
58
+ where: {
59
+ OR: [
60
+ {
61
+ status: 'ACTIVE',
62
+ cancelAtPeriodEnd: true,
63
+ currentPeriodEnd: { gte: todayStart, lte: wideUntil },
64
+ },
65
+ {
66
+ status: 'TRIALING',
67
+ trialEndsAt: { gte: todayStart, lte: wideUntil },
68
+ },
69
+ ],
70
+ },
71
+ include: {
72
+ user: { select: { email: true, locale: true } },
73
+ plan: { select: { displayName: true } },
74
+ },
75
+ })
76
+
77
+ const appUrl = process.env.VITE_APP_URL ?? 'http://localhost:3000'
78
+ let processed = 0
79
+
80
+ for (const sub of subs) {
81
+ if (!sub.user?.email || !sub.plan) continue
82
+ const endDate = sub.status === 'TRIALING' ? sub.trialEndsAt : sub.currentPeriodEnd
83
+ if (!endDate) continue
84
+ const endDateStr = dateOnly(endDate)
85
+
86
+ for (const w of WINDOWS) {
87
+ const until = new Date(todayStart.getTime() + w.offsetMs)
88
+ if (endDate < todayStart || endDate > until) continue
89
+ const dedupeKey = `sub-reminder:${sub.id}:${endDateStr}:${w.days}`
90
+ const res = await sendTemplateEmail({
91
+ key: 'billing.subscription-ending',
92
+ to: sub.user.email,
93
+ locale: sub.user.locale ?? 'zh',
94
+ userId: sub.userId,
95
+ dedupeKey,
96
+ vars: {
97
+ planName: sub.plan.displayName,
98
+ endDate: endDateStr,
99
+ dashboardUrl: `${appUrl}/settings/billing`,
100
+ },
101
+ })
102
+ if (res.sent) processed++
103
+ }
104
+ }
105
+
106
+ return Response.json({ processed })
107
+ },
108
+ },
109
+ },
110
+ })
@@ -16,16 +16,14 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { createFileRoute } from '@tanstack/react-router'
19
- import { authConfig } from '@/lib/auth/auth.config'
19
+ import { getAuthConfig } from '@/lib/auth/auth.config'
20
20
  import { StartAuthJS } from 'start-authjs'
21
21
 
22
- const authHandlers = StartAuthJS(authConfig)
23
-
24
22
  export const Route = createFileRoute('/api/auth/$')({
25
23
  server: {
26
24
  handlers: {
27
- GET: ({ request }) => authHandlers.GET({ request }),
28
- POST: ({ request }) => authHandlers.POST({ request }),
25
+ GET: async ({ request }) => (await StartAuthJS(await getAuthConfig())).GET({ request }),
26
+ POST: async ({ request }) => (await StartAuthJS(await getAuthConfig())).POST({ request }),
29
27
  },
30
28
  },
31
29
  })
@@ -178,14 +178,14 @@ describe('agent-crud serverFn', () => {
178
178
 
179
179
  it('FeatureUnavailableError / FeatureForbiddenError → 抛错不建', async () => {
180
180
  mocks.requireFeature.mockRejectedValue(new Error('FeatureUnavailableError: xxx'))
181
- await expect(createAgentFn({ data: { name: 'X', systemPrompt: 'Y' } })).rejects.toMatchObject({ message: expect.stringMatching(/FeatureUnavailable|FeatureForbidden/) })
181
+ await expect(createAgentFn({ data: { name: 'X', systemPrompt: 'Y' } })).rejects.toThrow(/FeatureUnavailable|FeatureForbidden/)
182
182
  expect(mocks.create).not.toHaveBeenCalled()
183
183
  })
184
184
 
185
185
  it('ResourceLimitError → 抛错不建', async () => {
186
186
  mocks.requireFeature.mockResolvedValue(undefined as any)
187
187
  mocks.requireResource.mockRejectedValue(new Error('ResourceLimitError: agentQuota 已达上限'))
188
- await expect(createAgentFn({ data: { name: 'X', systemPrompt: 'Y' } })).rejects.toMatchObject({ message: expect.stringMatching(/ResourceLimit|已达上限/) })
188
+ await expect(createAgentFn({ data: { name: 'X', systemPrompt: 'Y' } })).rejects.toThrow(/ResourceLimit|已达上限/)
189
189
  expect(mocks.create).not.toHaveBeenCalled()
190
190
  })
191
191
 
@@ -236,7 +236,7 @@ describe('agent-crud serverFn', () => {
236
236
 
237
237
  it('userId 不匹配 → 抛错且不调用 update', async () => {
238
238
  mocks.findFirst.mockResolvedValue(null)
239
- await expect(updateAgentFn({ data: { id: 'ag-other', name: 'X' } })).rejects.toMatchObject({ message: expect.stringMatching(/无权限|FORBIDDEN|not found/) })
239
+ await expect(updateAgentFn({ data: { id: 'ag-other', name: 'X' } })).rejects.toThrow(/无权限|FORBIDDEN|not found/)
240
240
  expect(mocks.update).not.toHaveBeenCalled()
241
241
  })
242
242
 
@@ -272,7 +272,7 @@ describe('agent-crud serverFn', () => {
272
272
 
273
273
  it('userId 不匹配 → 抛错且不调用 update', async () => {
274
274
  mocks.findFirst.mockResolvedValue(null)
275
- await expect(deleteAgentFn({ data: { id: 'ag-other' } })).rejects.toMatchObject({ message: expect.stringMatching(/无权限|FORBIDDEN|not found/) })
275
+ await expect(deleteAgentFn({ data: { id: 'ag-other' } })).rejects.toThrow(/无权限|FORBIDDEN|not found/)
276
276
  expect(mocks.update).not.toHaveBeenCalled()
277
277
  })
278
278
  })
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { consumeInvitationCode, type ConsumeResult } from '../auth-registration'
3
+ import type { Prisma } from '@allbluecn/database'
4
+
5
+ type TxMock = {
6
+ invitationCode: {
7
+ findUnique: ReturnType<typeof vi.fn>
8
+ updateMany: ReturnType<typeof vi.fn>
9
+ }
10
+ }
11
+
12
+ function makeTx(overrides: Record<string, unknown> = {}): { tx: Prisma.TransactionClient; mock: TxMock } {
13
+ const mock: TxMock = {
14
+ invitationCode: {
15
+ findUnique: vi.fn().mockResolvedValue({ id: 'code_1', maxUses: 2 }),
16
+ updateMany: vi.fn().mockResolvedValue({ count: 1 }),
17
+ ...overrides,
18
+ },
19
+ }
20
+ return { tx: mock as unknown as Prisma.TransactionClient, mock }
21
+ }
22
+
23
+ describe('consumeInvitationCode', () => {
24
+ it('有效邀请码:占一位并返回 codeId', async () => {
25
+ const { tx, mock } = makeTx()
26
+ const r: ConsumeResult = await consumeInvitationCode(tx, 'ABC')
27
+ expect(r).toEqual({ ok: true, codeId: 'code_1' })
28
+ expect(mock.invitationCode.updateMany).toHaveBeenCalledWith({
29
+ where: expect.objectContaining({
30
+ id: 'code_1',
31
+ usedCount: { lt: 2 },
32
+ disabledAt: null,
33
+ expiresAt: { gt: expect.any(Date) },
34
+ }),
35
+ data: { usedCount: { increment: 1 } },
36
+ })
37
+ })
38
+
39
+ it('邀请码不存在:返回「邀请码无效」', async () => {
40
+ const { tx, mock } = makeTx({ findUnique: vi.fn().mockResolvedValue(null) })
41
+ const r = await consumeInvitationCode(tx, 'NOPE')
42
+ expect(r).toEqual({ ok: false, error: '邀请码无效' })
43
+ expect(mock.invitationCode.updateMany).not.toHaveBeenCalled()
44
+ })
45
+
46
+ it('配额耗尽/过期/禁用:updateMany count=0 → 返回用尽错误', async () => {
47
+ const { tx } = makeTx({ updateMany: vi.fn().mockResolvedValue({ count: 0 }) })
48
+ const r = await consumeInvitationCode(tx, 'FULL')
49
+ expect(r).toEqual({ ok: false, error: '邀请码已用尽、过期或被禁用' })
50
+ })
51
+
52
+ it('邀请码大小写与首尾空格归一化后匹配', async () => {
53
+ const { tx, mock } = makeTx()
54
+ await consumeInvitationCode(tx, ' abc ')
55
+ expect(mock.invitationCode.findUnique).toHaveBeenCalledWith({
56
+ where: { code: 'ABC' },
57
+ select: { id: true, maxUses: true },
58
+ })
59
+ })
60
+ })
@@ -145,9 +145,7 @@ describe("createConversationFn", () => {
145
145
 
146
146
  await expect(
147
147
  createConversationFn({ data: { agentId: "agent-xxx" } }),
148
- ).rejects.toMatchObject({
149
- message: expect.stringContaining("not found"),
150
- })
148
+ ).rejects.toThrow("not found")
151
149
  expect(mocks.create).not.toHaveBeenCalled()
152
150
  })
153
151
 
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { assertFetchableUrl, FetchGuardError } from "../fetch-guard";
3
+
4
+ describe("assertFetchableUrl SSRF 主机防护", () => {
5
+ it.each([
6
+ "http://127.0.0.1:8080/api",
7
+ "http://localhost:3000/x",
8
+ "http://10.0.0.5/internal",
9
+ "http://172.16.1.1/admin",
10
+ "http://192.168.1.1/router",
11
+ "http://169.254.169.254/latest/meta-data", // 云元数据
12
+ "http://0.0.0.0/",
13
+ "http://[::1]:8080/",
14
+ "http://[fe80::1]/", // 链路本地
15
+ "http://[::ffff:127.0.0.1]/", // IPv4-mapped IPv6 回环(SSRF 经典绕过)
16
+ "http://[::ffff:a9fe:a9fe]/", // IPv4-mapped 云元数据
17
+ "http://[::ffff:10.0.0.5]/", // mapped 归一形态 ::ffff:a00:5(URL 压缩组前导零)
18
+ "http://[::ffff:0:0:127.0.0.1]/", // IPv4-translated 变体(::ffff:0:0/96 前缀)
19
+ "http://[::127.0.0.1]/", // IPv4-compatible ::/96 回环
20
+ "http://[::a9fe:a9fe]/", // IPv4-compatible 云元数据
21
+ "http://[::10.0.0.5]/", // IPv4-compatible A 类私网
22
+ "http://[fc00::1]/", // IPv6 ULA 私网段
23
+ "http://[fd12:3456::1]/",
24
+ ])("拒绝私网/回环/元数据地址:%s", (url) => {
25
+ expect(() => assertFetchableUrl(url)).toThrow(FetchGuardError);
26
+ });
27
+
28
+ it.each([
29
+ "https://api.openai.com/v1/models",
30
+ "http://example.com/page",
31
+ ])("放行公网地址:%s", (url) => {
32
+ expect(() => assertFetchableUrl(url)).not.toThrow();
33
+ });
34
+
35
+ it.each([
36
+ ["http://[2001:4860:4860::8888]/", "普通公网 IPv6"],
37
+ ["http://[::ffff:8.8.8.8]/", "IPv4-mapped 公网(防映射还原分支误伤)"],
38
+ ["http://[::ffff:0:0:8.8.8.8]/", "IPv4-mapped 翻译形态公网(防误伤对称覆盖)"],
39
+ ["http://[::8.8.8.8]/", "IPv4-compatible 公网(防内嵌分支误伤)"],
40
+ ["http://[2606:4700:4700::1111]/", "Cloudflare 公网 IPv6"],
41
+ ])("公网 IPv6 地址放行:%s(%s)", (url) => {
42
+ expect(() => assertFetchableUrl(url)).not.toThrow();
43
+ });
44
+
45
+ it.each([
46
+ "file:///etc/passwd",
47
+ "ftp://example.com/f",
48
+ "gopher://example.com/",
49
+ ])("仅允许 http/https 协议:%s", (url) => {
50
+ expect(() => assertFetchableUrl(url)).toThrow(FetchGuardError);
51
+ });
52
+
53
+ it("allowPrivate 显式放行内网(SearXNG 自托管场景)", () => {
54
+ expect(() =>
55
+ assertFetchableUrl("http://127.0.0.1:8888/search", { allowPrivate: true }),
56
+ ).not.toThrow();
57
+ });
58
+
59
+ it("allowHosts 白名单精确匹配放行", () => {
60
+ expect(() =>
61
+ assertFetchableUrl("https://searx.internal.corp/search", {
62
+ allowedHosts: ["searx.internal.corp"],
63
+ }),
64
+ ).not.toThrow();
65
+ expect(() =>
66
+ assertFetchableUrl("https://evil.corp/search", {
67
+ allowedHosts: ["searx.internal.corp"],
68
+ }),
69
+ ).toThrow(FetchGuardError);
70
+ });
71
+
72
+ it.each([
73
+ ["http://2130706433/", "十进制整数 127.0.0.1"],
74
+ ["http://0x7f000001/", "十六进制 127.0.0.1"],
75
+ ["http://017700000001/", "八进制整数 127.0.0.1"],
76
+ ["http://0177.0.0.1/", "点分八进制 127.0.0.1"],
77
+ ["http://99999999999999/", "超出 IPv4 范围(URL 构造期抛 Invalid URL)"],
78
+ ])("拒绝整数形式 IP 混淆:%s(%s)", (url) => {
79
+ expect(() => assertFetchableUrl(url)).toThrow(FetchGuardError);
80
+ });
81
+
82
+ it("返回规范化 URL 供 fetch 使用", () => {
83
+ expect(assertFetchableUrl("https://example.com/a?q=1").href).toBe(
84
+ "https://example.com/a?q=1",
85
+ );
86
+ });
87
+
88
+ it("大写协议与省略段 IPv4 规范化后仍被拒绝", () => {
89
+ expect(() => assertFetchableUrl("HTTP://127.0.0.1/x")).toThrow(FetchGuardError);
90
+ expect(() => assertFetchableUrl("http://127.1/")).toThrow(FetchGuardError);
91
+ });
92
+
93
+ it("协议被拒绝时错误类型仍是 FetchGuardError", () => {
94
+ expect(() => assertFetchableUrl("file:///etc/passwd")).toThrow(FetchGuardError);
95
+ expect(() => assertFetchableUrl("file:///etc/passwd")).toThrow(
96
+ /不在允许范围/,
97
+ );
98
+ });
99
+ });
@@ -62,8 +62,6 @@ describe('requirePermission', () => {
62
62
  it('有角色但 checkPermission 返回 none → 拒绝', async () => {
63
63
  vi.mocked(prisma.roleAssignment.count).mockResolvedValue(1)
64
64
  vi.mocked(getProService('permissions')!.checkPermission).mockResolvedValue('none')
65
- await expect(requirePermission('u1', 'knowledge', 'write')).rejects.toMatchObject({
66
- name: 'PermissionForbiddenError',
67
- })
65
+ await expect(requirePermission('u1', 'knowledge', 'write')).rejects.toBeInstanceOf(PermissionForbiddenError)
68
66
  })
69
67
  })