From 240567a937a5ecf66f2947e446fb4289c701630e Mon Sep 17 00:00:00 2001 From: fb0sh Date: Wed, 7 Oct 2026 14:19:26 +0800 Subject: [PATCH 01/11] =?UTF-8?q?refactor(frontend):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=20sdk/react/frontend-runtime=20=E4=B8=89=E4=B8=AA=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=E5=BD=93=E5=89=8D=20UI=20=E8=BF=81=E5=85=A5=20fronten?= =?UTF-8?q?ds/default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 可插拔前端平台的第一阶段:搭出正确的依赖方向,且**不改动任何 UI/UX**。 依赖方向(唯一允许): Backend → @floatctf/sdk → @floatctf/react → Frontend ↘ (非 React 前端直接用 sdk) @floatctf/frontend-runtime = 制品/运行时契约(框架无关) 新增包: - packages/frontend-runtime:frontend.json 严格校验(schemaVersion/安全 ID/semver/ 相对路径/路径穿越/兼容性)、registry.json 校验与**显式 currentVersion** 解析、 mount(context) 契约、bootstrapFrontend(同源动态 import + 回退 default + 内置兜底页)。 契约版本独立:FRONTEND_RUNTIME_VERSION / API_CONTRACT_VERSION / registry schema。 - packages/sdk:createFloatCTFClient(DI:getUserToken/getAdminToken/onUnauthorized, 不再 import router 与 AuthStore)、FloatCTFError(HTTP 状态/平台码/平台文案/original, 并保留 axios 兼容的 response 形态以免改动任何页面错误处理)、全部领域 API 客户端、 fetch-based SSE(Bearer 走 Authorization 头,行为逐字保留)、生成实体(@floatctf/sdk/entity) 与从路由页面反向 import 的 DTO 类型。 - packages/react:可选 headless React 绑定(query 工厂、SSE hook、失效工具)。 无 Primer/Tailwind/路由/页面/JSX(非测试目录无 .tsx)。 迁移: - apps/web/src/** → frontends/default/src/**(286 文件,git mv 保留历史)。 UUID 路由、布局、Primer 样式、AWD/AWDP UX、Zustand 登录态(含 localStorage) 全部原样保留;页面代码仅 import 路径变化。 - frontends/default 自带 Vite(dev HMR:mise run dev:web)+ 生产 lib 构建 (base "./"、确定性入口 assets/frontend.js、单文件 CSS、构建期用真实校验器 校验生成的 frontend.json)。 - apps/web 变成 bootstrap:14KB / gzip 5KB,无 React、无 Primer、无路由; 只做 ?frontend= 破窗 + bootstrapFrontend()。 - 契约常量落到 apps/api/src/core/contract.rs(API/运行时 major + 能力白名单), 与 packages/frontend-runtime 的声明由 scripts/check-architecture.sh 断言一致。 门禁:新增 scripts/check-architecture.sh(SDK 不得依赖 React/路由/状态库/Primer; react 包必须 headless;默认前端不得逃出自身包;bootstrap 产物不得含默认前端), 接入 mise run lint。测试:frontends/default 173、sdk 51(含 SSE 33 迁移)、react 10、 frontend-runtime 66,全部通过;cargo check 通过。 --- apps/api/src/core/contract.rs | 62 + apps/api/src/core/mod.rs | 4 + apps/web/.env.production | 2 +- apps/web/README.md | 13 +- apps/web/biome.json | 31 +- apps/web/index.html | 3 +- apps/web/package.json | 91 +- .../api/__tests__/axiosInterceptors.test.ts | 103 - apps/web/src/api/admin/index.ts | 30 - apps/web/src/api/axios.ts | 74 - apps/web/src/api/index.ts | 87 - .../api/queries/__tests__/query-keys.test.ts | 40 - apps/web/src/api/queries/index.ts | 9 - apps/web/src/api/service/index.ts | 10 - apps/web/src/entity/index.ts | 60 - apps/web/src/lib/sse/index.ts | 9 - apps/web/src/main.ts | 82 + apps/web/src/main.tsx | 75 - apps/web/tsconfig.json | 20 +- apps/web/vite.config.ts | 64 +- frontends/default/biome.json | 51 + frontends/default/index.html | 21 + frontends/default/package.json | 74 + frontends/default/src/api/client.ts | 39 + frontends/default/src/api/index.ts | 32 + frontends/default/src/api/react.ts | 34 + .../default}/src/components/ActionSelect.tsx | 2 +- .../src/components/EventStatusBadge.tsx | 0 .../default}/src/components/FilterBar.tsx | 0 .../default}/src/components/Header.tsx | 2 +- .../default}/src/components/MDPlusEditor.tsx | 2 +- .../default}/src/components/MsgBanner.tsx | 0 .../src/components/MsgInlineBanner.tsx | 0 .../default}/src/components/RouteLoading.tsx | 0 .../src/components/RouterUnderlineNavItem.tsx | 0 .../default}/src/components/SideBar.test.tsx | 0 .../default}/src/components/SideBar.tsx | 0 .../default}/src/components/SubmitWriteup.tsx | 0 .../default}/src/components/Table.tsx | 2 +- .../default}/src/components/Truncate.tsx | 0 .../src/components/UnderlineNavButton.tsx | 0 .../__tests__/RouterUnderlineNavItem.test.tsx | 0 .../__tests__/UnderlineNavButton.test.tsx | 0 .../components/admin/EventInstancesTable.tsx | 2 +- .../src/components/apiErrorMessage.ts | 0 .../src/components/awd/AwdEventProgress.tsx | 2 +- .../awd/__tests__/AwdStateLogic.test.ts | 0 .../awd/__tests__/EventNetworkPage.test.tsx | 2 +- .../__tests__/PlatformNetworkPage.test.tsx | 2 +- .../awd/__tests__/networkForm.test.ts | 0 .../src/components/awd/networkForm.ts | 2 +- .../src/components/awdp/AwdpEventProgress.tsx | 2 +- .../src/components/awdp/AwdpPhaseOverview.tsx | 2 +- .../src/components/awdp/AwdpScoreboard.tsx | 2 +- .../src/components/awdp/AwdpWorkbench.tsx | 2 +- .../src/components/awdp/RunWriteupEditor.tsx | 2 +- .../awdp/__tests__/AwdpEventProgress.test.tsx | 4 +- .../awdp/__tests__/AwdpPhaseOverview.test.tsx | 0 .../awdp/__tests__/AwdpScoreboard.test.tsx | 2 +- .../awdp/__tests__/AwdpWorkbench.test.tsx | 2 +- .../default}/src/components/index.tsx | 0 {apps/web => frontends/default}/src/config.ts | 0 frontends/default/src/dev.tsx | 27 + frontends/default/src/entry.tsx | 64 + .../integrations/tanstack-query/devtools.tsx | 0 .../tanstack-query/root-provider.tsx | 0 {apps/web => frontends/default}/src/logo.svg | 0 .../default}/src/navigation/AppLink.tsx | 0 .../src/navigation/NavigationContext.tsx | 0 .../src/navigation/NavigationProgress.tsx | 0 .../src/navigation/__tests__/AppLink.test.tsx | 0 .../__tests__/navigation-race.test.tsx | 0 .../__tests__/service-navigation.test.ts | 0 .../__tests__/sidebar-utils.test.ts | 0 .../src/navigation/admin-navigation.tsx | 0 .../default}/src/navigation/index.ts | 0 .../src/navigation/navigation-types.ts | 0 .../src/navigation/service-navigation.tsx | 0 .../default}/src/navigation/sidebar-types.ts | 0 .../default}/src/navigation/sidebar-utils.ts | 0 .../default}/src/navigation/useAppNavigate.ts | 0 .../default}/src/reportWebVitals.ts | 0 frontends/default/src/routeTree.gen.ts | 2413 +++++++++++++++++ frontends/default/src/router.tsx | 52 + .../default}/src/routes/__root.tsx | 0 .../src/routes/admin/announcements.tsx | 2 +- .../src/routes/admin/awd/gameboxes.tsx | 4 +- .../default}/src/routes/admin/awd/network.tsx | 4 +- .../src/routes/admin/challenge_sets/$id.tsx | 2 +- .../src/routes/admin/challenge_sets/index.tsx | 2 +- .../default}/src/routes/admin/challenges.tsx | 39 +- .../default}/src/routes/admin/dashboard.tsx | 64 +- .../default}/src/routes/admin/database.tsx | 20 +- .../default}/src/routes/admin/discussions.tsx | 2 +- .../src/routes/admin/docker/images.tsx | 0 .../src/routes/admin/docker/index.tsx | 0 .../src/routes/admin/docker/networks.tsx | 0 .../src/routes/admin/docker/route.tsx | 0 .../admin/events/awd.$id/announcements.tsx | 2 +- .../routes/admin/events/awd.$id/configure.tsx | 2 +- .../routes/admin/events/awd.$id/gameboxes.tsx | 4 +- .../src/routes/admin/events/awd.$id/index.tsx | 2 +- .../routes/admin/events/awd.$id/instance.tsx | 0 .../src/routes/admin/events/awd.$id/logs.tsx | 2 +- .../routes/admin/events/awd.$id/network.tsx | 2 +- .../src/routes/admin/events/awd.$id/ops.tsx | 2 +- .../src/routes/admin/events/awd.$id/route.tsx | 4 +- .../src/routes/admin/events/awd.$id/teams.tsx | 2 +- .../routes/admin/events/awd.$id/writeups.tsx | 2 +- .../admin/events/awdp.$id/announcements.tsx | 2 +- .../admin/events/awdp.$id/configure.tsx | 2 +- .../admin/events/awdp.$id/data_present.tsx | 2 +- .../admin/events/awdp.$id/gameboxes.tsx | 6 +- .../routes/admin/events/awdp.$id/index.tsx | 0 .../routes/admin/events/awdp.$id/instance.tsx | 0 .../src/routes/admin/events/awdp.$id/logs.tsx | 2 +- .../src/routes/admin/events/awdp.$id/ops.tsx | 2 +- .../routes/admin/events/awdp.$id/route.tsx | 4 +- .../admin/events/awdp.$id/scoreboard.tsx | 2 +- .../routes/admin/events/awdp.$id/teams.tsx | 2 +- .../routes/admin/events/awdp.$id/writeups.tsx | 2 +- .../src/routes/admin/events/index.tsx | 2 +- .../events/jeopardy.$id/announcements.tsx | 2 +- .../events/jeopardy.$id/data_present.tsx | 48 +- .../admin/events/jeopardy.$id/index.tsx | 23 +- .../admin/events/jeopardy.$id/instance.tsx | 0 .../routes/admin/events/jeopardy.$id/logs.tsx | 2 +- .../admin/events/jeopardy.$id/route.tsx | 2 +- .../admin/events/jeopardy.$id/teams.tsx | 23 +- .../admin/events/jeopardy.$id/users.tsx | 14 +- .../admin/events/jeopardy.$id/writeups.tsx | 2 +- .../default}/src/routes/admin/index.tsx | 0 .../default}/src/routes/admin/logs.tsx | 2 +- .../default}/src/routes/admin/route.tsx | 0 .../src/routes/admin/scheduled_tasks.tsx | 2 +- .../default}/src/routes/admin/settings.tsx | 4 +- .../src/routes/admin/super_admins.tsx | 2 +- .../default}/src/routes/admin/terminal.tsx | 2 +- .../default}/src/routes/admin/users.tsx | 2 +- .../default}/src/routes/admin/version.tsx | 0 .../default}/src/routes/admin/weapons.tsx | 2 +- .../default}/src/routes/index.tsx | 0 .../default}/src/routes/register.tsx | 0 .../default}/src/routes/reset.tsx | 0 .../default}/src/routes/reset_password.tsx | 0 .../src/routes/service/announcements.tsx | 2 +- .../routes/service/awdp/runs.$runId/index.tsx | 4 +- .../routes/service/awdp/runs.$runId/route.tsx | 0 .../service/awdp/runs.$runId/writeup.tsx | 2 +- .../service/challenge_sets/$id/index.tsx | 6 +- .../routes/service/challenge_sets/index.tsx | 2 +- .../routes/service/challenges/$id/index.tsx | 4 +- .../routes/service/challenges/$id/route.tsx | 0 .../routes/service/challenges/$id/writeup.tsx | 17 +- .../src/routes/service/challenges/index.tsx | 2 +- .../src/routes/service/discussions/$id.tsx | 2 +- .../src/routes/service/discussions/index.tsx | 2 +- .../src/routes/service/discussions/my.tsx | 2 +- .../src/routes/service/discussions/route.tsx | 0 .../service/events/awd.$id/gameboxes.tsx | 6 +- .../routes/service/events/awd.$id/index.tsx | 4 +- .../routes/service/events/awd.$id/route.tsx | 4 +- .../service/events/awd.$id/scoreboard.tsx | 4 +- .../src/routes/service/events/awd.$id/ssh.tsx | 2 +- .../service/events/awd.$id/wireguard.tsx | 2 +- .../service/events/awdp.$id/-workbench.tsx | 2 +- .../service/events/awdp.$id/announcement.tsx | 4 +- .../service/events/awdp.$id/gameboxes.tsx | 0 .../routes/service/events/awdp.$id/index.tsx | 2 +- .../routes/service/events/awdp.$id/rounds.tsx | 2 +- .../routes/service/events/awdp.$id/route.tsx | 2 +- .../service/events/awdp.$id/scoreboard.tsx | 4 +- .../routes/service/events/awdp.$id/trend.tsx | 4 +- .../src/routes/service/events/index.tsx | 27 +- .../events/jeopardy.$id/announcement.tsx | 4 +- .../events/jeopardy.$id/challenges.tsx | 22 +- .../service/events/jeopardy.$id/index.tsx | 4 +- .../service/events/jeopardy.$id/instances.tsx | 17 +- .../service/events/jeopardy.$id/route.tsx | 2 +- .../events/jeopardy.$id/scoreboard.tsx | 25 +- .../service/events/jeopardy.$id/trend.tsx | 20 +- .../default}/src/routes/service/gameboxes.tsx | 2 +- .../default}/src/routes/service/index.tsx | 0 .../default}/src/routes/service/instances.tsx | 4 +- .../default}/src/routes/service/profile.tsx | 2 +- .../default}/src/routes/service/route.tsx | 0 .../default}/src/routes/service/solves.tsx | 2 +- .../default}/src/routes/service/top.tsx | 14 +- .../default}/src/routes/service/weapons.tsx | 2 +- .../src/routes/service/writeups/$id.tsx | 2 +- .../src/routes/service/writeups/index.tsx | 2 +- .../default}/src/stores/AuthStore.ts | 0 {apps/web => frontends/default}/src/style.css | 0 {apps/web => frontends/default}/src/util.ts | 0 frontends/default/tsconfig.json | 29 + frontends/default/vite.config.ts | 198 ++ mise.toml | 95 +- package.json | 11 +- packages/frontend-runtime/README.md | 27 + packages/frontend-runtime/package.json | 34 + .../src/__tests__/bootstrap.test.ts | 347 +++ .../src/__tests__/manifest.test.ts | 168 ++ .../src/__tests__/registry.test.ts | 268 ++ packages/frontend-runtime/src/bootstrap.ts | 320 +++ packages/frontend-runtime/src/emergency.ts | 125 + packages/frontend-runtime/src/index.ts | 82 + packages/frontend-runtime/src/manifest.ts | 248 ++ packages/frontend-runtime/src/module.ts | 87 + packages/frontend-runtime/src/paths.ts | 131 + packages/frontend-runtime/src/registry.ts | 436 +++ packages/frontend-runtime/src/version.ts | 38 + packages/frontend-runtime/tsconfig.build.json | 10 + packages/frontend-runtime/tsconfig.json | 21 + packages/frontend-runtime/vitest.config.ts | 10 + packages/react/package.json | 48 + .../src}/__tests__/awdInvalidation.test.ts | 0 .../react/src/__tests__/query-keys.test.ts | 51 + .../src}/__tests__/useAwdEventStream.test.tsx | 28 +- .../react/src}/awdInvalidation.ts | 0 packages/react/src/index.ts | 81 + .../react/src/queries/challenge.ts | 16 +- .../react/src/queries/eventInfo.ts | 9 +- packages/react/src/queries/index.ts | 25 + .../react/src/queries/systemInformation.ts | 9 +- packages/react/src/tokenSource.ts | 8 + .../react/src}/useAdminAwdEventStream.ts | 23 +- .../react/src}/useAwdEventStream.ts | 15 +- .../react/src}/useAwdpEventStream.ts | 11 +- .../react/src}/useAwdpRunStream.ts | 13 +- packages/react/tsconfig.build.json | 9 + packages/react/tsconfig.json | 22 + packages/react/vitest.config.ts | 11 + packages/sdk/README.md | 78 + packages/sdk/package.json | 53 + packages/sdk/src/__tests__/errors.test.ts | 93 + packages/sdk/src/__tests__/transport.test.ts | 153 ++ .../sdk}/src/api/admin/announcements.ts | 4 +- .../sdk}/src/api/admin/auth.ts | 2 +- .../sdk}/src/api/admin/challenges.ts | 8 +- .../sdk}/src/api/admin/dashboard.ts | 2 +- .../sdk}/src/api/admin/database.ts | 4 +- .../sdk}/src/api/admin/discussions.ts | 4 +- .../sdk}/src/api/admin/docker.ts | 2 +- .../sdk}/src/api/admin/download.ts | 2 +- .../sdk}/src/api/admin/event_announcements.ts | 4 +- .../sdk}/src/api/admin/event_challenges.ts | 6 +- .../sdk}/src/api/admin/event_logs.ts | 4 +- .../sdk}/src/api/admin/event_teams.ts | 4 +- .../sdk}/src/api/admin/event_users.ts | 4 +- .../sdk}/src/api/admin/event_writeups.ts | 4 +- .../sdk}/src/api/admin/events.ts | 6 +- packages/sdk/src/api/admin/index.ts | 30 + .../sdk}/src/api/admin/instances.ts | 4 +- .../sdk}/src/api/admin/logs.ts | 4 +- .../sdk}/src/api/admin/scheduled_tasks.ts | 4 +- .../sdk}/src/api/admin/settings.ts | 4 +- .../sdk}/src/api/admin/super_admin.ts | 4 +- .../sdk}/src/api/admin/system.ts | 4 +- .../sdk}/src/api/admin/users.ts | 4 +- .../sdk}/src/api/admin/weapons.ts | 4 +- {apps/web => packages/sdk}/src/api/awd.ts | 2 +- {apps/web => packages/sdk}/src/api/awdp.ts | 4 +- .../web => packages/sdk}/src/api/awdpRuns.ts | 4 +- packages/sdk/src/api/index.ts | 137 + .../sdk}/src/api/service/announcements.ts | 4 +- .../sdk}/src/api/service/challenges.ts | 10 +- .../sdk}/src/api/service/discussions.ts | 4 +- .../sdk}/src/api/service/events.ts | 16 +- packages/sdk/src/api/service/index.ts | 10 + .../sdk}/src/api/service/instances.ts | 30 +- .../sdk}/src/api/service/solves.ts | 6 +- .../sdk}/src/api/service/submit.ts | 4 +- .../sdk}/src/api/service/uploads.ts | 2 +- .../sdk}/src/api/service/users.ts | 4 +- .../sdk}/src/api/service/weapons.ts | 4 +- packages/sdk/src/client.ts | 115 + .../sdk}/src/entity/announcements.ts | 0 .../sdk}/src/entity/awd_event_gameboxes.ts | 0 .../sdk}/src/entity/awd_event_networks.ts | 2 +- .../sdk}/src/entity/awd_events.ts | 2 +- .../sdk}/src/entity/awd_flag_issues.ts | 0 .../sdk}/src/entity/awd_flag_submissions.ts | 0 .../entity/awd_internal_token_rotations.ts | 0 .../sdk}/src/entity/awd_judge_batches.ts | 0 .../sdk}/src/entity/awd_judge_tasks.ts | 2 +- .../src/entity/awd_network_allocations.ts | 2 +- .../sdk}/src/entity/awd_network_settings.ts | 0 .../sdk}/src/entity/awd_orphan_resources.ts | 0 .../sdk}/src/entity/awd_precheck_runs.ts | 2 +- .../sdk}/src/entity/awd_reset_records.ts | 0 .../sdk}/src/entity/awd_rounds.ts | 2 +- .../sdk}/src/entity/awd_runtime_resources.ts | 0 .../sdk}/src/entity/awd_score_events.ts | 2 +- .../sdk}/src/entity/awd_team_bans.ts | 2 +- .../sdk}/src/entity/awd_team_networks.ts | 0 .../sdk}/src/entity/awd_wireguard_peers.ts | 2 +- .../sdk}/src/entity/awdp_breaks.ts | 0 .../sdk}/src/entity/awdp_evaluation_proofs.ts | 0 .../sdk}/src/entity/awdp_evaluations.ts | 2 +- .../sdk}/src/entity/awdp_event_gameboxes.ts | 0 .../sdk}/src/entity/awdp_event_networks.ts | 0 .../sdk}/src/entity/awdp_events.ts | 0 .../sdk}/src/entity/awdp_fix_rounds.ts | 0 .../sdk}/src/entity/awdp_instances.ts | 0 .../sdk}/src/entity/awdp_patch_submissions.ts | 0 .../sdk}/src/entity/awdp_run_writeups.ts | 0 .../sdk}/src/entity/awdp_runs.ts | 2 +- .../sdk}/src/entity/awdp_score_events.ts | 0 .../sdk}/src/entity/challenge_set_items.ts | 0 .../sdk}/src/entity/challenge_sets.ts | 0 .../sdk}/src/entity/challenge_writeup.ts | 0 .../sdk}/src/entity/challenges.ts | 0 .../sdk}/src/entity/discussion_comments.ts | 0 .../sdk}/src/entity/discussion_likes.ts | 0 .../sdk}/src/entity/discussions.ts | 0 .../sdk}/src/entity/event_announcements.ts | 0 .../src/entity/event_challenge_instance.ts | 0 .../src/entity/event_gamebox_instances.ts | 2 +- .../sdk}/src/entity/event_instances.ts | 0 .../sdk}/src/entity/event_logs.ts | 2 +- .../sdk}/src/entity/event_team_members.ts | 2 +- .../sdk}/src/entity/event_teams.ts | 0 .../sdk}/src/entity/event_users.ts | 0 .../sdk}/src/entity/event_writeup.ts | 0 .../web => packages/sdk}/src/entity/events.ts | 2 +- .../sdk}/src/entity/gameboxes.ts | 0 packages/sdk/src/entity/index.ts | 60 + .../sdk}/src/entity/instance_endpoints.ts | 0 .../src/entity/jeopardy_challenge_solves.ts | 0 .../src/entity/jeopardy_event_challenges.ts | 0 {apps/web => packages/sdk}/src/entity/logs.ts | 0 .../sdk}/src/entity/scheduled_tasks.ts | 0 .../sdk}/src/entity/sea_orm_active_enums.ts | 0 .../sdk}/src/entity/settings.ts | 2 +- .../sdk}/src/entity/super_admin.ts | 0 .../web => packages/sdk}/src/entity/users.ts | 0 .../sdk}/src/entity/weapons.ts | 0 packages/sdk/src/errors.ts | 156 ++ packages/sdk/src/index.ts | 69 + packages/sdk/src/protocol.ts | 32 + .../sdk/src}/sse/__tests__/connectSse.test.ts | 0 .../sdk/src}/sse/__tests__/parser.test.ts | 0 .../sdk/src}/sse/connectSse.ts | 2 +- packages/sdk/src/sse/index.ts | 9 + .../lib => packages/sdk/src}/sse/parser.ts | 0 packages/sdk/src/transport.ts | 233 ++ packages/sdk/src/types/adminChallenge.ts | 33 + packages/sdk/src/types/adminEventChallenge.ts | 15 + packages/sdk/src/types/adminEventTeam.ts | 17 + packages/sdk/src/types/adminEventUser.ts | 8 + .../sdk}/src/types/challengeDto.ts | 2 +- packages/sdk/src/types/challengeWriteup.ts | 11 + packages/sdk/src/types/dataPresent.ts | 32 + packages/sdk/src/types/database.ts | 14 + packages/sdk/src/types/eventChallenge.ts | 12 + packages/sdk/src/types/eventInfo.ts | 21 + packages/sdk/src/types/eventInstance.ts | 9 + packages/sdk/src/types/index.ts | 42 + packages/sdk/src/types/instanceDto.ts | 30 + packages/sdk/src/types/scoreboard.ts | 17 + packages/sdk/src/types/systemInformation.ts | 54 + packages/sdk/src/types/top.ts | 8 + packages/sdk/src/types/trend.ts | 12 + packages/sdk/tsconfig.build.json | 9 + packages/sdk/tsconfig.json | 21 + packages/sdk/vitest.config.ts | 11 + pnpm-lock.yaml | 400 +-- pnpm-workspace.yaml | 1 + scripts/check-architecture.sh | 195 ++ 369 files changed, 8292 insertions(+), 1467 deletions(-) create mode 100644 apps/api/src/core/contract.rs delete mode 100644 apps/web/src/api/__tests__/axiosInterceptors.test.ts delete mode 100644 apps/web/src/api/admin/index.ts delete mode 100644 apps/web/src/api/axios.ts delete mode 100644 apps/web/src/api/index.ts delete mode 100644 apps/web/src/api/queries/__tests__/query-keys.test.ts delete mode 100644 apps/web/src/api/queries/index.ts delete mode 100644 apps/web/src/api/service/index.ts delete mode 100644 apps/web/src/entity/index.ts delete mode 100644 apps/web/src/lib/sse/index.ts create mode 100644 apps/web/src/main.ts delete mode 100644 apps/web/src/main.tsx create mode 100644 frontends/default/biome.json create mode 100644 frontends/default/index.html create mode 100644 frontends/default/package.json create mode 100644 frontends/default/src/api/client.ts create mode 100644 frontends/default/src/api/index.ts create mode 100644 frontends/default/src/api/react.ts rename {apps/web => frontends/default}/src/components/ActionSelect.tsx (98%) rename {apps/web => frontends/default}/src/components/EventStatusBadge.tsx (100%) rename {apps/web => frontends/default}/src/components/FilterBar.tsx (100%) rename {apps/web => frontends/default}/src/components/Header.tsx (98%) rename {apps/web => frontends/default}/src/components/MDPlusEditor.tsx (99%) rename {apps/web => frontends/default}/src/components/MsgBanner.tsx (100%) rename {apps/web => frontends/default}/src/components/MsgInlineBanner.tsx (100%) rename {apps/web => frontends/default}/src/components/RouteLoading.tsx (100%) rename {apps/web => frontends/default}/src/components/RouterUnderlineNavItem.tsx (100%) rename {apps/web => frontends/default}/src/components/SideBar.test.tsx (100%) rename {apps/web => frontends/default}/src/components/SideBar.tsx (100%) rename {apps/web => frontends/default}/src/components/SubmitWriteup.tsx (100%) rename {apps/web => frontends/default}/src/components/Table.tsx (99%) rename {apps/web => frontends/default}/src/components/Truncate.tsx (100%) rename {apps/web => frontends/default}/src/components/UnderlineNavButton.tsx (100%) rename {apps/web => frontends/default}/src/components/__tests__/RouterUnderlineNavItem.test.tsx (100%) rename {apps/web => frontends/default}/src/components/__tests__/UnderlineNavButton.test.tsx (100%) rename {apps/web => frontends/default}/src/components/admin/EventInstancesTable.tsx (97%) rename {apps/web => frontends/default}/src/components/apiErrorMessage.ts (100%) rename {apps/web => frontends/default}/src/components/awd/AwdEventProgress.tsx (98%) rename {apps/web => frontends/default}/src/components/awd/__tests__/AwdStateLogic.test.ts (100%) rename {apps/web => frontends/default}/src/components/awd/__tests__/EventNetworkPage.test.tsx (99%) rename {apps/web => frontends/default}/src/components/awd/__tests__/PlatformNetworkPage.test.tsx (99%) rename {apps/web => frontends/default}/src/components/awd/__tests__/networkForm.test.ts (100%) rename {apps/web => frontends/default}/src/components/awd/networkForm.ts (99%) rename {apps/web => frontends/default}/src/components/awdp/AwdpEventProgress.tsx (99%) rename {apps/web => frontends/default}/src/components/awdp/AwdpPhaseOverview.tsx (99%) rename {apps/web => frontends/default}/src/components/awdp/AwdpScoreboard.tsx (99%) rename {apps/web => frontends/default}/src/components/awdp/AwdpWorkbench.tsx (99%) rename {apps/web => frontends/default}/src/components/awdp/RunWriteupEditor.tsx (97%) rename {apps/web => frontends/default}/src/components/awdp/__tests__/AwdpEventProgress.test.tsx (99%) rename {apps/web => frontends/default}/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx (100%) rename {apps/web => frontends/default}/src/components/awdp/__tests__/AwdpScoreboard.test.tsx (98%) rename {apps/web => frontends/default}/src/components/awdp/__tests__/AwdpWorkbench.test.tsx (99%) rename {apps/web => frontends/default}/src/components/index.tsx (100%) rename {apps/web => frontends/default}/src/config.ts (100%) create mode 100644 frontends/default/src/dev.tsx create mode 100644 frontends/default/src/entry.tsx rename {apps/web => frontends/default}/src/integrations/tanstack-query/devtools.tsx (100%) rename {apps/web => frontends/default}/src/integrations/tanstack-query/root-provider.tsx (100%) rename {apps/web => frontends/default}/src/logo.svg (100%) rename {apps/web => frontends/default}/src/navigation/AppLink.tsx (100%) rename {apps/web => frontends/default}/src/navigation/NavigationContext.tsx (100%) rename {apps/web => frontends/default}/src/navigation/NavigationProgress.tsx (100%) rename {apps/web => frontends/default}/src/navigation/__tests__/AppLink.test.tsx (100%) rename {apps/web => frontends/default}/src/navigation/__tests__/navigation-race.test.tsx (100%) rename {apps/web => frontends/default}/src/navigation/__tests__/service-navigation.test.ts (100%) rename {apps/web => frontends/default}/src/navigation/__tests__/sidebar-utils.test.ts (100%) rename {apps/web => frontends/default}/src/navigation/admin-navigation.tsx (100%) rename {apps/web => frontends/default}/src/navigation/index.ts (100%) rename {apps/web => frontends/default}/src/navigation/navigation-types.ts (100%) rename {apps/web => frontends/default}/src/navigation/service-navigation.tsx (100%) rename {apps/web => frontends/default}/src/navigation/sidebar-types.ts (100%) rename {apps/web => frontends/default}/src/navigation/sidebar-utils.ts (100%) rename {apps/web => frontends/default}/src/navigation/useAppNavigate.ts (100%) rename {apps/web => frontends/default}/src/reportWebVitals.ts (100%) create mode 100644 frontends/default/src/routeTree.gen.ts create mode 100644 frontends/default/src/router.tsx rename {apps/web => frontends/default}/src/routes/__root.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/announcements.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/awd/gameboxes.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/awd/network.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/challenge_sets/$id.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/challenge_sets/index.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/challenges.tsx (95%) rename {apps/web => frontends/default}/src/routes/admin/dashboard.tsx (94%) rename {apps/web => frontends/default}/src/routes/admin/database.tsx (94%) rename {apps/web => frontends/default}/src/routes/admin/discussions.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/docker/images.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/docker/index.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/docker/networks.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/docker/route.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/announcements.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/configure.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/gameboxes.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/index.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/instance.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/logs.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/network.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/ops.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/route.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/teams.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/events/awd.$id/writeups.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/announcements.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/configure.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/data_present.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/gameboxes.tsx (96%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/index.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/instance.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/logs.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/ops.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/route.tsx (96%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/scoreboard.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/teams.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/events/awdp.$id/writeups.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/index.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/announcements.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/data_present.tsx (91%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/index.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/instance.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/logs.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/route.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/teams.tsx (94%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/users.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/events/jeopardy.$id/writeups.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/index.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/logs.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/route.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/scheduled_tasks.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/settings.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/super_admins.tsx (98%) rename {apps/web => frontends/default}/src/routes/admin/terminal.tsx (99%) rename {apps/web => frontends/default}/src/routes/admin/users.tsx (97%) rename {apps/web => frontends/default}/src/routes/admin/version.tsx (100%) rename {apps/web => frontends/default}/src/routes/admin/weapons.tsx (99%) rename {apps/web => frontends/default}/src/routes/index.tsx (100%) rename {apps/web => frontends/default}/src/routes/register.tsx (100%) rename {apps/web => frontends/default}/src/routes/reset.tsx (100%) rename {apps/web => frontends/default}/src/routes/reset_password.tsx (100%) rename {apps/web => frontends/default}/src/routes/service/announcements.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/awdp/runs.$runId/index.tsx (98%) rename {apps/web => frontends/default}/src/routes/service/awdp/runs.$runId/route.tsx (100%) rename {apps/web => frontends/default}/src/routes/service/awdp/runs.$runId/writeup.tsx (98%) rename {apps/web => frontends/default}/src/routes/service/challenge_sets/$id/index.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/challenge_sets/index.tsx (95%) rename {apps/web => frontends/default}/src/routes/service/challenges/$id/index.tsx (98%) rename {apps/web => frontends/default}/src/routes/service/challenges/$id/route.tsx (100%) rename {apps/web => frontends/default}/src/routes/service/challenges/$id/writeup.tsx (93%) rename {apps/web => frontends/default}/src/routes/service/challenges/index.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/discussions/$id.tsx (99%) rename {apps/web => frontends/default}/src/routes/service/discussions/index.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/discussions/my.tsx (98%) rename {apps/web => frontends/default}/src/routes/service/discussions/route.tsx (100%) rename {apps/web => frontends/default}/src/routes/service/events/awd.$id/gameboxes.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/events/awd.$id/index.tsx (99%) rename {apps/web => frontends/default}/src/routes/service/events/awd.$id/route.tsx (96%) rename {apps/web => frontends/default}/src/routes/service/events/awd.$id/scoreboard.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/events/awd.$id/ssh.tsx (99%) rename {apps/web => frontends/default}/src/routes/service/events/awd.$id/wireguard.tsx (99%) rename {apps/web => frontends/default}/src/routes/service/events/awdp.$id/-workbench.tsx (98%) rename {apps/web => frontends/default}/src/routes/service/events/awdp.$id/announcement.tsx (94%) rename {apps/web => frontends/default}/src/routes/service/events/awdp.$id/gameboxes.tsx (100%) rename {apps/web => frontends/default}/src/routes/service/events/awdp.$id/index.tsx (99%) rename {apps/web => frontends/default}/src/routes/service/events/awdp.$id/rounds.tsx (99%) rename {apps/web => frontends/default}/src/routes/service/events/awdp.$id/route.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/events/awdp.$id/scoreboard.tsx (90%) rename {apps/web => frontends/default}/src/routes/service/events/awdp.$id/trend.tsx (90%) rename {apps/web => frontends/default}/src/routes/service/events/index.tsx (90%) rename {apps/web => frontends/default}/src/routes/service/events/jeopardy.$id/announcement.tsx (94%) rename {apps/web => frontends/default}/src/routes/service/events/jeopardy.$id/challenges.tsx (95%) rename {apps/web => frontends/default}/src/routes/service/events/jeopardy.$id/index.tsx (98%) rename {apps/web => frontends/default}/src/routes/service/events/jeopardy.$id/instances.tsx (91%) rename {apps/web => frontends/default}/src/routes/service/events/jeopardy.$id/route.tsx (98%) rename {apps/web => frontends/default}/src/routes/service/events/jeopardy.$id/scoreboard.tsx (94%) rename {apps/web => frontends/default}/src/routes/service/events/jeopardy.$id/trend.tsx (96%) rename {apps/web => frontends/default}/src/routes/service/gameboxes.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/index.tsx (100%) rename {apps/web => frontends/default}/src/routes/service/instances.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/profile.tsx (99%) rename {apps/web => frontends/default}/src/routes/service/route.tsx (100%) rename {apps/web => frontends/default}/src/routes/service/solves.tsx (96%) rename {apps/web => frontends/default}/src/routes/service/top.tsx (94%) rename {apps/web => frontends/default}/src/routes/service/weapons.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/writeups/$id.tsx (97%) rename {apps/web => frontends/default}/src/routes/service/writeups/index.tsx (97%) rename {apps/web => frontends/default}/src/stores/AuthStore.ts (100%) rename {apps/web => frontends/default}/src/style.css (100%) rename {apps/web => frontends/default}/src/util.ts (100%) create mode 100644 frontends/default/tsconfig.json create mode 100644 frontends/default/vite.config.ts create mode 100644 packages/frontend-runtime/README.md create mode 100644 packages/frontend-runtime/package.json create mode 100644 packages/frontend-runtime/src/__tests__/bootstrap.test.ts create mode 100644 packages/frontend-runtime/src/__tests__/manifest.test.ts create mode 100644 packages/frontend-runtime/src/__tests__/registry.test.ts create mode 100644 packages/frontend-runtime/src/bootstrap.ts create mode 100644 packages/frontend-runtime/src/emergency.ts create mode 100644 packages/frontend-runtime/src/index.ts create mode 100644 packages/frontend-runtime/src/manifest.ts create mode 100644 packages/frontend-runtime/src/module.ts create mode 100644 packages/frontend-runtime/src/paths.ts create mode 100644 packages/frontend-runtime/src/registry.ts create mode 100644 packages/frontend-runtime/src/version.ts create mode 100644 packages/frontend-runtime/tsconfig.build.json create mode 100644 packages/frontend-runtime/tsconfig.json create mode 100644 packages/frontend-runtime/vitest.config.ts create mode 100644 packages/react/package.json rename {apps/web/src/hooks => packages/react/src}/__tests__/awdInvalidation.test.ts (100%) create mode 100644 packages/react/src/__tests__/query-keys.test.ts rename {apps/web/src/hooks => packages/react/src}/__tests__/useAwdEventStream.test.tsx (90%) rename {apps/web/src/hooks => packages/react/src}/awdInvalidation.ts (100%) create mode 100644 packages/react/src/index.ts rename apps/web/src/api/queries/service/challenges.ts => packages/react/src/queries/challenge.ts (56%) rename apps/web/src/api/queries/service/events.ts => packages/react/src/queries/eventInfo.ts (75%) create mode 100644 packages/react/src/queries/index.ts rename apps/web/src/api/queries/admin/system.ts => packages/react/src/queries/systemInformation.ts (64%) create mode 100644 packages/react/src/tokenSource.ts rename {apps/web/src/hooks => packages/react/src}/useAdminAwdEventStream.ts (92%) rename {apps/web/src/hooks => packages/react/src}/useAwdEventStream.ts (95%) rename {apps/web/src/hooks => packages/react/src}/useAwdpEventStream.ts (95%) rename {apps/web/src/hooks => packages/react/src}/useAwdpRunStream.ts (93%) create mode 100644 packages/react/tsconfig.build.json create mode 100644 packages/react/tsconfig.json create mode 100644 packages/react/vitest.config.ts create mode 100644 packages/sdk/README.md create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/__tests__/errors.test.ts create mode 100644 packages/sdk/src/__tests__/transport.test.ts rename {apps/web => packages/sdk}/src/api/admin/announcements.ts (86%) rename {apps/web => packages/sdk}/src/api/admin/auth.ts (79%) rename {apps/web => packages/sdk}/src/api/admin/challenges.ts (93%) rename {apps/web => packages/sdk}/src/api/admin/dashboard.ts (95%) rename {apps/web => packages/sdk}/src/api/admin/database.ts (65%) rename {apps/web => packages/sdk}/src/api/admin/discussions.ts (87%) rename {apps/web => packages/sdk}/src/api/admin/docker.ts (96%) rename {apps/web => packages/sdk}/src/api/admin/download.ts (95%) rename {apps/web => packages/sdk}/src/api/admin/event_announcements.ts (90%) rename {apps/web => packages/sdk}/src/api/admin/event_challenges.ts (93%) rename {apps/web => packages/sdk}/src/api/admin/event_logs.ts (71%) rename {apps/web => packages/sdk}/src/api/admin/event_teams.ts (88%) rename {apps/web => packages/sdk}/src/api/admin/event_users.ts (90%) rename {apps/web => packages/sdk}/src/api/admin/event_writeups.ts (71%) rename {apps/web => packages/sdk}/src/api/admin/events.ts (90%) create mode 100644 packages/sdk/src/api/admin/index.ts rename {apps/web => packages/sdk}/src/api/admin/instances.ts (88%) rename {apps/web => packages/sdk}/src/api/admin/logs.ts (60%) rename {apps/web => packages/sdk}/src/api/admin/scheduled_tasks.ts (88%) rename {apps/web => packages/sdk}/src/api/admin/settings.ts (87%) rename {apps/web => packages/sdk}/src/api/admin/super_admin.ts (85%) rename {apps/web => packages/sdk}/src/api/admin/system.ts (70%) rename {apps/web => packages/sdk}/src/api/admin/users.ts (84%) rename {apps/web => packages/sdk}/src/api/admin/weapons.ts (89%) rename {apps/web => packages/sdk}/src/api/awd.ts (99%) rename {apps/web => packages/sdk}/src/api/awdp.ts (99%) rename {apps/web => packages/sdk}/src/api/awdpRuns.ts (99%) create mode 100644 packages/sdk/src/api/index.ts rename {apps/web => packages/sdk}/src/api/service/announcements.ts (79%) rename {apps/web => packages/sdk}/src/api/service/challenges.ts (91%) rename {apps/web => packages/sdk}/src/api/service/discussions.ts (96%) rename {apps/web => packages/sdk}/src/api/service/events.ts (87%) create mode 100644 packages/sdk/src/api/service/index.ts rename {apps/web => packages/sdk}/src/api/service/instances.ts (58%) rename {apps/web => packages/sdk}/src/api/service/solves.ts (88%) rename {apps/web => packages/sdk}/src/api/service/submit.ts (94%) rename {apps/web => packages/sdk}/src/api/service/uploads.ts (92%) rename {apps/web => packages/sdk}/src/api/service/users.ts (93%) rename {apps/web => packages/sdk}/src/api/service/weapons.ts (79%) create mode 100644 packages/sdk/src/client.ts rename {apps/web => packages/sdk}/src/entity/announcements.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_event_gameboxes.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_event_networks.ts (97%) rename {apps/web => packages/sdk}/src/entity/awd_events.ts (99%) rename {apps/web => packages/sdk}/src/entity/awd_flag_issues.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_flag_submissions.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_internal_token_rotations.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_judge_batches.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_judge_tasks.ts (90%) rename {apps/web => packages/sdk}/src/entity/awd_network_allocations.ts (95%) rename {apps/web => packages/sdk}/src/entity/awd_network_settings.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_orphan_resources.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_precheck_runs.ts (84%) rename {apps/web => packages/sdk}/src/entity/awd_reset_records.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_rounds.ts (97%) rename {apps/web => packages/sdk}/src/entity/awd_runtime_resources.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_score_events.ts (85%) rename {apps/web => packages/sdk}/src/entity/awd_team_bans.ts (84%) rename {apps/web => packages/sdk}/src/entity/awd_team_networks.ts (100%) rename {apps/web => packages/sdk}/src/entity/awd_wireguard_peers.ts (85%) rename {apps/web => packages/sdk}/src/entity/awdp_breaks.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_evaluation_proofs.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_evaluations.ts (95%) rename {apps/web => packages/sdk}/src/entity/awdp_event_gameboxes.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_event_networks.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_events.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_fix_rounds.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_instances.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_patch_submissions.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_run_writeups.ts (100%) rename {apps/web => packages/sdk}/src/entity/awdp_runs.ts (90%) rename {apps/web => packages/sdk}/src/entity/awdp_score_events.ts (100%) rename {apps/web => packages/sdk}/src/entity/challenge_set_items.ts (100%) rename {apps/web => packages/sdk}/src/entity/challenge_sets.ts (100%) rename {apps/web => packages/sdk}/src/entity/challenge_writeup.ts (100%) rename {apps/web => packages/sdk}/src/entity/challenges.ts (100%) rename {apps/web => packages/sdk}/src/entity/discussion_comments.ts (100%) rename {apps/web => packages/sdk}/src/entity/discussion_likes.ts (100%) rename {apps/web => packages/sdk}/src/entity/discussions.ts (100%) rename {apps/web => packages/sdk}/src/entity/event_announcements.ts (100%) rename {apps/web => packages/sdk}/src/entity/event_challenge_instance.ts (100%) rename {apps/web => packages/sdk}/src/entity/event_gamebox_instances.ts (83%) rename {apps/web => packages/sdk}/src/entity/event_instances.ts (100%) rename {apps/web => packages/sdk}/src/entity/event_logs.ts (92%) rename {apps/web => packages/sdk}/src/entity/event_team_members.ts (97%) rename {apps/web => packages/sdk}/src/entity/event_teams.ts (100%) rename {apps/web => packages/sdk}/src/entity/event_users.ts (100%) rename {apps/web => packages/sdk}/src/entity/event_writeup.ts (100%) rename {apps/web => packages/sdk}/src/entity/events.ts (93%) rename {apps/web => packages/sdk}/src/entity/gameboxes.ts (100%) create mode 100644 packages/sdk/src/entity/index.ts rename {apps/web => packages/sdk}/src/entity/instance_endpoints.ts (100%) rename {apps/web => packages/sdk}/src/entity/jeopardy_challenge_solves.ts (100%) rename {apps/web => packages/sdk}/src/entity/jeopardy_event_challenges.ts (100%) rename {apps/web => packages/sdk}/src/entity/logs.ts (100%) rename {apps/web => packages/sdk}/src/entity/scheduled_tasks.ts (100%) rename {apps/web => packages/sdk}/src/entity/sea_orm_active_enums.ts (100%) rename {apps/web => packages/sdk}/src/entity/settings.ts (71%) rename {apps/web => packages/sdk}/src/entity/super_admin.ts (100%) rename {apps/web => packages/sdk}/src/entity/users.ts (100%) rename {apps/web => packages/sdk}/src/entity/weapons.ts (100%) create mode 100644 packages/sdk/src/errors.ts create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/protocol.ts rename {apps/web/src/lib => packages/sdk/src}/sse/__tests__/connectSse.test.ts (100%) rename {apps/web/src/lib => packages/sdk/src}/sse/__tests__/parser.test.ts (100%) rename {apps/web/src/lib => packages/sdk/src}/sse/connectSse.ts (99%) create mode 100644 packages/sdk/src/sse/index.ts rename {apps/web/src/lib => packages/sdk/src}/sse/parser.ts (100%) create mode 100644 packages/sdk/src/transport.ts create mode 100644 packages/sdk/src/types/adminChallenge.ts create mode 100644 packages/sdk/src/types/adminEventChallenge.ts create mode 100644 packages/sdk/src/types/adminEventTeam.ts create mode 100644 packages/sdk/src/types/adminEventUser.ts rename {apps/web => packages/sdk}/src/types/challengeDto.ts (95%) create mode 100644 packages/sdk/src/types/challengeWriteup.ts create mode 100644 packages/sdk/src/types/dataPresent.ts create mode 100644 packages/sdk/src/types/database.ts create mode 100644 packages/sdk/src/types/eventChallenge.ts create mode 100644 packages/sdk/src/types/eventInfo.ts create mode 100644 packages/sdk/src/types/eventInstance.ts create mode 100644 packages/sdk/src/types/index.ts create mode 100644 packages/sdk/src/types/instanceDto.ts create mode 100644 packages/sdk/src/types/scoreboard.ts create mode 100644 packages/sdk/src/types/systemInformation.ts create mode 100644 packages/sdk/src/types/top.ts create mode 100644 packages/sdk/src/types/trend.ts create mode 100644 packages/sdk/tsconfig.build.json create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/vitest.config.ts create mode 100755 scripts/check-architecture.sh diff --git a/apps/api/src/core/contract.rs b/apps/api/src/core/contract.rs new file mode 100644 index 00000000..1d67c839 --- /dev/null +++ b/apps/api/src/core/contract.rs @@ -0,0 +1,62 @@ +//! 对外契约版本(前端平台)。 +//! +//! 三个版本是**互相独立**的概念,禁止合并成一个变量(详见 +//! `docs/frontend/ARCHITECTURE.md`「版本策略」): +//! +//! | 常量 | 含义 | 何时 +1 | +//! |---|---|---| +//! | [`API_CONTRACT_VERSION`] | 前端可见的 HTTP API 契约 major | 破坏性 API 变更(删字段 / 改语义 / 改必填) | +//! | [`FRONTEND_RUNTIME_CONTRACT_VERSION`] | 前端制品与 `mount(context)` 契约 major | 破坏 `frontend.json` 或 runtime 接口 | +//! | 平台版本(`CARGO_PKG_VERSION`) | FloatCTF 平台自身版本 | 按仓库既有发版策略 | +//! +//! **加法式变更不升 major**:新增可选字段、新增端点、新增响应字段都留在当前 +//! contract major 内。只有"旧前端会因此坏掉"的变更才升 major,并且必须同步 +//! `packages/frontend-runtime/src/version.ts`(`scripts/check-architecture.sh` 会 +//! 断言两边一致,防止漂移)。 + +/// 前端可见的 HTTP API 契约 major(`GET /api/frontend` 的 `api_contract_version`)。 +pub const API_CONTRACT_VERSION: &str = "1"; + +/// 前端运行时契约 major(`GET /api/frontend` 的 `frontend_runtime_version`)。 +pub const FRONTEND_RUNTIME_CONTRACT_VERSION: &str = "1"; + +/// 平台真实且稳定的能力标记白名单。 +/// +/// `GET /api/frontend` 只允许返回这里的元素:能力列表是**公开**契约, +/// 不能随手把内部特性写进去(既有泄露风险,也会让前端探测到不存在的功能)。 +pub const PLATFORM_CAPABILITIES: &[&str] = &[ + "jeopardy", + "awd", + "awdp", + "discussions", + "writeups", + "web_terminal", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_majors_are_positive_integers() { + for value in [API_CONTRACT_VERSION, FRONTEND_RUNTIME_CONTRACT_VERSION] { + assert!( + value.parse::().is_ok(), + "contract major must be an integer string, got {value:?}" + ); + } + } + + #[test] + fn capabilities_are_lowercase_identifiers() { + for capability in PLATFORM_CAPABILITIES { + assert!(!capability.is_empty()); + assert!( + capability + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'), + "capability must be a lowercase identifier: {capability:?}" + ); + } + } +} diff --git a/apps/api/src/core/mod.rs b/apps/api/src/core/mod.rs index 654d2bda..3cd18da6 100644 --- a/apps/api/src/core/mod.rs +++ b/apps/api/src/core/mod.rs @@ -1,11 +1,15 @@ //! 跨模块核心原语:配置、密钥、鉴权、系统固定主键等。 pub mod config; +pub mod contract; pub mod secret; pub mod security; pub mod system_ids; pub use config::AppConfig; +pub use contract::{ + API_CONTRACT_VERSION, FRONTEND_RUNTIME_CONTRACT_VERSION, PLATFORM_CAPABILITIES, +}; pub use secret::Secret; pub use system_ids::{ EVENT_PRACTICE_JEOPARDY, EVENT_PRACTICE_JEOPARDY_SYSTEM_KEY, SCHED_CHECK_PRACTICE_EVENT, diff --git a/apps/web/.env.production b/apps/web/.env.production index a681dc85..842d5257 100644 --- a/apps/web/.env.production +++ b/apps/web/.env.production @@ -1 +1 @@ -VITE_SHOW_DEVTOOLS=false \ No newline at end of file +# Bootstrap 没有任何环境变量配置:一切引导信息来自 GET /api/frontend 与本地注册表。 diff --git a/apps/web/README.md b/apps/web/README.md index cdcf4ed7..10f94aec 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,7 +1,18 @@ -# @floatctf/web +# @floatctf/web — FloatCTF web bootstrap +This package is **not** a UI. It is the tiny host page that: +1. reads the browser-local break-glass override (`?frontend=`), +2. calls `bootstrapFrontend()` from `@floatctf/frontend-runtime`, +3. which fetches `GET /api/frontend` + the local frontend registry, validates + compatibility, injects the frontend's styles and dynamically imports its ESM entry, +4. falls back to the Default Frontend, and finally renders a built-in emergency page. +The real UI lives in [`frontends/default`](../../frontends/default) and is installed +under `$FLOATCTF_HOME/frontends/`. + +Deliberately React-free, Primer-free and stable. See +[`docs/frontend/ARCHITECTURE.md`](../../docs/frontend/ARCHITECTURE.md). ## License 本项目以 [GNU AGPLv3](LICENSE) 协议发布。 diff --git a/apps/web/biome.json b/apps/web/biome.json index c60300f1..2fe5176b 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -1,14 +1,9 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "vcs": { - "enabled": false, - "clientKind": "git", - "useIgnoreFile": false - }, "files": { "ignoreUnknown": false, - "ignore": ["src/routeTree.gen.ts"], - "include": ["src/*", ".vscode/*", "index.html", "vite.config.js"] + "ignore": ["dist"], + "include": ["src/*", "index.html", "vite.config.ts"] }, "formatter": { "enabled": true, @@ -20,27 +15,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, - "style": { - "noNonNullAssertion": "off", - "noUnusedTemplateLiteral": "off", - "useImportType": "off", - "useTemplate": "off" - }, - "a11y": { - "useKeyWithClickEvents": "off", - "useSemanticElements": "off", - "useButtonType": "off" - }, - "complexity": { - "noForEach": "off" - }, - "correctness": { - "useExhaustiveDependencies": "off" - }, - "suspicious": { - "noExplicitAny": "off" - } + "recommended": true } }, "javascript": { diff --git a/apps/web/index.html b/apps/web/index.html index 898c5b48..955657b7 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -12,6 +12,7 @@
- + + diff --git a/apps/web/package.json b/apps/web/package.json index f2fbfd58..04b63876 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,67 +1,28 @@ { - "name": "@floatctf/web", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --port 13000", - "start": "vite --port 13000", - "build": "vite build && tsc", - "serve": "vite preview", - "test": "vitest run --passWithNoTests", - "format": "biome format", - "lint": "biome lint", - "check": "biome check" - }, - "dependencies": { - "@emotion/is-prop-valid": "1.2.2", - "@faker-js/faker": "^9.6.0", - "@primer/css": "^22.0.2", - "@primer/octicons-react": "^19.15.5", - "@primer/primitives": "^11.1.0", - "@primer/react": "^37.31.0", - "@tailwindcss/vite": "^4.0.6", - "@tanstack/match-sorter-utils": "^8.19.4", - "@tanstack/react-devtools": "^0.2.2", - "@tanstack/react-query": "^5.66.5", - "@tanstack/react-query-devtools": "^5.84.2", - "@tanstack/react-router": "^1.130.2", - "@tanstack/react-router-devtools": "^1.131.5", - "@tanstack/react-table": "^8.21.2", - "@tanstack/router-plugin": "^1.121.2", - "@types/node": "^24.5.0", - "@uiw/react-md-editor": "^4.0.8", - "@xterm/addon-fit": "^0.10.0", - "@xterm/xterm": "^5.5.0", - "ahooks": "^3.9.5", - "axios": "^1.11.0", - "dayjs": "^1.11.13", - "dayjs-plugin-utc": "^0.1.2", - "mermaid": "^11.12.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-image-crop": "^11.0.8", - "recharts": "^3.2.0", - "rehype-rewrite": "^4.0.2", - "styled-components": "^6.1.19", - "ts-pattern": "^5.8.0", - "zod": "^4.1.13", - "zustand": "^5.0.8" - }, - "devDependencies": { - "@biomejs/biome": "1.9.4", - "@testing-library/dom": "^10.4.0", - "@testing-library/react": "^16.2.0", - "@types/react": "^19.0.8", - "@types/react-dom": "^19.0.3", - "@vitejs/plugin-react": "^4.3.4", - "autoprefixer": "^10.4.21", - "jsdom": "^26.0.0", - "postcss": "^8.5.6", - "tailwindcss": "^4.0.6", - "typescript": "^5.7.2", - "vite": "^6.3.5", - "vitest": "^3.0.5", - "web-vitals": "^4.2.4" - } + "name": "@floatctf/web", + "version": "1.0.0", + "private": true, + "description": "FloatCTF web bootstrap: resolves the installed frontend and mounts it. Intentionally boring and React-free.", + "type": "module", + "license": "AGPL-3.0-only", + "scripts": { + "build": "vite build && tsc --noEmit", + "build:bootstrap": "vite build", + "preview": "vite preview", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit", + "lint": "biome lint", + "format": "biome format", + "check": "biome check" + }, + "dependencies": { + "@floatctf/frontend-runtime": "workspace:*" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.2", + "vite": "^6.3.5", + "vitest": "^3.0.5" + } } diff --git a/apps/web/src/api/__tests__/axiosInterceptors.test.ts b/apps/web/src/api/__tests__/axiosInterceptors.test.ts deleted file mode 100644 index ada6e96b..00000000 --- a/apps/web/src/api/__tests__/axiosInterceptors.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - navigate: vi.fn(), - removeAdminToken: vi.fn(), - removeToken: vi.fn(), -})); - -// 隔离 @/main(路由实例)与登录态 store,只测响应拦截器本身。 -vi.mock("@/main", () => ({ router: { navigate: mocks.navigate } })); -vi.mock("@/stores/AuthStore", () => ({ - useAuthStore: { - getState: () => ({ - adminToken: "admin-token", - token: "user-token", - removeAdminToken: mocks.removeAdminToken, - removeToken: mocks.removeToken, - }), - }, -})); - -import { admin_api, service_api } from "@/api/axios"; - -type RejectedHandler = (error: unknown) => Promise; - -function rejectedHandler(instance: typeof admin_api): RejectedHandler { - const handlers = ( - instance.interceptors.response as unknown as { - handlers: { rejected?: RejectedHandler }[]; - } - ).handlers; - const handler = handlers.find((h) => h.rejected)?.rejected; - if (!handler) { - throw new Error("响应拦截器未注册 rejected handler"); - } - return handler; -} - -describe("axios 响应拦截器:无 response 的网络错误", () => { - beforeEach(() => { - vi.clearAllMocks(); - // 拦截器内部会 console.log(error),测试里静音。 - vi.spyOn(console, "log").mockImplementation(() => {}); - }); - - it("admin_api 原样透传网络错误,不清理管理员登录态", async () => { - const networkError = Object.assign(new Error("Network Error"), { - code: "ERR_NETWORK", - }); - - await expect(rejectedHandler(admin_api)(networkError)).rejects.toBe( - networkError, - ); - expect(mocks.removeAdminToken).not.toHaveBeenCalled(); - expect(mocks.navigate).not.toHaveBeenCalled(); - }); - - it("service_api 原样透传超时错误,不清理用户登录态", async () => { - const timeoutError = Object.assign( - new Error("timeout of 10000ms exceeded"), - { - code: "ECONNABORTED", - }, - ); - - await expect(rejectedHandler(service_api)(timeoutError)).rejects.toBe( - timeoutError, - ); - expect(mocks.removeToken).not.toHaveBeenCalled(); - expect(mocks.navigate).not.toHaveBeenCalled(); - }); - - it("admin_api 401 仍然清理管理员登录态并回到 /admin", async () => { - const unauthorized = { response: { status: 401 } }; - - await expect(rejectedHandler(admin_api)(unauthorized)).rejects.toBe( - unauthorized, - ); - expect(mocks.removeAdminToken).toHaveBeenCalledTimes(1); - expect(mocks.navigate).toHaveBeenCalledWith({ to: "/admin" }); - }); - - it("service_api 401 仍然清理用户登录态并回到 /", async () => { - const unauthorized = { response: { status: 401 } }; - - await expect(rejectedHandler(service_api)(unauthorized)).rejects.toBe( - unauthorized, - ); - expect(mocks.removeToken).toHaveBeenCalledTimes(1); - expect(mocks.navigate).toHaveBeenCalledWith({ to: "/" }); - }); - - it("500 等带响应的错误不影响登录态", async () => { - const serverError = { response: { status: 500 } }; - - await expect(rejectedHandler(admin_api)(serverError)).rejects.toBe( - serverError, - ); - expect(mocks.removeAdminToken).not.toHaveBeenCalled(); - expect(mocks.navigate).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/api/admin/index.ts b/apps/web/src/api/admin/index.ts deleted file mode 100644 index b48176ab..00000000 --- a/apps/web/src/api/admin/index.ts +++ /dev/null @@ -1,30 +0,0 @@ -export { adminLoginFn } from "./auth"; -export { systemAdminApi } from "./system"; -export { settingAdminApi } from "./settings"; -export { announcementAdminApi } from "./announcements"; -export { challengeAdminApi } from "./challenges"; -export { userAdminApi } from "./users"; -export { eventAdminApi } from "./events"; -export { instanceAdminApi } from "./instances"; -export { eventChallengeAdminApi } from "./event_challenges"; -export { eventUserAdminApi } from "./event_users"; -export { eventAnnouncementAdminApi } from "./event_announcements"; -export { eventWriteupAdminApi } from "./event_writeups"; -export { eventTeamAdminApi } from "./event_teams"; -export { eventLogAdminApi } from "./event_logs"; -export { databaseAdminApi } from "./database"; -export { scheduledTaskAdminApi } from "./scheduled_tasks"; -export { weaponsAdminApi } from "./weapons"; -export { logsAdminApi } from "./logs"; -export { downloadAdminApi } from "./download"; -export { discussionAdminApi } from "./discussions"; -export { dashboardAdminApi } from "./dashboard"; -export { superAdminApi } from "./super_admin"; -export { - dockerAdminApi, - type FloatDockerContainer, - type ContainerInfo, - type PortInfo, - type ImageInfo, - type NetworkInfo, -} from "./docker"; diff --git a/apps/web/src/api/axios.ts b/apps/web/src/api/axios.ts deleted file mode 100644 index 5193b115..00000000 --- a/apps/web/src/api/axios.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ADMIN_API_URL, API_URL } from "@/config"; -import { router } from "@/main"; -import { useAuthStore } from "@/stores/AuthStore"; - -import axios from "axios"; -export const service_api = axios.create({ - baseURL: API_URL, -}); - -export const admin_api = axios.create({ - baseURL: ADMIN_API_URL, -}); - -admin_api.interceptors.request.use((config) => { - const token = useAuthStore.getState().adminToken; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); -admin_api.interceptors.response.use( - (response) => { - // console.log(response); - return response; - }, - - (error) => { - // 网络层失败(超时 / 连接中断)没有 response:必须用可选链, - // 否则真正的错误会被 TypeError 覆盖成 "reading 'status'"。 - if (error.response?.status === 401) { - useAuthStore.getState().removeAdminToken(); - router.navigate({ to: "/admin" }); - } - console.log(error); - return Promise.reject(error); - }, -); -service_api.interceptors.request.use((config) => { - const token = useAuthStore.getState().token; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); -service_api.interceptors.response.use( - (response) => { - // console.log(response); - return response; - }, - (error) => { - // 同上:没有 response 的网络错误不得读 response.status。 - if (error.response?.status === 401) { - useAuthStore.getState().removeToken(); - router.navigate({ to: "/" }); - } - console.log(error); - return Promise.reject(error); - }, -); - -export type QueryParams = { - offset?: number; - limit?: number; - page?: number; - total?: number; - filter?: string; -}; - -export type UniResponse = { - code: number; - message: string; - data?: T; - meta?: QueryParams; -}; diff --git a/apps/web/src/api/index.ts b/apps/web/src/api/index.ts deleted file mode 100644 index 646af353..00000000 --- a/apps/web/src/api/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -export * from "@/api/axios"; -import { - type FloatDockerContainer, - type ImageInfo, - type NetworkInfo, - adminLoginFn, - announcementAdminApi, - challengeAdminApi, - dashboardAdminApi, - databaseAdminApi, - discussionAdminApi, - dockerAdminApi, - downloadAdminApi, - eventAdminApi, - eventAnnouncementAdminApi, - eventChallengeAdminApi, - eventLogAdminApi, - eventTeamAdminApi, - eventUserAdminApi, - eventWriteupAdminApi, - instanceAdminApi, - logsAdminApi, - scheduledTaskAdminApi, - settingAdminApi, - superAdminApi, - systemAdminApi, - userAdminApi, - weaponsAdminApi, -} from "@/api/admin"; - -import { - announcementServiceApi, - challengeServiceApi, - discussionServiceApi, - eventServiceApi, - instanceServiceApi, - solveServiceApi, - submitServiceApi, - uploadsServiceApi, - userServiceApi, - weaponsServiceApi, -} from "@/api/service"; -import { awdAdminApi, awdPlayerApi } from "./awd"; - -export type { FloatDockerContainer, ImageInfo, NetworkInfo }; -export { awdAdminApi, awdPlayerApi }; - -export const adminApi = { - login: adminLoginFn, - system: systemAdminApi, - settings: settingAdminApi, - announcements: announcementAdminApi, - challenges: challengeAdminApi, - discussions: discussionAdminApi, - users: userAdminApi, - events: eventAdminApi, - instances: instanceAdminApi, - event_challenges: eventChallengeAdminApi, - event_users: eventUserAdminApi, - event_announcements: eventAnnouncementAdminApi, - event_logs: eventLogAdminApi, - event_writeups: eventWriteupAdminApi, - event_teams: eventTeamAdminApi, - database: databaseAdminApi, - scheduled_tasks: scheduledTaskAdminApi, - weapons: weaponsAdminApi, - logs: logsAdminApi, - download: downloadAdminApi, - docker: dockerAdminApi, - dashboard: dashboardAdminApi, - super_admin: superAdminApi, - awd: awdAdminApi, -}; - -export const serviceApi = { - users: userServiceApi, - events: eventServiceApi, - challenges: challengeServiceApi, - instances: instanceServiceApi, - submit: submitServiceApi, - solves: solveServiceApi, - weapons: weaponsServiceApi, - announcements: announcementServiceApi, - discussions: discussionServiceApi, - uploads: uploadsServiceApi, - awd: awdPlayerApi, -}; diff --git a/apps/web/src/api/queries/__tests__/query-keys.test.ts b/apps/web/src/api/queries/__tests__/query-keys.test.ts deleted file mode 100644 index ce61c401..00000000 --- a/apps/web/src/api/queries/__tests__/query-keys.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { - challengeInstanceQueryOptions, - challengeQueryOptions, - eventInfoQueryOptions, - systemInformationQueryOptions, -} from "@/api/queries"; -// @vitest-environment jsdom -// query options 工厂必须保持与原先 useQuery 完全相同的 query key, -// 因为既有代码按 key 做 invalidate/refetch -// (例如 useAwdEventStream.invalidateQueries)。 -import { describe, expect, it } from "vitest"; - -describe("query options factory keys", () => { - it("eventInfo keeps key ['eventInfo', id]", () => { - expect(eventInfoQueryOptions("abc-123").queryKey).toEqual([ - "eventInfo", - "abc-123", - ]); - }); - - it("challenge keeps key ['challenge', id]", () => { - expect(challengeQueryOptions("abc-123").queryKey).toEqual([ - "challenge", - "abc-123", - ]); - }); - - it("instance keeps key ['instance', id]", () => { - expect(challengeInstanceQueryOptions("abc-123").queryKey).toEqual([ - "instance", - "abc-123", - ]); - }); - - it("system_information keeps key ['system_information']", () => { - expect(systemInformationQueryOptions().queryKey).toEqual([ - "system_information", - ]); - }); -}); diff --git a/apps/web/src/api/queries/index.ts b/apps/web/src/api/queries/index.ts deleted file mode 100644 index 6ddec316..00000000 --- a/apps/web/src/api/queries/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// 选手端 query options -export { eventInfoQueryOptions } from "./service/events"; -export { - challengeQueryOptions, - challengeInstanceQueryOptions, -} from "./service/challenges"; - -// 管理端 query options -export { systemInformationQueryOptions } from "./admin/system"; diff --git a/apps/web/src/api/service/index.ts b/apps/web/src/api/service/index.ts deleted file mode 100644 index 2c8e4935..00000000 --- a/apps/web/src/api/service/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { userServiceApi } from "./users"; -export { eventServiceApi } from "./events"; -export { challengeServiceApi } from "./challenges"; -export { instanceServiceApi } from "./instances"; -export { submitServiceApi } from "./submit"; -export { solveServiceApi } from "./solves"; -export { weaponsServiceApi } from "./weapons"; -export { announcementServiceApi } from "./announcements"; -export { uploadsServiceApi } from "./uploads"; -export { discussionServiceApi } from "./discussions"; diff --git a/apps/web/src/entity/index.ts b/apps/web/src/entity/index.ts deleted file mode 100644 index 44f5bb58..00000000 --- a/apps/web/src/entity/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -export * from "./sea_orm_active_enums"; -export * from "./announcements"; -export * from "./awd_event_gameboxes"; -export * from "./awd_event_networks"; -export * from "./awd_events"; -export * from "./awd_flag_issues"; -export * from "./awd_flag_submissions"; -export * from "./awd_internal_token_rotations"; -export * from "./awd_judge_batches"; -export * from "./awd_judge_tasks"; -export * from "./awd_network_allocations"; -export * from "./awd_network_settings"; -export * from "./awd_orphan_resources"; -export * from "./awd_precheck_runs"; -export * from "./awd_reset_records"; -export * from "./awd_rounds"; -export * from "./awd_runtime_resources"; -export * from "./awd_score_events"; -export * from "./awd_team_bans"; -export * from "./awd_team_networks"; -export * from "./awd_wireguard_peers"; -export * from "./awdp_breaks"; -export * from "./awdp_evaluation_proofs"; -export * from "./awdp_evaluations"; -export * from "./awdp_event_gameboxes"; -export * from "./awdp_event_networks"; -export * from "./awdp_events"; -export * from "./awdp_fix_rounds"; -export * from "./awdp_instances"; -export * from "./awdp_patch_submissions"; -export * from "./awdp_run_writeups"; -export * from "./awdp_runs"; -export * from "./awdp_score_events"; -export * from "./challenge_set_items"; -export * from "./challenge_sets"; -export * from "./challenge_writeup"; -export * from "./challenges"; -export * from "./discussion_comments"; -export * from "./discussion_likes"; -export * from "./discussions"; -export * from "./event_announcements"; -export * from "./event_challenge_instance"; -export * from "./event_gamebox_instances"; -export * from "./event_instances"; -export * from "./event_logs"; -export * from "./event_team_members"; -export * from "./event_teams"; -export * from "./event_users"; -export * from "./event_writeup"; -export * from "./events"; -export * from "./gameboxes"; -export * from "./instance_endpoints"; -export * from "./jeopardy_challenge_solves"; -export * from "./jeopardy_event_challenges"; -export * from "./logs"; -export * from "./scheduled_tasks"; -export * from "./settings"; -export * from "./super_admin"; -export * from "./users"; -export * from "./weapons"; diff --git a/apps/web/src/lib/sse/index.ts b/apps/web/src/lib/sse/index.ts deleted file mode 100644 index ab6f5172..00000000 --- a/apps/web/src/lib/sse/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { createSseParser } from "./parser"; -export type { SseEvent, SseParser } from "./parser"; -export { connectSse } from "./connectSse"; -export type { - ConnectSseOptions, - SseConnection, - SseConnectionState, - SseConnectionStatus, -} from "./connectSse"; diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 00000000..f2eb6609 --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,82 @@ +/** + * FloatCTF web bootstrap —— **不是 UI**。 + * + * 它只做"把已安装的前端加载起来"这件事,全部机制在 + * `@floatctf/frontend-runtime` 的 `bootstrapFrontend()` 里: + * + * ``` + * GET /api/frontend → 平台版本 / API 契约 / FRONTEND_ACTIVE / capabilities + * GET /__floatctf/frontends/registry.json → 本地已安装前端 + * → 客户端校验兼容性(runtime / API 契约 major) + * → 注入该前端的样式 + * → 动态 import 同源 ESM(/__floatctf/frontends///...) + * → frontend.mount(context) + * → 失败回退 default → 再失败渲染内置兜底页 + * ``` + * + * 刻意保持无框架、无 Primer、无路由、无状态库:bootstrap 越"无聊",它就越不会 + * 成为前端演进(甚至换成非 React 前端)的瓶颈。 + * + * 破窗恢复:`?frontend=default`。该参数**只**影响当前这次页面加载, + * 且只接受本地注册表里**已安装**的安全前端 ID —— 不写后端设置、不需要登录。 + */ + +import { + bootstrapFrontend, + renderBootstrapEmergencyUi, + type BootstrapDiagnostics, +} from "@floatctf/frontend-runtime"; + +const ROOT_ID = "app"; + +function readOverride(): string | null { + try { + return new URLSearchParams(window.location.search).get("frontend"); + } catch { + return null; + } +} + +function mount(): void { + const root = document.getElementById(ROOT_ID); + if (!root) { + // index.html 缺 #app 说明构建产物被篡改/损坏:直接给兜底页而不是白屏。 + renderBootstrapEmergencyUi(document.body, { + attempted: [], + errors: [`bootstrap: #${ROOT_ID} element is missing from index.html`], + }); + return; + } + // 清掉可能存在的构建期 loading 占位,保证前端挂到一个干净宿主上。 + root.textContent = ""; + + const override = readOverride(); + + void bootstrapFrontend({ + root, + overrideFrontendId: override, + // 兜底页接管整页(它会先清空宿主)。 + emergencyHost: document.body, + }) + .then((result) => { + if (!result.mounted) { + // bootstrapFrontend 已经渲染了兜底页;这里只在控制台留下诊断, + // 不额外暴露任何信息到页面上。 + console.error("[floatctf] frontend bootstrap failed", result.diagnostics); + } + }) + .catch((error: unknown) => { + // bootstrapFrontend 契约上不抛错;这一层只是防止"兜底页本身都失败"。 + const diagnostics: BootstrapDiagnostics = { + attempted: [], + errors: [error instanceof Error ? error.message : String(error)], + }; + renderBootstrapEmergencyUi(document.body, diagnostics); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mount, { once: true }); +} else { + mount(); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx deleted file mode 100644 index b5ae5b6b..00000000 --- a/apps/web/src/main.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { BaseStyles, ThemeProvider } from "@primer/react"; -import { RouterProvider, createRouter } from "@tanstack/react-router"; -import { StrictMode } from "react"; -import isPropValid from "@emotion/is-prop-valid"; -import { StyleSheetManager } from "styled-components"; -import ReactDOM from "react-dom/client"; -import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx"; -import { RouteLoading } from "./components/RouteLoading.tsx"; -import reportWebVitals from "./reportWebVitals.ts"; -// 导入生成的路由树 -import { routeTree } from "./routeTree.gen"; -import "./style.css"; -// 创建路由实例 - -const TanStackQueryProviderContext = TanStackQueryProvider.getContext(); -export const router = createRouter({ - routeTree, - context: { - ...TanStackQueryProviderContext, - }, - defaultPreload: "intent", - scrollRestoration: true, - defaultStructuralSharing: true, - defaultPreloadStaleTime: 0, - // 懒加载 chunk / loader 期间在内容区显示加载态,替代默认白屏。 - defaultPendingComponent: RouteLoading, - defaultPendingMs: 100, // 100ms 内完成的路由切换不闪烁加载态 - defaultPendingMinMs: 300, // 加载态至少展示 300ms,避免快速加载时闪一下 - // 路由加载失败 / 404 时给出提示,避免整页白屏。 - defaultErrorComponent: ({ error }: { error: Error }) => ( -
-

页面加载失败:{String(error)}

-
- ), - defaultNotFoundComponent: () => ( -
-

404 · 页面不存在

-
- ), -}); - -// 注册路由实例以获得类型安全 -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} - -// 渲染应用 -const rootElement = document.getElementById("app"); -if (rootElement && !rootElement.innerHTML) { - const root = ReactDOM.createRoot(rootElement); - root.render( - - - typeof target !== "string" || isPropValid(propName) - } - > - - - - - - - - - - ); -} - -// 若要测量应用性能,可传入函数 -// 记录结果(例如:reportWebVitals(console.log)) -// 或发送到分析端点。详见 https://bit.ly/CRA-vitals -reportWebVitals(); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 17f98399..88bd1a38 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,29 +1,19 @@ { - "include": ["**/*.ts", "**/*.tsx"], + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"], "compilerOptions": { - "target": "ES2022", - "jsx": "react-jsx", "module": "ESNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"], - - /* Bundler mode */ "moduleResolution": "bundler", - "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "noEmit": true, - - /* Linting */ "skipLibCheck": true, "strict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, + "noUnusedLocals": true, + "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"], - } + "noUncheckedSideEffectImports": true } } diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 32443c52..995c6e80 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,45 +1,25 @@ -import { defineConfig } from "vite"; -import viteReact from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; -import pkg from "./package.json"; -import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; -import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; -// https://vitejs.dev/config/ +/** + * Bootstrap 的构建配置**刻意保持最小**: + * 没有 React 插件、没有 Tailwind、没有路由插件、没有代码分割魔法。 + * 产物只有 `index.html` + 一个入口 JS —— 任何"bootstrap 悄悄带上默认前端" + * 的回归都会在体积和 `scripts/check-architecture.sh` 的产物断言上暴露。 + */ export default defineConfig({ - define: { - "import.meta.env.VITE_APP_VERSION": JSON.stringify(pkg.version), - }, - plugins: [ - // Vitest 下关闭路由代码分割:分割后的页面是懒加载组件, - // 单测里渲染路由页面会一直停在 Suspense fallback。开发/构建仍开启。 - TanStackRouterVite({ autoCodeSplitting: !process.env.VITEST }), - viteReact(), - tailwindcss(), - ], - - resolve: { - alias: { - "@": resolve(__dirname, "./src"), - }, - }, - server: { - // host: true 让 dev server 监听 0.0.0.0, - // 这样 Caddy 容器可通过 host-gateway (172.17.0.1:13000) 反向代理。 - host: true, - watch: { - ignored: ["**/routeTree.gen.ts"], // ← 加这 3 行 - }, - // 启动时预编译常用页面,避免开发时首次点击标签页要等编译。 - warmup: { - clientFiles: [ - "./src/routes/service/index.tsx", - "./src/routes/service/top.tsx", - "./src/routes/service/challenges/index.tsx", - "./src/routes/admin/index.tsx", - "./src/routes/admin/dashboard.tsx", - "./src/routes/admin/challenges.tsx", - ], - }, - }, + build: { + outDir: "dist", + emptyOutDir: true, + target: "esnext", + rollupOptions: { + output: { + entryFileNames: "assets/bootstrap-[hash].js", + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: "assets/[name]-[hash][extname]", + }, + }, + }, + test: { + environment: "node", + }, }); diff --git a/frontends/default/biome.json b/frontends/default/biome.json new file mode 100644 index 00000000..aea22a9d --- /dev/null +++ b/frontends/default/biome.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false, + "ignore": ["src/routeTree.gen.ts", "dist"], + "include": ["src/*", ".vscode/*", "index.html", "vite.config.js"] + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off", + "noUnusedTemplateLiteral": "off", + "useImportType": "off", + "useTemplate": "off" + }, + "a11y": { + "useKeyWithClickEvents": "off", + "useSemanticElements": "off", + "useButtonType": "off" + }, + "complexity": { + "noForEach": "off" + }, + "correctness": { + "useExhaustiveDependencies": "off" + }, + "suspicious": { + "noExplicitAny": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + } +} diff --git a/frontends/default/index.html b/frontends/default/index.html new file mode 100644 index 00000000..b3f201a1 --- /dev/null +++ b/frontends/default/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + FloatCTF + + +
+ + + + diff --git a/frontends/default/package.json b/frontends/default/package.json new file mode 100644 index 00000000..451c4209 --- /dev/null +++ b/frontends/default/package.json @@ -0,0 +1,74 @@ +{ + "name": "@floatctf/frontend-default", + "version": "1.0.0", + "description": "The official FloatCTF frontend: the complete current UI (React + TanStack Router + Primer)", + "license": "AGPL-3.0-only", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 13000", + "start": "vite --port 13000", + "build": "vite build && tsc --noEmit", + "build:artifact": "vite build", + "serve": "vite preview", + "test": "vitest run --passWithNoTests", + "format": "biome format", + "lint": "biome lint", + "check": "biome check", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@floatctf/frontend-runtime": "workspace:*", + "@floatctf/react": "workspace:*", + "@floatctf/sdk": "workspace:*", + "@faker-js/faker": "^9.6.0", + "@primer/css": "^22.0.2", + "@primer/octicons-react": "^19.15.5", + "@primer/primitives": "^11.1.0", + "@primer/react": "^37.31.0", + "@tailwindcss/vite": "^4.0.6", + "@tanstack/match-sorter-utils": "^8.19.4", + "@tanstack/react-devtools": "^0.2.2", + "@tanstack/react-query": "^5.66.5", + "@tanstack/react-query-devtools": "^5.84.2", + "@tanstack/react-router": "^1.130.2", + "@tanstack/react-router-devtools": "^1.131.5", + "@tanstack/react-table": "^8.21.2", + "@tanstack/router-plugin": "^1.121.2", + "@types/node": "^24.5.0", + "@uiw/react-md-editor": "^4.0.8", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", + "ahooks": "^3.9.5", + "axios": "^1.11.0", + "dayjs": "^1.11.13", + "dayjs-plugin-utc": "^0.1.2", + "mermaid": "^11.12.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-image-crop": "^11.0.8", + "recharts": "^3.2.0", + "rehype-rewrite": "^4.0.2", + "styled-components": "^6.1.19", + "ts-pattern": "^5.8.0", + "zod": "^4.1.13", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.2.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.21", + "jsdom": "^26.0.0", + "postcss": "^8.5.6", + "tailwindcss": "^4.0.6", + "typescript": "^5.7.2", + "vite": "^6.3.5", + "vitest": "^3.0.5", + "web-vitals": "^4.2.4" + } +} diff --git a/frontends/default/src/api/client.ts b/frontends/default/src/api/client.ts new file mode 100644 index 00000000..4a2128dd --- /dev/null +++ b/frontends/default/src/api/client.ts @@ -0,0 +1,39 @@ +/** + * **Default Frontend 的 SDK 接线层**(前端拥有,SDK 不拥有)。 + * + * 这里做三件事——**全部是 UI 侧决策**,SDK 一概不参与: + * 1. token 从哪来:Zustand `AuthStore`(含既有 localStorage 兼容) + * 2. 401 之后干什么:清对应 token + 跳转到既有登录入口(选手 `/`、管理 `/admin`) + * 3. 暴露绑定好的客户端给页面使用 + * + * 迁移说明:这一段逻辑原先散在 `apps/web/src/api/axios.ts` 里(直接 import router + * 与 AuthStore)。现在传输机制在 `@floatctf/sdk`,**UI 反应留在这里**, + * 因此外部前端可以完全换成自己的写法(Context、内存、别的状态库)。 + */ + +import { createFloatCTFClient } from "@floatctf/sdk"; + +import { router } from "@/router"; +import { useAuthStore } from "@/stores/AuthStore"; +import { API_URL } from "@/config"; + +export const client = createFloatCTFClient({ + baseUrl: API_URL, + getUserToken: () => useAuthStore.getState().token, + getAdminToken: () => useAuthStore.getState().adminToken, + onUnauthorized: ({ scope, error }) => { + // 与迁移前逐字一致的行为:清 token → 回到该端的登录入口。 + if (scope === "admin") { + useAuthStore.getState().removeAdminToken(); + void router.navigate({ to: "/admin" }); + } else { + useAuthStore.getState().removeToken(); + void router.navigate({ to: "/" }); + } + console.log(error); + }, + onError: (error) => { + // 迁移前 axios 拦截器会 console.log(error);保持同样的可见性,不改变行为。 + console.log(error); + }, +}); diff --git a/frontends/default/src/api/index.ts b/frontends/default/src/api/index.ts new file mode 100644 index 00000000..05ea3a18 --- /dev/null +++ b/frontends/default/src/api/index.ts @@ -0,0 +1,32 @@ +/** + * `@/api` —— Default Frontend 的领域 API 门面。 + * + * 客户端实现(传输、错误归一化、领域方法)全部来自 `@floatctf/sdk`; + * 这个文件只是"把 SDK 门面 + 本前端的接线"合并成页面直接可用的形状, + * 因此页面的 `import { serviceApi, adminApi } from "@/api"` 语义与迁移前一致。 + */ + +import { + adminApi as adminApiFacade, + serviceApi as serviceApiFacade, +} from "@floatctf/sdk"; + +import { client } from "./client"; + +/** 选手端 API(与迁移前的 `serviceApi` 形状一致)。 */ +export const serviceApi = serviceApiFacade; + +/** 管理端 API(与迁移前的 `adminApi` 形状一致)。 */ +export const adminApi = adminApiFacade; + +export { client }; +export type { FloatCTFClient } from "@floatctf/sdk"; + +/** Docker/DTO 类型(迁移前由 `@/api` 再导出,保持调用方不变)。 */ +export type { + ContainerInfo, + FloatDockerContainer, + ImageInfo, + NetworkInfo, + PortInfo, +} from "@floatctf/sdk"; diff --git a/frontends/default/src/api/react.ts b/frontends/default/src/api/react.ts new file mode 100644 index 00000000..2bcdd67a --- /dev/null +++ b/frontends/default/src/api/react.ts @@ -0,0 +1,34 @@ +/** + * Default Frontend 的 React 绑定接线(pages 通过 `@/api/react` 使用)。 + * + * `@floatctf/react` 是**可选**的 headless 包:它不知道 token 存在哪里,也不知道 + * 用哪个状态库。这里把本前端的 Zustand 选择器注入进去,得到与迁移前 + * `@/hooks/*`、`@/api/queries` **同名同签名**的 hook 与 query 工厂, + * 因此页面代码零改动。 + * + * 非 React 前端完全不使用本文件——它们直接用 `@floatctf/sdk`。 + */ + +import { createFloatCTFReact } from "@floatctf/react"; + +import { useAuthStore } from "@/stores/AuthStore"; +import { client } from "./client"; + +const bindings = createFloatCTFReact({ + client, + useUserToken: () => useAuthStore((s) => s.token), + useAdminToken: () => useAuthStore((s) => s.adminToken), +}); + +export const { + // 实时事件流 + useAwdEventStream, + useAdminAwdEventStream, + useAwdpEventStream, + useAwdpRunStream, + // query options 工厂 + eventInfoQueryOptions, + challengeQueryOptions, + challengeInstanceQueryOptions, + systemInformationQueryOptions, +} = bindings; diff --git a/apps/web/src/components/ActionSelect.tsx b/frontends/default/src/components/ActionSelect.tsx similarity index 98% rename from apps/web/src/components/ActionSelect.tsx rename to frontends/default/src/components/ActionSelect.tsx index 2b817e6e..d9585eec 100644 --- a/apps/web/src/components/ActionSelect.tsx +++ b/frontends/default/src/components/ActionSelect.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { XIcon } from "@primer/octicons-react"; import { Autocomplete, Button, FormControl, TextInput } from "@primer/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; diff --git a/apps/web/src/components/EventStatusBadge.tsx b/frontends/default/src/components/EventStatusBadge.tsx similarity index 100% rename from apps/web/src/components/EventStatusBadge.tsx rename to frontends/default/src/components/EventStatusBadge.tsx diff --git a/apps/web/src/components/FilterBar.tsx b/frontends/default/src/components/FilterBar.tsx similarity index 100% rename from apps/web/src/components/FilterBar.tsx rename to frontends/default/src/components/FilterBar.tsx diff --git a/apps/web/src/components/Header.tsx b/frontends/default/src/components/Header.tsx similarity index 98% rename from apps/web/src/components/Header.tsx rename to frontends/default/src/components/Header.tsx index 5f4491f3..02b897bd 100644 --- a/apps/web/src/components/Header.tsx +++ b/frontends/default/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { userServiceApi } from "@/api/service"; +import { userServiceApi } from "@floatctf/sdk"; import { useAppNavigate } from "@/navigation"; import { useAuthStore } from "@/stores/AuthStore"; import { Button } from "@primer/react"; diff --git a/apps/web/src/components/MDPlusEditor.tsx b/frontends/default/src/components/MDPlusEditor.tsx similarity index 99% rename from apps/web/src/components/MDPlusEditor.tsx rename to frontends/default/src/components/MDPlusEditor.tsx index ddb64b47..1d201e2e 100644 --- a/apps/web/src/components/MDPlusEditor.tsx +++ b/frontends/default/src/components/MDPlusEditor.tsx @@ -1,4 +1,4 @@ -import { uploadsServiceApi } from "@/api/service"; +import { uploadsServiceApi } from "@floatctf/sdk"; import { ImageIcon, UploadIcon } from "@primer/octicons-react"; import { Button } from "@primer/react"; import MDEditor, { diff --git a/apps/web/src/components/MsgBanner.tsx b/frontends/default/src/components/MsgBanner.tsx similarity index 100% rename from apps/web/src/components/MsgBanner.tsx rename to frontends/default/src/components/MsgBanner.tsx diff --git a/apps/web/src/components/MsgInlineBanner.tsx b/frontends/default/src/components/MsgInlineBanner.tsx similarity index 100% rename from apps/web/src/components/MsgInlineBanner.tsx rename to frontends/default/src/components/MsgInlineBanner.tsx diff --git a/apps/web/src/components/RouteLoading.tsx b/frontends/default/src/components/RouteLoading.tsx similarity index 100% rename from apps/web/src/components/RouteLoading.tsx rename to frontends/default/src/components/RouteLoading.tsx diff --git a/apps/web/src/components/RouterUnderlineNavItem.tsx b/frontends/default/src/components/RouterUnderlineNavItem.tsx similarity index 100% rename from apps/web/src/components/RouterUnderlineNavItem.tsx rename to frontends/default/src/components/RouterUnderlineNavItem.tsx diff --git a/apps/web/src/components/SideBar.test.tsx b/frontends/default/src/components/SideBar.test.tsx similarity index 100% rename from apps/web/src/components/SideBar.test.tsx rename to frontends/default/src/components/SideBar.test.tsx diff --git a/apps/web/src/components/SideBar.tsx b/frontends/default/src/components/SideBar.tsx similarity index 100% rename from apps/web/src/components/SideBar.tsx rename to frontends/default/src/components/SideBar.tsx diff --git a/apps/web/src/components/SubmitWriteup.tsx b/frontends/default/src/components/SubmitWriteup.tsx similarity index 100% rename from apps/web/src/components/SubmitWriteup.tsx rename to frontends/default/src/components/SubmitWriteup.tsx diff --git a/apps/web/src/components/Table.tsx b/frontends/default/src/components/Table.tsx similarity index 99% rename from apps/web/src/components/Table.tsx rename to frontends/default/src/components/Table.tsx index 5b9750b3..126983bd 100644 --- a/apps/web/src/components/Table.tsx +++ b/frontends/default/src/components/Table.tsx @@ -1,4 +1,4 @@ -import type { QueryParams, UniResponse } from "@/api/axios"; +import type { QueryParams, UniResponse } from "@floatctf/sdk"; import { diffToPatch } from "@/util"; import { KebabHorizontalIcon } from "@primer/octicons-react"; import { diff --git a/apps/web/src/components/Truncate.tsx b/frontends/default/src/components/Truncate.tsx similarity index 100% rename from apps/web/src/components/Truncate.tsx rename to frontends/default/src/components/Truncate.tsx diff --git a/apps/web/src/components/UnderlineNavButton.tsx b/frontends/default/src/components/UnderlineNavButton.tsx similarity index 100% rename from apps/web/src/components/UnderlineNavButton.tsx rename to frontends/default/src/components/UnderlineNavButton.tsx diff --git a/apps/web/src/components/__tests__/RouterUnderlineNavItem.test.tsx b/frontends/default/src/components/__tests__/RouterUnderlineNavItem.test.tsx similarity index 100% rename from apps/web/src/components/__tests__/RouterUnderlineNavItem.test.tsx rename to frontends/default/src/components/__tests__/RouterUnderlineNavItem.test.tsx diff --git a/apps/web/src/components/__tests__/UnderlineNavButton.test.tsx b/frontends/default/src/components/__tests__/UnderlineNavButton.test.tsx similarity index 100% rename from apps/web/src/components/__tests__/UnderlineNavButton.test.tsx rename to frontends/default/src/components/__tests__/UnderlineNavButton.test.tsx diff --git a/apps/web/src/components/admin/EventInstancesTable.tsx b/frontends/default/src/components/admin/EventInstancesTable.tsx similarity index 97% rename from apps/web/src/components/admin/EventInstancesTable.tsx rename to frontends/default/src/components/admin/EventInstancesTable.tsx index ae7cb759..c3ed6808 100644 --- a/apps/web/src/components/admin/EventInstancesTable.tsx +++ b/frontends/default/src/components/admin/EventInstancesTable.tsx @@ -1,6 +1,6 @@ import { Label } from "@primer/react"; -import { instanceAdminApi, type AdminInstanceRow } from "@/api/admin/instances"; +import { instanceAdminApi, type AdminInstanceRow } from "@floatctf/sdk"; import { GenericTable } from "@/components"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/components/apiErrorMessage.ts b/frontends/default/src/components/apiErrorMessage.ts similarity index 100% rename from apps/web/src/components/apiErrorMessage.ts rename to frontends/default/src/components/apiErrorMessage.ts diff --git a/apps/web/src/components/awd/AwdEventProgress.tsx b/frontends/default/src/components/awd/AwdEventProgress.tsx similarity index 98% rename from apps/web/src/components/awd/AwdEventProgress.tsx rename to frontends/default/src/components/awd/AwdEventProgress.tsx index d8945910..79a51b15 100644 --- a/apps/web/src/components/awd/AwdEventProgress.tsx +++ b/frontends/default/src/components/awd/AwdEventProgress.tsx @@ -1,7 +1,7 @@ import { ProgressBar } from "@primer/react"; import { useEffect, useState } from "react"; -import type { AwdEventStatus, AwdPlayerStatus } from "@/api/awd"; +import type { AwdEventStatus, AwdPlayerStatus } from "@floatctf/sdk"; /** AWD 赛事阶段标签。 */ const PHASE_LABEL: Record = { diff --git a/apps/web/src/components/awd/__tests__/AwdStateLogic.test.ts b/frontends/default/src/components/awd/__tests__/AwdStateLogic.test.ts similarity index 100% rename from apps/web/src/components/awd/__tests__/AwdStateLogic.test.ts rename to frontends/default/src/components/awd/__tests__/AwdStateLogic.test.ts diff --git a/apps/web/src/components/awd/__tests__/EventNetworkPage.test.tsx b/frontends/default/src/components/awd/__tests__/EventNetworkPage.test.tsx similarity index 99% rename from apps/web/src/components/awd/__tests__/EventNetworkPage.test.tsx rename to frontends/default/src/components/awd/__tests__/EventNetworkPage.test.tsx index 37a79552..20340e14 100644 --- a/apps/web/src/components/awd/__tests__/EventNetworkPage.test.tsx +++ b/frontends/default/src/components/awd/__tests__/EventNetworkPage.test.tsx @@ -18,7 +18,7 @@ import { vi, } from "vitest"; -import type { EventNetworkInfo } from "@/api/awd"; +import type { EventNetworkInfo } from "@floatctf/sdk"; const fixtures = vi.hoisted(() => { const EVENT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; diff --git a/apps/web/src/components/awd/__tests__/PlatformNetworkPage.test.tsx b/frontends/default/src/components/awd/__tests__/PlatformNetworkPage.test.tsx similarity index 99% rename from apps/web/src/components/awd/__tests__/PlatformNetworkPage.test.tsx rename to frontends/default/src/components/awd/__tests__/PlatformNetworkPage.test.tsx index 952c6b93..75662369 100644 --- a/apps/web/src/components/awd/__tests__/PlatformNetworkPage.test.tsx +++ b/frontends/default/src/components/awd/__tests__/PlatformNetworkPage.test.tsx @@ -14,7 +14,7 @@ import type { PlatformNetworkAllocation, PlatformNetworkHealth, PlatformNetworkSettings, -} from "@/api/awd"; +} from "@floatctf/sdk"; const fixtures = vi.hoisted(() => { const settings: PlatformNetworkSettings = { diff --git a/apps/web/src/components/awd/__tests__/networkForm.test.ts b/frontends/default/src/components/awd/__tests__/networkForm.test.ts similarity index 100% rename from apps/web/src/components/awd/__tests__/networkForm.test.ts rename to frontends/default/src/components/awd/__tests__/networkForm.test.ts diff --git a/apps/web/src/components/awd/networkForm.ts b/frontends/default/src/components/awd/networkForm.ts similarity index 99% rename from apps/web/src/components/awd/networkForm.ts rename to frontends/default/src/components/awd/networkForm.ts index b23da596..6c7a4c4a 100644 --- a/apps/web/src/components/awd/networkForm.ts +++ b/frontends/default/src/components/awd/networkForm.ts @@ -8,7 +8,7 @@ * - WireGuardPortRange:1 ≤ 起始端口 ≤ 结束端口 ≤ 65535。 */ -import type { PlatformNetworkSettings } from "@/api/awd"; +import type { PlatformNetworkSettings } from "@floatctf/sdk"; const MAX_PREFIX = 32; const MAX_PORT = 65535; diff --git a/apps/web/src/components/awdp/AwdpEventProgress.tsx b/frontends/default/src/components/awdp/AwdpEventProgress.tsx similarity index 99% rename from apps/web/src/components/awdp/AwdpEventProgress.tsx rename to frontends/default/src/components/awdp/AwdpEventProgress.tsx index fbc67855..fc6f9fa7 100644 --- a/apps/web/src/components/awdp/AwdpEventProgress.tsx +++ b/frontends/default/src/components/awdp/AwdpEventProgress.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { serviceApi } from "@/api"; -import { type AwdpOverview, awdpPlayerApi } from "@/api/awdp"; +import { type AwdpOverview, awdpPlayerApi } from "@floatctf/sdk"; import { computeEventStatus } from "@/components"; import { AwdpTimeline, diff --git a/apps/web/src/components/awdp/AwdpPhaseOverview.tsx b/frontends/default/src/components/awdp/AwdpPhaseOverview.tsx similarity index 99% rename from apps/web/src/components/awdp/AwdpPhaseOverview.tsx rename to frontends/default/src/components/awdp/AwdpPhaseOverview.tsx index 6cd29783..7c9d88ef 100644 --- a/apps/web/src/components/awdp/AwdpPhaseOverview.tsx +++ b/frontends/default/src/components/awdp/AwdpPhaseOverview.tsx @@ -17,7 +17,7 @@ import { BugIcon, ToolsIcon } from "@primer/octicons-react"; import { Button, Label, SegmentedControl } from "@primer/react"; -import type { AwdpPhase } from "@/api/awdp"; +import type { AwdpPhase } from "@floatctf/sdk"; // ──────────────────────────────────────────────────────────────────────────── // Phase 语义元数据(badge 颜色/描述文案) diff --git a/apps/web/src/components/awdp/AwdpScoreboard.tsx b/frontends/default/src/components/awdp/AwdpScoreboard.tsx similarity index 99% rename from apps/web/src/components/awdp/AwdpScoreboard.tsx rename to frontends/default/src/components/awdp/AwdpScoreboard.tsx index 37f53649..fe67ee83 100644 --- a/apps/web/src/components/awdp/AwdpScoreboard.tsx +++ b/frontends/default/src/components/awdp/AwdpScoreboard.tsx @@ -8,7 +8,7 @@ import type { AwdpScoreboardDetail, AwdpScoreboardGameBox, AwdpScoreboardRow, -} from "@/api/awdp"; +} from "@floatctf/sdk"; /** * AWDP 赛事 Scoreboard(选手端)。 diff --git a/apps/web/src/components/awdp/AwdpWorkbench.tsx b/frontends/default/src/components/awdp/AwdpWorkbench.tsx similarity index 99% rename from apps/web/src/components/awdp/AwdpWorkbench.tsx rename to frontends/default/src/components/awdp/AwdpWorkbench.tsx index 8ad8f1cd..95e1f6d0 100644 --- a/apps/web/src/components/awdp/AwdpWorkbench.tsx +++ b/frontends/default/src/components/awdp/AwdpWorkbench.tsx @@ -28,7 +28,7 @@ import type { BreakSubmitResponse, ManualCheckDto, PatchSubmitResponse, -} from "@/api/awdp"; +} from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AwdpPhaseOverview } from "@/components/awdp/AwdpPhaseOverview"; diff --git a/apps/web/src/components/awdp/RunWriteupEditor.tsx b/frontends/default/src/components/awdp/RunWriteupEditor.tsx similarity index 97% rename from apps/web/src/components/awdp/RunWriteupEditor.tsx rename to frontends/default/src/components/awdp/RunWriteupEditor.tsx index dc8684e6..b6de5a6b 100644 --- a/apps/web/src/components/awdp/RunWriteupEditor.tsx +++ b/frontends/default/src/components/awdp/RunWriteupEditor.tsx @@ -7,7 +7,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; -import { awdpRunApi } from "@/api/awdpRuns"; +import { awdpRunApi } from "@floatctf/sdk"; import { MDPlusEditor, useMsgBanner } from "@/components"; export function RunWriteupEditor({ runId }: { runId: string }) { const banner = useMsgBanner(); diff --git a/apps/web/src/components/awdp/__tests__/AwdpEventProgress.test.tsx b/frontends/default/src/components/awdp/__tests__/AwdpEventProgress.test.tsx similarity index 99% rename from apps/web/src/components/awdp/__tests__/AwdpEventProgress.test.tsx rename to frontends/default/src/components/awdp/__tests__/AwdpEventProgress.test.tsx index e89c9dfb..bf1d1df3 100644 --- a/apps/web/src/components/awdp/__tests__/AwdpEventProgress.test.tsx +++ b/frontends/default/src/components/awdp/__tests__/AwdpEventProgress.test.tsx @@ -18,8 +18,8 @@ import { } from "vitest"; import { serviceApi } from "@/api"; -import type { AwdpOverview } from "@/api/awdp"; -import { awdpPlayerApi } from "@/api/awdp"; +import type { AwdpOverview } from "@floatctf/sdk"; +import { awdpPlayerApi } from "@floatctf/sdk"; import { AwdpEventProgress } from "../AwdpEventProgress"; diff --git a/apps/web/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx b/frontends/default/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx similarity index 100% rename from apps/web/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx rename to frontends/default/src/components/awdp/__tests__/AwdpPhaseOverview.test.tsx diff --git a/apps/web/src/components/awdp/__tests__/AwdpScoreboard.test.tsx b/frontends/default/src/components/awdp/__tests__/AwdpScoreboard.test.tsx similarity index 98% rename from apps/web/src/components/awdp/__tests__/AwdpScoreboard.test.tsx rename to frontends/default/src/components/awdp/__tests__/AwdpScoreboard.test.tsx index d567cc3b..ade2f2e2 100644 --- a/apps/web/src/components/awdp/__tests__/AwdpScoreboard.test.tsx +++ b/frontends/default/src/components/awdp/__tests__/AwdpScoreboard.test.tsx @@ -9,7 +9,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { AwdpScoreboardDetail } from "@/api/awdp"; +import type { AwdpScoreboardDetail } from "@floatctf/sdk"; import { AwdpScoreboardView } from "../AwdpScoreboard"; diff --git a/apps/web/src/components/awdp/__tests__/AwdpWorkbench.test.tsx b/frontends/default/src/components/awdp/__tests__/AwdpWorkbench.test.tsx similarity index 99% rename from apps/web/src/components/awdp/__tests__/AwdpWorkbench.test.tsx rename to frontends/default/src/components/awdp/__tests__/AwdpWorkbench.test.tsx index 0042d679..039c0aa0 100644 --- a/apps/web/src/components/awdp/__tests__/AwdpWorkbench.test.tsx +++ b/frontends/default/src/components/awdp/__tests__/AwdpWorkbench.test.tsx @@ -8,7 +8,7 @@ import { } from "@testing-library/react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import type { AwdpPhase } from "@/api/awdp"; +import type { AwdpPhase } from "@floatctf/sdk"; import { AwdpWorkbench, type AwdpWorkbenchGameBox, diff --git a/apps/web/src/components/index.tsx b/frontends/default/src/components/index.tsx similarity index 100% rename from apps/web/src/components/index.tsx rename to frontends/default/src/components/index.tsx diff --git a/apps/web/src/config.ts b/frontends/default/src/config.ts similarity index 100% rename from apps/web/src/config.ts rename to frontends/default/src/config.ts diff --git a/frontends/default/src/dev.tsx b/frontends/default/src/dev.tsx new file mode 100644 index 00000000..9efc1f33 --- /dev/null +++ b/frontends/default/src/dev.tsx @@ -0,0 +1,27 @@ +/** + * DEV ONLY 入口(`frontends/default/index.html` 加载它)。 + * + * 开发模式下 Default Frontend 直接挂载**工作区源码**,保留 Vite HMR; + * 生产模式走 `apps/web` bootstrap → 本地注册表 → 版本化 ESM 制品。 + * 两条路径都最终调用同一个 `mount(context)`,因此契约不会分叉。 + */ + +import { DEFAULT_API_BASE_URL } from "@floatctf/frontend-runtime"; + +import { mount } from "./entry.tsx"; +import pkg from "../package.json"; + +const root = document.getElementById("app"); +if (root) { + mount({ + root, + apiBaseUrl: DEFAULT_API_BASE_URL, + assetBaseUrl: "/", + frontendId: "default", + frontendVersion: pkg.version, + platformVersion: "dev", + apiContractVersion: "1", + frontendRuntimeVersion: "1", + capabilities: ["jeopardy", "awd", "awdp", "discussions", "writeups", "web_terminal"], + }); +} diff --git a/frontends/default/src/entry.tsx b/frontends/default/src/entry.tsx new file mode 100644 index 00000000..297cd433 --- /dev/null +++ b/frontends/default/src/entry.tsx @@ -0,0 +1,64 @@ +/** + * Default Frontend 的制品入口 —— 实现 FloatCTF 前端运行时契约。 + * + * bootstrap(`@floatctf/frontend-runtime`)只做三件事:解析注册表、注入样式、 + * 动态 import 这个模块并调用 `mount(context)`。**其余全部在这里**:React、Router、 + * TanStack Query、Primer、Tailwind、styled-components、页面与导航。 + * + * 与迁移前的 `apps/web/src/main.tsx` 相比,唯一的语义变化是: + * 渲染宿主来自 `context.root`(由 bootstrap 决定),而不是写死 `#app`。 + * 渲染树、Provider 顺序、单例 QueryClient、StrictMode、styled-components 的 + * `shouldForwardProp` 过滤器都与迁移前完全一致 —— UI/UX 不变。 + */ + +import { BaseStyles, ThemeProvider } from "@primer/react"; +import { StrictMode } from "react"; +import isPropValid from "@emotion/is-prop-valid"; +import { StyleSheetManager } from "styled-components"; +import ReactDOM from "react-dom/client"; +import type { FloatCTFFrontendModule, FloatCTFMountContext } from "@floatctf/frontend-runtime"; + +import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx"; +import { + RouterProvider, + TanStackQueryProviderContext, + router, +} from "./router.tsx"; +import reportWebVitals from "./reportWebVitals.ts"; +// 样式:dev 由 Vite 注入;生产构建会抽成独立 CSS,由 bootstrap 按 frontend.json 注入。 +import "./style.css"; + +let mountedRoot: HTMLElement | null = null; + +export function mount(context: FloatCTFMountContext): void { + if (mountedRoot === context.root) return; // 幂等:同一宿主重复 mount 直接忽略 + mountedRoot = context.root; + + const root = ReactDOM.createRoot(context.root); + root.render( + + + typeof target !== "string" || isPropValid(propName) + } + > + + + + + + + + + , + ); + + // 若要测量应用性能,可传入函数(记录结果或发送到分析端点)。 + reportWebVitals(); +} + +/** 制品自述(诊断用;平台以本地注册表为准)。 */ +export const manifest = { id: "default", version: "1.0.0" }; + +const frontendModule: FloatCTFFrontendModule = { manifest, mount }; +export default frontendModule; diff --git a/apps/web/src/integrations/tanstack-query/devtools.tsx b/frontends/default/src/integrations/tanstack-query/devtools.tsx similarity index 100% rename from apps/web/src/integrations/tanstack-query/devtools.tsx rename to frontends/default/src/integrations/tanstack-query/devtools.tsx diff --git a/apps/web/src/integrations/tanstack-query/root-provider.tsx b/frontends/default/src/integrations/tanstack-query/root-provider.tsx similarity index 100% rename from apps/web/src/integrations/tanstack-query/root-provider.tsx rename to frontends/default/src/integrations/tanstack-query/root-provider.tsx diff --git a/apps/web/src/logo.svg b/frontends/default/src/logo.svg similarity index 100% rename from apps/web/src/logo.svg rename to frontends/default/src/logo.svg diff --git a/apps/web/src/navigation/AppLink.tsx b/frontends/default/src/navigation/AppLink.tsx similarity index 100% rename from apps/web/src/navigation/AppLink.tsx rename to frontends/default/src/navigation/AppLink.tsx diff --git a/apps/web/src/navigation/NavigationContext.tsx b/frontends/default/src/navigation/NavigationContext.tsx similarity index 100% rename from apps/web/src/navigation/NavigationContext.tsx rename to frontends/default/src/navigation/NavigationContext.tsx diff --git a/apps/web/src/navigation/NavigationProgress.tsx b/frontends/default/src/navigation/NavigationProgress.tsx similarity index 100% rename from apps/web/src/navigation/NavigationProgress.tsx rename to frontends/default/src/navigation/NavigationProgress.tsx diff --git a/apps/web/src/navigation/__tests__/AppLink.test.tsx b/frontends/default/src/navigation/__tests__/AppLink.test.tsx similarity index 100% rename from apps/web/src/navigation/__tests__/AppLink.test.tsx rename to frontends/default/src/navigation/__tests__/AppLink.test.tsx diff --git a/apps/web/src/navigation/__tests__/navigation-race.test.tsx b/frontends/default/src/navigation/__tests__/navigation-race.test.tsx similarity index 100% rename from apps/web/src/navigation/__tests__/navigation-race.test.tsx rename to frontends/default/src/navigation/__tests__/navigation-race.test.tsx diff --git a/apps/web/src/navigation/__tests__/service-navigation.test.ts b/frontends/default/src/navigation/__tests__/service-navigation.test.ts similarity index 100% rename from apps/web/src/navigation/__tests__/service-navigation.test.ts rename to frontends/default/src/navigation/__tests__/service-navigation.test.ts diff --git a/apps/web/src/navigation/__tests__/sidebar-utils.test.ts b/frontends/default/src/navigation/__tests__/sidebar-utils.test.ts similarity index 100% rename from apps/web/src/navigation/__tests__/sidebar-utils.test.ts rename to frontends/default/src/navigation/__tests__/sidebar-utils.test.ts diff --git a/apps/web/src/navigation/admin-navigation.tsx b/frontends/default/src/navigation/admin-navigation.tsx similarity index 100% rename from apps/web/src/navigation/admin-navigation.tsx rename to frontends/default/src/navigation/admin-navigation.tsx diff --git a/apps/web/src/navigation/index.ts b/frontends/default/src/navigation/index.ts similarity index 100% rename from apps/web/src/navigation/index.ts rename to frontends/default/src/navigation/index.ts diff --git a/apps/web/src/navigation/navigation-types.ts b/frontends/default/src/navigation/navigation-types.ts similarity index 100% rename from apps/web/src/navigation/navigation-types.ts rename to frontends/default/src/navigation/navigation-types.ts diff --git a/apps/web/src/navigation/service-navigation.tsx b/frontends/default/src/navigation/service-navigation.tsx similarity index 100% rename from apps/web/src/navigation/service-navigation.tsx rename to frontends/default/src/navigation/service-navigation.tsx diff --git a/apps/web/src/navigation/sidebar-types.ts b/frontends/default/src/navigation/sidebar-types.ts similarity index 100% rename from apps/web/src/navigation/sidebar-types.ts rename to frontends/default/src/navigation/sidebar-types.ts diff --git a/apps/web/src/navigation/sidebar-utils.ts b/frontends/default/src/navigation/sidebar-utils.ts similarity index 100% rename from apps/web/src/navigation/sidebar-utils.ts rename to frontends/default/src/navigation/sidebar-utils.ts diff --git a/apps/web/src/navigation/useAppNavigate.ts b/frontends/default/src/navigation/useAppNavigate.ts similarity index 100% rename from apps/web/src/navigation/useAppNavigate.ts rename to frontends/default/src/navigation/useAppNavigate.ts diff --git a/apps/web/src/reportWebVitals.ts b/frontends/default/src/reportWebVitals.ts similarity index 100% rename from apps/web/src/reportWebVitals.ts rename to frontends/default/src/reportWebVitals.ts diff --git a/frontends/default/src/routeTree.gen.ts b/frontends/default/src/routeTree.gen.ts new file mode 100644 index 00000000..23f5ee04 --- /dev/null +++ b/frontends/default/src/routeTree.gen.ts @@ -0,0 +1,2413 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as Reset_passwordRouteImport } from './routes/reset_password' +import { Route as ResetRouteImport } from './routes/reset' +import { Route as RegisterRouteImport } from './routes/register' +import { Route as ServiceRouteRouteImport } from './routes/service/route' +import { Route as AdminRouteRouteImport } from './routes/admin/route' +import { Route as IndexRouteImport } from './routes/index' +import { Route as ServiceIndexRouteImport } from './routes/service/index' +import { Route as AdminIndexRouteImport } from './routes/admin/index' +import { Route as ServiceWeaponsRouteImport } from './routes/service/weapons' +import { Route as ServiceTopRouteImport } from './routes/service/top' +import { Route as ServiceSolvesRouteImport } from './routes/service/solves' +import { Route as ServiceProfileRouteImport } from './routes/service/profile' +import { Route as ServiceInstancesRouteImport } from './routes/service/instances' +import { Route as ServiceGameboxesRouteImport } from './routes/service/gameboxes' +import { Route as ServiceAnnouncementsRouteImport } from './routes/service/announcements' +import { Route as AdminWeaponsRouteImport } from './routes/admin/weapons' +import { Route as AdminVersionRouteImport } from './routes/admin/version' +import { Route as AdminUsersRouteImport } from './routes/admin/users' +import { Route as AdminTerminalRouteImport } from './routes/admin/terminal' +import { Route as AdminSuper_adminsRouteImport } from './routes/admin/super_admins' +import { Route as AdminSettingsRouteImport } from './routes/admin/settings' +import { Route as AdminScheduled_tasksRouteImport } from './routes/admin/scheduled_tasks' +import { Route as AdminLogsRouteImport } from './routes/admin/logs' +import { Route as AdminDiscussionsRouteImport } from './routes/admin/discussions' +import { Route as AdminDatabaseRouteImport } from './routes/admin/database' +import { Route as AdminDashboardRouteImport } from './routes/admin/dashboard' +import { Route as AdminChallengesRouteImport } from './routes/admin/challenges' +import { Route as AdminAnnouncementsRouteImport } from './routes/admin/announcements' +import { Route as ServiceDiscussionsRouteRouteImport } from './routes/service/discussions/route' +import { Route as AdminDockerRouteRouteImport } from './routes/admin/docker/route' +import { Route as ServiceWriteupsIndexRouteImport } from './routes/service/writeups/index' +import { Route as ServiceEventsIndexRouteImport } from './routes/service/events/index' +import { Route as ServiceDiscussionsIndexRouteImport } from './routes/service/discussions/index' +import { Route as ServiceChallengesIndexRouteImport } from './routes/service/challenges/index' +import { Route as ServiceChallenge_setsIndexRouteImport } from './routes/service/challenge_sets/index' +import { Route as AdminEventsIndexRouteImport } from './routes/admin/events/index' +import { Route as AdminDockerIndexRouteImport } from './routes/admin/docker/index' +import { Route as AdminChallenge_setsIndexRouteImport } from './routes/admin/challenge_sets/index' +import { Route as ServiceWriteupsIdRouteImport } from './routes/service/writeups/$id' +import { Route as ServiceDiscussionsMyRouteImport } from './routes/service/discussions/my' +import { Route as ServiceDiscussionsIdRouteImport } from './routes/service/discussions/$id' +import { Route as AdminDockerNetworksRouteImport } from './routes/admin/docker/networks' +import { Route as AdminDockerImagesRouteImport } from './routes/admin/docker/images' +import { Route as AdminChallenge_setsIdRouteImport } from './routes/admin/challenge_sets/$id' +import { Route as AdminAwdNetworkRouteImport } from './routes/admin/awd/network' +import { Route as AdminAwdGameboxesRouteImport } from './routes/admin/awd/gameboxes' +import { Route as ServiceChallengesIdRouteRouteImport } from './routes/service/challenges/$id/route' +import { Route as ServiceChallengesIdIndexRouteImport } from './routes/service/challenges/$id/index' +import { Route as ServiceChallenge_setsIdIndexRouteImport } from './routes/service/challenge_sets/$id/index' +import { Route as ServiceChallengesIdWriteupRouteImport } from './routes/service/challenges/$id/writeup' +import { Route as ServiceEventsJeopardyIdRouteRouteImport } from './routes/service/events/jeopardy.$id/route' +import { Route as ServiceEventsAwdpIdRouteRouteImport } from './routes/service/events/awdp.$id/route' +import { Route as ServiceEventsAwdIdRouteRouteImport } from './routes/service/events/awd.$id/route' +import { Route as ServiceAwdpRunsRunIdRouteRouteImport } from './routes/service/awdp/runs.$runId/route' +import { Route as AdminEventsJeopardyIdRouteRouteImport } from './routes/admin/events/jeopardy.$id/route' +import { Route as AdminEventsAwdpIdRouteRouteImport } from './routes/admin/events/awdp.$id/route' +import { Route as AdminEventsAwdIdRouteRouteImport } from './routes/admin/events/awd.$id/route' +import { Route as ServiceEventsJeopardyIdIndexRouteImport } from './routes/service/events/jeopardy.$id/index' +import { Route as ServiceEventsAwdpIdIndexRouteImport } from './routes/service/events/awdp.$id/index' +import { Route as ServiceEventsAwdIdIndexRouteImport } from './routes/service/events/awd.$id/index' +import { Route as ServiceAwdpRunsRunIdIndexRouteImport } from './routes/service/awdp/runs.$runId/index' +import { Route as AdminEventsJeopardyIdIndexRouteImport } from './routes/admin/events/jeopardy.$id/index' +import { Route as AdminEventsAwdpIdIndexRouteImport } from './routes/admin/events/awdp.$id/index' +import { Route as AdminEventsAwdIdIndexRouteImport } from './routes/admin/events/awd.$id/index' +import { Route as ServiceEventsJeopardyIdTrendRouteImport } from './routes/service/events/jeopardy.$id/trend' +import { Route as ServiceEventsJeopardyIdScoreboardRouteImport } from './routes/service/events/jeopardy.$id/scoreboard' +import { Route as ServiceEventsJeopardyIdInstancesRouteImport } from './routes/service/events/jeopardy.$id/instances' +import { Route as ServiceEventsJeopardyIdChallengesRouteImport } from './routes/service/events/jeopardy.$id/challenges' +import { Route as ServiceEventsJeopardyIdAnnouncementRouteImport } from './routes/service/events/jeopardy.$id/announcement' +import { Route as ServiceEventsAwdpIdTrendRouteImport } from './routes/service/events/awdp.$id/trend' +import { Route as ServiceEventsAwdpIdScoreboardRouteImport } from './routes/service/events/awdp.$id/scoreboard' +import { Route as ServiceEventsAwdpIdRoundsRouteImport } from './routes/service/events/awdp.$id/rounds' +import { Route as ServiceEventsAwdpIdGameboxesRouteImport } from './routes/service/events/awdp.$id/gameboxes' +import { Route as ServiceEventsAwdpIdAnnouncementRouteImport } from './routes/service/events/awdp.$id/announcement' +import { Route as ServiceEventsAwdIdWireguardRouteImport } from './routes/service/events/awd.$id/wireguard' +import { Route as ServiceEventsAwdIdSshRouteImport } from './routes/service/events/awd.$id/ssh' +import { Route as ServiceEventsAwdIdScoreboardRouteImport } from './routes/service/events/awd.$id/scoreboard' +import { Route as ServiceEventsAwdIdGameboxesRouteImport } from './routes/service/events/awd.$id/gameboxes' +import { Route as ServiceAwdpRunsRunIdWriteupRouteImport } from './routes/service/awdp/runs.$runId/writeup' +import { Route as AdminEventsJeopardyIdWriteupsRouteImport } from './routes/admin/events/jeopardy.$id/writeups' +import { Route as AdminEventsJeopardyIdUsersRouteImport } from './routes/admin/events/jeopardy.$id/users' +import { Route as AdminEventsJeopardyIdTeamsRouteImport } from './routes/admin/events/jeopardy.$id/teams' +import { Route as AdminEventsJeopardyIdLogsRouteImport } from './routes/admin/events/jeopardy.$id/logs' +import { Route as AdminEventsJeopardyIdInstanceRouteImport } from './routes/admin/events/jeopardy.$id/instance' +import { Route as AdminEventsJeopardyIdData_presentRouteImport } from './routes/admin/events/jeopardy.$id/data_present' +import { Route as AdminEventsJeopardyIdAnnouncementsRouteImport } from './routes/admin/events/jeopardy.$id/announcements' +import { Route as AdminEventsAwdpIdWriteupsRouteImport } from './routes/admin/events/awdp.$id/writeups' +import { Route as AdminEventsAwdpIdTeamsRouteImport } from './routes/admin/events/awdp.$id/teams' +import { Route as AdminEventsAwdpIdScoreboardRouteImport } from './routes/admin/events/awdp.$id/scoreboard' +import { Route as AdminEventsAwdpIdOpsRouteImport } from './routes/admin/events/awdp.$id/ops' +import { Route as AdminEventsAwdpIdLogsRouteImport } from './routes/admin/events/awdp.$id/logs' +import { Route as AdminEventsAwdpIdInstanceRouteImport } from './routes/admin/events/awdp.$id/instance' +import { Route as AdminEventsAwdpIdGameboxesRouteImport } from './routes/admin/events/awdp.$id/gameboxes' +import { Route as AdminEventsAwdpIdData_presentRouteImport } from './routes/admin/events/awdp.$id/data_present' +import { Route as AdminEventsAwdpIdConfigureRouteImport } from './routes/admin/events/awdp.$id/configure' +import { Route as AdminEventsAwdpIdAnnouncementsRouteImport } from './routes/admin/events/awdp.$id/announcements' +import { Route as AdminEventsAwdIdWriteupsRouteImport } from './routes/admin/events/awd.$id/writeups' +import { Route as AdminEventsAwdIdTeamsRouteImport } from './routes/admin/events/awd.$id/teams' +import { Route as AdminEventsAwdIdOpsRouteImport } from './routes/admin/events/awd.$id/ops' +import { Route as AdminEventsAwdIdNetworkRouteImport } from './routes/admin/events/awd.$id/network' +import { Route as AdminEventsAwdIdLogsRouteImport } from './routes/admin/events/awd.$id/logs' +import { Route as AdminEventsAwdIdInstanceRouteImport } from './routes/admin/events/awd.$id/instance' +import { Route as AdminEventsAwdIdGameboxesRouteImport } from './routes/admin/events/awd.$id/gameboxes' +import { Route as AdminEventsAwdIdConfigureRouteImport } from './routes/admin/events/awd.$id/configure' +import { Route as AdminEventsAwdIdAnnouncementsRouteImport } from './routes/admin/events/awd.$id/announcements' + +const Reset_passwordRoute = Reset_passwordRouteImport.update({ + id: '/reset_password', + path: '/reset_password', + getParentRoute: () => rootRouteImport, +} as any) +const ResetRoute = ResetRouteImport.update({ + id: '/reset', + path: '/reset', + getParentRoute: () => rootRouteImport, +} as any) +const RegisterRoute = RegisterRouteImport.update({ + id: '/register', + path: '/register', + getParentRoute: () => rootRouteImport, +} as any) +const ServiceRouteRoute = ServiceRouteRouteImport.update({ + id: '/service', + path: '/service', + getParentRoute: () => rootRouteImport, +} as any) +const AdminRouteRoute = AdminRouteRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const ServiceIndexRoute = ServiceIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ServiceRouteRoute, +} as any) +const AdminIndexRoute = AdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminRouteRoute, +} as any) +const ServiceWeaponsRoute = ServiceWeaponsRouteImport.update({ + id: '/weapons', + path: '/weapons', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceTopRoute = ServiceTopRouteImport.update({ + id: '/top', + path: '/top', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceSolvesRoute = ServiceSolvesRouteImport.update({ + id: '/solves', + path: '/solves', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceProfileRoute = ServiceProfileRouteImport.update({ + id: '/profile', + path: '/profile', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceInstancesRoute = ServiceInstancesRouteImport.update({ + id: '/instances', + path: '/instances', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceGameboxesRoute = ServiceGameboxesRouteImport.update({ + id: '/gameboxes', + path: '/gameboxes', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceAnnouncementsRoute = ServiceAnnouncementsRouteImport.update({ + id: '/announcements', + path: '/announcements', + getParentRoute: () => ServiceRouteRoute, +} as any) +const AdminWeaponsRoute = AdminWeaponsRouteImport.update({ + id: '/weapons', + path: '/weapons', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminVersionRoute = AdminVersionRouteImport.update({ + id: '/version', + path: '/version', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminUsersRoute = AdminUsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminTerminalRoute = AdminTerminalRouteImport.update({ + id: '/terminal', + path: '/terminal', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminSuper_adminsRoute = AdminSuper_adminsRouteImport.update({ + id: '/super_admins', + path: '/super_admins', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminSettingsRoute = AdminSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminScheduled_tasksRoute = AdminScheduled_tasksRouteImport.update({ + id: '/scheduled_tasks', + path: '/scheduled_tasks', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminLogsRoute = AdminLogsRouteImport.update({ + id: '/logs', + path: '/logs', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminDiscussionsRoute = AdminDiscussionsRouteImport.update({ + id: '/discussions', + path: '/discussions', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminDatabaseRoute = AdminDatabaseRouteImport.update({ + id: '/database', + path: '/database', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminDashboardRoute = AdminDashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminChallengesRoute = AdminChallengesRouteImport.update({ + id: '/challenges', + path: '/challenges', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminAnnouncementsRoute = AdminAnnouncementsRouteImport.update({ + id: '/announcements', + path: '/announcements', + getParentRoute: () => AdminRouteRoute, +} as any) +const ServiceDiscussionsRouteRoute = ServiceDiscussionsRouteRouteImport.update({ + id: '/discussions', + path: '/discussions', + getParentRoute: () => ServiceRouteRoute, +} as any) +const AdminDockerRouteRoute = AdminDockerRouteRouteImport.update({ + id: '/docker', + path: '/docker', + getParentRoute: () => AdminRouteRoute, +} as any) +const ServiceWriteupsIndexRoute = ServiceWriteupsIndexRouteImport.update({ + id: '/writeups/', + path: '/writeups/', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceEventsIndexRoute = ServiceEventsIndexRouteImport.update({ + id: '/events/', + path: '/events/', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceDiscussionsIndexRoute = ServiceDiscussionsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ServiceDiscussionsRouteRoute, +} as any) +const ServiceChallengesIndexRoute = ServiceChallengesIndexRouteImport.update({ + id: '/challenges/', + path: '/challenges/', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceChallenge_setsIndexRoute = + ServiceChallenge_setsIndexRouteImport.update({ + id: '/challenge_sets/', + path: '/challenge_sets/', + getParentRoute: () => ServiceRouteRoute, + } as any) +const AdminEventsIndexRoute = AdminEventsIndexRouteImport.update({ + id: '/events/', + path: '/events/', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminDockerIndexRoute = AdminDockerIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminDockerRouteRoute, +} as any) +const AdminChallenge_setsIndexRoute = + AdminChallenge_setsIndexRouteImport.update({ + id: '/challenge_sets/', + path: '/challenge_sets/', + getParentRoute: () => AdminRouteRoute, + } as any) +const ServiceWriteupsIdRoute = ServiceWriteupsIdRouteImport.update({ + id: '/writeups/$id', + path: '/writeups/$id', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceDiscussionsMyRoute = ServiceDiscussionsMyRouteImport.update({ + id: '/my', + path: '/my', + getParentRoute: () => ServiceDiscussionsRouteRoute, +} as any) +const ServiceDiscussionsIdRoute = ServiceDiscussionsIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => ServiceDiscussionsRouteRoute, +} as any) +const AdminDockerNetworksRoute = AdminDockerNetworksRouteImport.update({ + id: '/networks', + path: '/networks', + getParentRoute: () => AdminDockerRouteRoute, +} as any) +const AdminDockerImagesRoute = AdminDockerImagesRouteImport.update({ + id: '/images', + path: '/images', + getParentRoute: () => AdminDockerRouteRoute, +} as any) +const AdminChallenge_setsIdRoute = AdminChallenge_setsIdRouteImport.update({ + id: '/challenge_sets/$id', + path: '/challenge_sets/$id', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminAwdNetworkRoute = AdminAwdNetworkRouteImport.update({ + id: '/awd/network', + path: '/awd/network', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminAwdGameboxesRoute = AdminAwdGameboxesRouteImport.update({ + id: '/awd/gameboxes', + path: '/awd/gameboxes', + getParentRoute: () => AdminRouteRoute, +} as any) +const ServiceChallengesIdRouteRoute = + ServiceChallengesIdRouteRouteImport.update({ + id: '/challenges/$id', + path: '/challenges/$id', + getParentRoute: () => ServiceRouteRoute, + } as any) +const ServiceChallengesIdIndexRoute = + ServiceChallengesIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ServiceChallengesIdRouteRoute, + } as any) +const ServiceChallenge_setsIdIndexRoute = + ServiceChallenge_setsIdIndexRouteImport.update({ + id: '/challenge_sets/$id/', + path: '/challenge_sets/$id/', + getParentRoute: () => ServiceRouteRoute, + } as any) +const ServiceChallengesIdWriteupRoute = + ServiceChallengesIdWriteupRouteImport.update({ + id: '/writeup', + path: '/writeup', + getParentRoute: () => ServiceChallengesIdRouteRoute, + } as any) +const ServiceEventsJeopardyIdRouteRoute = + ServiceEventsJeopardyIdRouteRouteImport.update({ + id: '/events/jeopardy/$id', + path: '/events/jeopardy/$id', + getParentRoute: () => ServiceRouteRoute, + } as any) +const ServiceEventsAwdpIdRouteRoute = + ServiceEventsAwdpIdRouteRouteImport.update({ + id: '/events/awdp/$id', + path: '/events/awdp/$id', + getParentRoute: () => ServiceRouteRoute, + } as any) +const ServiceEventsAwdIdRouteRoute = ServiceEventsAwdIdRouteRouteImport.update({ + id: '/events/awd/$id', + path: '/events/awd/$id', + getParentRoute: () => ServiceRouteRoute, +} as any) +const ServiceAwdpRunsRunIdRouteRoute = + ServiceAwdpRunsRunIdRouteRouteImport.update({ + id: '/awdp/runs/$runId', + path: '/awdp/runs/$runId', + getParentRoute: () => ServiceRouteRoute, + } as any) +const AdminEventsJeopardyIdRouteRoute = + AdminEventsJeopardyIdRouteRouteImport.update({ + id: '/events/jeopardy/$id', + path: '/events/jeopardy/$id', + getParentRoute: () => AdminRouteRoute, + } as any) +const AdminEventsAwdpIdRouteRoute = AdminEventsAwdpIdRouteRouteImport.update({ + id: '/events/awdp/$id', + path: '/events/awdp/$id', + getParentRoute: () => AdminRouteRoute, +} as any) +const AdminEventsAwdIdRouteRoute = AdminEventsAwdIdRouteRouteImport.update({ + id: '/events/awd/$id', + path: '/events/awd/$id', + getParentRoute: () => AdminRouteRoute, +} as any) +const ServiceEventsJeopardyIdIndexRoute = + ServiceEventsJeopardyIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ServiceEventsJeopardyIdRouteRoute, + } as any) +const ServiceEventsAwdpIdIndexRoute = + ServiceEventsAwdpIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ServiceEventsAwdpIdRouteRoute, + } as any) +const ServiceEventsAwdIdIndexRoute = ServiceEventsAwdIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ServiceEventsAwdIdRouteRoute, +} as any) +const ServiceAwdpRunsRunIdIndexRoute = + ServiceAwdpRunsRunIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ServiceAwdpRunsRunIdRouteRoute, + } as any) +const AdminEventsJeopardyIdIndexRoute = + AdminEventsJeopardyIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminEventsJeopardyIdRouteRoute, + } as any) +const AdminEventsAwdpIdIndexRoute = AdminEventsAwdpIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, +} as any) +const AdminEventsAwdIdIndexRoute = AdminEventsAwdIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminEventsAwdIdRouteRoute, +} as any) +const ServiceEventsJeopardyIdTrendRoute = + ServiceEventsJeopardyIdTrendRouteImport.update({ + id: '/trend', + path: '/trend', + getParentRoute: () => ServiceEventsJeopardyIdRouteRoute, + } as any) +const ServiceEventsJeopardyIdScoreboardRoute = + ServiceEventsJeopardyIdScoreboardRouteImport.update({ + id: '/scoreboard', + path: '/scoreboard', + getParentRoute: () => ServiceEventsJeopardyIdRouteRoute, + } as any) +const ServiceEventsJeopardyIdInstancesRoute = + ServiceEventsJeopardyIdInstancesRouteImport.update({ + id: '/instances', + path: '/instances', + getParentRoute: () => ServiceEventsJeopardyIdRouteRoute, + } as any) +const ServiceEventsJeopardyIdChallengesRoute = + ServiceEventsJeopardyIdChallengesRouteImport.update({ + id: '/challenges', + path: '/challenges', + getParentRoute: () => ServiceEventsJeopardyIdRouteRoute, + } as any) +const ServiceEventsJeopardyIdAnnouncementRoute = + ServiceEventsJeopardyIdAnnouncementRouteImport.update({ + id: '/announcement', + path: '/announcement', + getParentRoute: () => ServiceEventsJeopardyIdRouteRoute, + } as any) +const ServiceEventsAwdpIdTrendRoute = + ServiceEventsAwdpIdTrendRouteImport.update({ + id: '/trend', + path: '/trend', + getParentRoute: () => ServiceEventsAwdpIdRouteRoute, + } as any) +const ServiceEventsAwdpIdScoreboardRoute = + ServiceEventsAwdpIdScoreboardRouteImport.update({ + id: '/scoreboard', + path: '/scoreboard', + getParentRoute: () => ServiceEventsAwdpIdRouteRoute, + } as any) +const ServiceEventsAwdpIdRoundsRoute = + ServiceEventsAwdpIdRoundsRouteImport.update({ + id: '/rounds', + path: '/rounds', + getParentRoute: () => ServiceEventsAwdpIdRouteRoute, + } as any) +const ServiceEventsAwdpIdGameboxesRoute = + ServiceEventsAwdpIdGameboxesRouteImport.update({ + id: '/gameboxes', + path: '/gameboxes', + getParentRoute: () => ServiceEventsAwdpIdRouteRoute, + } as any) +const ServiceEventsAwdpIdAnnouncementRoute = + ServiceEventsAwdpIdAnnouncementRouteImport.update({ + id: '/announcement', + path: '/announcement', + getParentRoute: () => ServiceEventsAwdpIdRouteRoute, + } as any) +const ServiceEventsAwdIdWireguardRoute = + ServiceEventsAwdIdWireguardRouteImport.update({ + id: '/wireguard', + path: '/wireguard', + getParentRoute: () => ServiceEventsAwdIdRouteRoute, + } as any) +const ServiceEventsAwdIdSshRoute = ServiceEventsAwdIdSshRouteImport.update({ + id: '/ssh', + path: '/ssh', + getParentRoute: () => ServiceEventsAwdIdRouteRoute, +} as any) +const ServiceEventsAwdIdScoreboardRoute = + ServiceEventsAwdIdScoreboardRouteImport.update({ + id: '/scoreboard', + path: '/scoreboard', + getParentRoute: () => ServiceEventsAwdIdRouteRoute, + } as any) +const ServiceEventsAwdIdGameboxesRoute = + ServiceEventsAwdIdGameboxesRouteImport.update({ + id: '/gameboxes', + path: '/gameboxes', + getParentRoute: () => ServiceEventsAwdIdRouteRoute, + } as any) +const ServiceAwdpRunsRunIdWriteupRoute = + ServiceAwdpRunsRunIdWriteupRouteImport.update({ + id: '/writeup', + path: '/writeup', + getParentRoute: () => ServiceAwdpRunsRunIdRouteRoute, + } as any) +const AdminEventsJeopardyIdWriteupsRoute = + AdminEventsJeopardyIdWriteupsRouteImport.update({ + id: '/writeups', + path: '/writeups', + getParentRoute: () => AdminEventsJeopardyIdRouteRoute, + } as any) +const AdminEventsJeopardyIdUsersRoute = + AdminEventsJeopardyIdUsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => AdminEventsJeopardyIdRouteRoute, + } as any) +const AdminEventsJeopardyIdTeamsRoute = + AdminEventsJeopardyIdTeamsRouteImport.update({ + id: '/teams', + path: '/teams', + getParentRoute: () => AdminEventsJeopardyIdRouteRoute, + } as any) +const AdminEventsJeopardyIdLogsRoute = + AdminEventsJeopardyIdLogsRouteImport.update({ + id: '/logs', + path: '/logs', + getParentRoute: () => AdminEventsJeopardyIdRouteRoute, + } as any) +const AdminEventsJeopardyIdInstanceRoute = + AdminEventsJeopardyIdInstanceRouteImport.update({ + id: '/instance', + path: '/instance', + getParentRoute: () => AdminEventsJeopardyIdRouteRoute, + } as any) +const AdminEventsJeopardyIdData_presentRoute = + AdminEventsJeopardyIdData_presentRouteImport.update({ + id: '/data_present', + path: '/data_present', + getParentRoute: () => AdminEventsJeopardyIdRouteRoute, + } as any) +const AdminEventsJeopardyIdAnnouncementsRoute = + AdminEventsJeopardyIdAnnouncementsRouteImport.update({ + id: '/announcements', + path: '/announcements', + getParentRoute: () => AdminEventsJeopardyIdRouteRoute, + } as any) +const AdminEventsAwdpIdWriteupsRoute = + AdminEventsAwdpIdWriteupsRouteImport.update({ + id: '/writeups', + path: '/writeups', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, + } as any) +const AdminEventsAwdpIdTeamsRoute = AdminEventsAwdpIdTeamsRouteImport.update({ + id: '/teams', + path: '/teams', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, +} as any) +const AdminEventsAwdpIdScoreboardRoute = + AdminEventsAwdpIdScoreboardRouteImport.update({ + id: '/scoreboard', + path: '/scoreboard', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, + } as any) +const AdminEventsAwdpIdOpsRoute = AdminEventsAwdpIdOpsRouteImport.update({ + id: '/ops', + path: '/ops', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, +} as any) +const AdminEventsAwdpIdLogsRoute = AdminEventsAwdpIdLogsRouteImport.update({ + id: '/logs', + path: '/logs', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, +} as any) +const AdminEventsAwdpIdInstanceRoute = + AdminEventsAwdpIdInstanceRouteImport.update({ + id: '/instance', + path: '/instance', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, + } as any) +const AdminEventsAwdpIdGameboxesRoute = + AdminEventsAwdpIdGameboxesRouteImport.update({ + id: '/gameboxes', + path: '/gameboxes', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, + } as any) +const AdminEventsAwdpIdData_presentRoute = + AdminEventsAwdpIdData_presentRouteImport.update({ + id: '/data_present', + path: '/data_present', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, + } as any) +const AdminEventsAwdpIdConfigureRoute = + AdminEventsAwdpIdConfigureRouteImport.update({ + id: '/configure', + path: '/configure', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, + } as any) +const AdminEventsAwdpIdAnnouncementsRoute = + AdminEventsAwdpIdAnnouncementsRouteImport.update({ + id: '/announcements', + path: '/announcements', + getParentRoute: () => AdminEventsAwdpIdRouteRoute, + } as any) +const AdminEventsAwdIdWriteupsRoute = + AdminEventsAwdIdWriteupsRouteImport.update({ + id: '/writeups', + path: '/writeups', + getParentRoute: () => AdminEventsAwdIdRouteRoute, + } as any) +const AdminEventsAwdIdTeamsRoute = AdminEventsAwdIdTeamsRouteImport.update({ + id: '/teams', + path: '/teams', + getParentRoute: () => AdminEventsAwdIdRouteRoute, +} as any) +const AdminEventsAwdIdOpsRoute = AdminEventsAwdIdOpsRouteImport.update({ + id: '/ops', + path: '/ops', + getParentRoute: () => AdminEventsAwdIdRouteRoute, +} as any) +const AdminEventsAwdIdNetworkRoute = AdminEventsAwdIdNetworkRouteImport.update({ + id: '/network', + path: '/network', + getParentRoute: () => AdminEventsAwdIdRouteRoute, +} as any) +const AdminEventsAwdIdLogsRoute = AdminEventsAwdIdLogsRouteImport.update({ + id: '/logs', + path: '/logs', + getParentRoute: () => AdminEventsAwdIdRouteRoute, +} as any) +const AdminEventsAwdIdInstanceRoute = + AdminEventsAwdIdInstanceRouteImport.update({ + id: '/instance', + path: '/instance', + getParentRoute: () => AdminEventsAwdIdRouteRoute, + } as any) +const AdminEventsAwdIdGameboxesRoute = + AdminEventsAwdIdGameboxesRouteImport.update({ + id: '/gameboxes', + path: '/gameboxes', + getParentRoute: () => AdminEventsAwdIdRouteRoute, + } as any) +const AdminEventsAwdIdConfigureRoute = + AdminEventsAwdIdConfigureRouteImport.update({ + id: '/configure', + path: '/configure', + getParentRoute: () => AdminEventsAwdIdRouteRoute, + } as any) +const AdminEventsAwdIdAnnouncementsRoute = + AdminEventsAwdIdAnnouncementsRouteImport.update({ + id: '/announcements', + path: '/announcements', + getParentRoute: () => AdminEventsAwdIdRouteRoute, + } as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/admin': typeof AdminRouteRouteWithChildren + '/service': typeof ServiceRouteRouteWithChildren + '/register': typeof RegisterRoute + '/reset': typeof ResetRoute + '/reset_password': typeof Reset_passwordRoute + '/admin/docker': typeof AdminDockerRouteRouteWithChildren + '/service/discussions': typeof ServiceDiscussionsRouteRouteWithChildren + '/admin/announcements': typeof AdminAnnouncementsRoute + '/admin/challenges': typeof AdminChallengesRoute + '/admin/dashboard': typeof AdminDashboardRoute + '/admin/database': typeof AdminDatabaseRoute + '/admin/discussions': typeof AdminDiscussionsRoute + '/admin/logs': typeof AdminLogsRoute + '/admin/scheduled_tasks': typeof AdminScheduled_tasksRoute + '/admin/settings': typeof AdminSettingsRoute + '/admin/super_admins': typeof AdminSuper_adminsRoute + '/admin/terminal': typeof AdminTerminalRoute + '/admin/users': typeof AdminUsersRoute + '/admin/version': typeof AdminVersionRoute + '/admin/weapons': typeof AdminWeaponsRoute + '/service/announcements': typeof ServiceAnnouncementsRoute + '/service/gameboxes': typeof ServiceGameboxesRoute + '/service/instances': typeof ServiceInstancesRoute + '/service/profile': typeof ServiceProfileRoute + '/service/solves': typeof ServiceSolvesRoute + '/service/top': typeof ServiceTopRoute + '/service/weapons': typeof ServiceWeaponsRoute + '/admin/': typeof AdminIndexRoute + '/service/': typeof ServiceIndexRoute + '/service/challenges/$id': typeof ServiceChallengesIdRouteRouteWithChildren + '/admin/awd/gameboxes': typeof AdminAwdGameboxesRoute + '/admin/awd/network': typeof AdminAwdNetworkRoute + '/admin/challenge_sets/$id': typeof AdminChallenge_setsIdRoute + '/admin/docker/images': typeof AdminDockerImagesRoute + '/admin/docker/networks': typeof AdminDockerNetworksRoute + '/service/discussions/$id': typeof ServiceDiscussionsIdRoute + '/service/discussions/my': typeof ServiceDiscussionsMyRoute + '/service/writeups/$id': typeof ServiceWriteupsIdRoute + '/admin/challenge_sets/': typeof AdminChallenge_setsIndexRoute + '/admin/docker/': typeof AdminDockerIndexRoute + '/admin/events/': typeof AdminEventsIndexRoute + '/service/challenge_sets/': typeof ServiceChallenge_setsIndexRoute + '/service/challenges/': typeof ServiceChallengesIndexRoute + '/service/discussions/': typeof ServiceDiscussionsIndexRoute + '/service/events/': typeof ServiceEventsIndexRoute + '/service/writeups/': typeof ServiceWriteupsIndexRoute + '/admin/events/awd/$id': typeof AdminEventsAwdIdRouteRouteWithChildren + '/admin/events/awdp/$id': typeof AdminEventsAwdpIdRouteRouteWithChildren + '/admin/events/jeopardy/$id': typeof AdminEventsJeopardyIdRouteRouteWithChildren + '/service/awdp/runs/$runId': typeof ServiceAwdpRunsRunIdRouteRouteWithChildren + '/service/events/awd/$id': typeof ServiceEventsAwdIdRouteRouteWithChildren + '/service/events/awdp/$id': typeof ServiceEventsAwdpIdRouteRouteWithChildren + '/service/events/jeopardy/$id': typeof ServiceEventsJeopardyIdRouteRouteWithChildren + '/service/challenges/$id/writeup': typeof ServiceChallengesIdWriteupRoute + '/service/challenge_sets/$id/': typeof ServiceChallenge_setsIdIndexRoute + '/service/challenges/$id/': typeof ServiceChallengesIdIndexRoute + '/admin/events/awd/$id/announcements': typeof AdminEventsAwdIdAnnouncementsRoute + '/admin/events/awd/$id/configure': typeof AdminEventsAwdIdConfigureRoute + '/admin/events/awd/$id/gameboxes': typeof AdminEventsAwdIdGameboxesRoute + '/admin/events/awd/$id/instance': typeof AdminEventsAwdIdInstanceRoute + '/admin/events/awd/$id/logs': typeof AdminEventsAwdIdLogsRoute + '/admin/events/awd/$id/network': typeof AdminEventsAwdIdNetworkRoute + '/admin/events/awd/$id/ops': typeof AdminEventsAwdIdOpsRoute + '/admin/events/awd/$id/teams': typeof AdminEventsAwdIdTeamsRoute + '/admin/events/awd/$id/writeups': typeof AdminEventsAwdIdWriteupsRoute + '/admin/events/awdp/$id/announcements': typeof AdminEventsAwdpIdAnnouncementsRoute + '/admin/events/awdp/$id/configure': typeof AdminEventsAwdpIdConfigureRoute + '/admin/events/awdp/$id/data_present': typeof AdminEventsAwdpIdData_presentRoute + '/admin/events/awdp/$id/gameboxes': typeof AdminEventsAwdpIdGameboxesRoute + '/admin/events/awdp/$id/instance': typeof AdminEventsAwdpIdInstanceRoute + '/admin/events/awdp/$id/logs': typeof AdminEventsAwdpIdLogsRoute + '/admin/events/awdp/$id/ops': typeof AdminEventsAwdpIdOpsRoute + '/admin/events/awdp/$id/scoreboard': typeof AdminEventsAwdpIdScoreboardRoute + '/admin/events/awdp/$id/teams': typeof AdminEventsAwdpIdTeamsRoute + '/admin/events/awdp/$id/writeups': typeof AdminEventsAwdpIdWriteupsRoute + '/admin/events/jeopardy/$id/announcements': typeof AdminEventsJeopardyIdAnnouncementsRoute + '/admin/events/jeopardy/$id/data_present': typeof AdminEventsJeopardyIdData_presentRoute + '/admin/events/jeopardy/$id/instance': typeof AdminEventsJeopardyIdInstanceRoute + '/admin/events/jeopardy/$id/logs': typeof AdminEventsJeopardyIdLogsRoute + '/admin/events/jeopardy/$id/teams': typeof AdminEventsJeopardyIdTeamsRoute + '/admin/events/jeopardy/$id/users': typeof AdminEventsJeopardyIdUsersRoute + '/admin/events/jeopardy/$id/writeups': typeof AdminEventsJeopardyIdWriteupsRoute + '/service/awdp/runs/$runId/writeup': typeof ServiceAwdpRunsRunIdWriteupRoute + '/service/events/awd/$id/gameboxes': typeof ServiceEventsAwdIdGameboxesRoute + '/service/events/awd/$id/scoreboard': typeof ServiceEventsAwdIdScoreboardRoute + '/service/events/awd/$id/ssh': typeof ServiceEventsAwdIdSshRoute + '/service/events/awd/$id/wireguard': typeof ServiceEventsAwdIdWireguardRoute + '/service/events/awdp/$id/announcement': typeof ServiceEventsAwdpIdAnnouncementRoute + '/service/events/awdp/$id/gameboxes': typeof ServiceEventsAwdpIdGameboxesRoute + '/service/events/awdp/$id/rounds': typeof ServiceEventsAwdpIdRoundsRoute + '/service/events/awdp/$id/scoreboard': typeof ServiceEventsAwdpIdScoreboardRoute + '/service/events/awdp/$id/trend': typeof ServiceEventsAwdpIdTrendRoute + '/service/events/jeopardy/$id/announcement': typeof ServiceEventsJeopardyIdAnnouncementRoute + '/service/events/jeopardy/$id/challenges': typeof ServiceEventsJeopardyIdChallengesRoute + '/service/events/jeopardy/$id/instances': typeof ServiceEventsJeopardyIdInstancesRoute + '/service/events/jeopardy/$id/scoreboard': typeof ServiceEventsJeopardyIdScoreboardRoute + '/service/events/jeopardy/$id/trend': typeof ServiceEventsJeopardyIdTrendRoute + '/admin/events/awd/$id/': typeof AdminEventsAwdIdIndexRoute + '/admin/events/awdp/$id/': typeof AdminEventsAwdpIdIndexRoute + '/admin/events/jeopardy/$id/': typeof AdminEventsJeopardyIdIndexRoute + '/service/awdp/runs/$runId/': typeof ServiceAwdpRunsRunIdIndexRoute + '/service/events/awd/$id/': typeof ServiceEventsAwdIdIndexRoute + '/service/events/awdp/$id/': typeof ServiceEventsAwdpIdIndexRoute + '/service/events/jeopardy/$id/': typeof ServiceEventsJeopardyIdIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/register': typeof RegisterRoute + '/reset': typeof ResetRoute + '/reset_password': typeof Reset_passwordRoute + '/admin/announcements': typeof AdminAnnouncementsRoute + '/admin/challenges': typeof AdminChallengesRoute + '/admin/dashboard': typeof AdminDashboardRoute + '/admin/database': typeof AdminDatabaseRoute + '/admin/discussions': typeof AdminDiscussionsRoute + '/admin/logs': typeof AdminLogsRoute + '/admin/scheduled_tasks': typeof AdminScheduled_tasksRoute + '/admin/settings': typeof AdminSettingsRoute + '/admin/super_admins': typeof AdminSuper_adminsRoute + '/admin/terminal': typeof AdminTerminalRoute + '/admin/users': typeof AdminUsersRoute + '/admin/version': typeof AdminVersionRoute + '/admin/weapons': typeof AdminWeaponsRoute + '/service/announcements': typeof ServiceAnnouncementsRoute + '/service/gameboxes': typeof ServiceGameboxesRoute + '/service/instances': typeof ServiceInstancesRoute + '/service/profile': typeof ServiceProfileRoute + '/service/solves': typeof ServiceSolvesRoute + '/service/top': typeof ServiceTopRoute + '/service/weapons': typeof ServiceWeaponsRoute + '/admin': typeof AdminIndexRoute + '/service': typeof ServiceIndexRoute + '/admin/awd/gameboxes': typeof AdminAwdGameboxesRoute + '/admin/awd/network': typeof AdminAwdNetworkRoute + '/admin/challenge_sets/$id': typeof AdminChallenge_setsIdRoute + '/admin/docker/images': typeof AdminDockerImagesRoute + '/admin/docker/networks': typeof AdminDockerNetworksRoute + '/service/discussions/$id': typeof ServiceDiscussionsIdRoute + '/service/discussions/my': typeof ServiceDiscussionsMyRoute + '/service/writeups/$id': typeof ServiceWriteupsIdRoute + '/admin/challenge_sets': typeof AdminChallenge_setsIndexRoute + '/admin/docker': typeof AdminDockerIndexRoute + '/admin/events': typeof AdminEventsIndexRoute + '/service/challenge_sets': typeof ServiceChallenge_setsIndexRoute + '/service/challenges': typeof ServiceChallengesIndexRoute + '/service/discussions': typeof ServiceDiscussionsIndexRoute + '/service/events': typeof ServiceEventsIndexRoute + '/service/writeups': typeof ServiceWriteupsIndexRoute + '/service/challenges/$id/writeup': typeof ServiceChallengesIdWriteupRoute + '/service/challenge_sets/$id': typeof ServiceChallenge_setsIdIndexRoute + '/service/challenges/$id': typeof ServiceChallengesIdIndexRoute + '/admin/events/awd/$id/announcements': typeof AdminEventsAwdIdAnnouncementsRoute + '/admin/events/awd/$id/configure': typeof AdminEventsAwdIdConfigureRoute + '/admin/events/awd/$id/gameboxes': typeof AdminEventsAwdIdGameboxesRoute + '/admin/events/awd/$id/instance': typeof AdminEventsAwdIdInstanceRoute + '/admin/events/awd/$id/logs': typeof AdminEventsAwdIdLogsRoute + '/admin/events/awd/$id/network': typeof AdminEventsAwdIdNetworkRoute + '/admin/events/awd/$id/ops': typeof AdminEventsAwdIdOpsRoute + '/admin/events/awd/$id/teams': typeof AdminEventsAwdIdTeamsRoute + '/admin/events/awd/$id/writeups': typeof AdminEventsAwdIdWriteupsRoute + '/admin/events/awdp/$id/announcements': typeof AdminEventsAwdpIdAnnouncementsRoute + '/admin/events/awdp/$id/configure': typeof AdminEventsAwdpIdConfigureRoute + '/admin/events/awdp/$id/data_present': typeof AdminEventsAwdpIdData_presentRoute + '/admin/events/awdp/$id/gameboxes': typeof AdminEventsAwdpIdGameboxesRoute + '/admin/events/awdp/$id/instance': typeof AdminEventsAwdpIdInstanceRoute + '/admin/events/awdp/$id/logs': typeof AdminEventsAwdpIdLogsRoute + '/admin/events/awdp/$id/ops': typeof AdminEventsAwdpIdOpsRoute + '/admin/events/awdp/$id/scoreboard': typeof AdminEventsAwdpIdScoreboardRoute + '/admin/events/awdp/$id/teams': typeof AdminEventsAwdpIdTeamsRoute + '/admin/events/awdp/$id/writeups': typeof AdminEventsAwdpIdWriteupsRoute + '/admin/events/jeopardy/$id/announcements': typeof AdminEventsJeopardyIdAnnouncementsRoute + '/admin/events/jeopardy/$id/data_present': typeof AdminEventsJeopardyIdData_presentRoute + '/admin/events/jeopardy/$id/instance': typeof AdminEventsJeopardyIdInstanceRoute + '/admin/events/jeopardy/$id/logs': typeof AdminEventsJeopardyIdLogsRoute + '/admin/events/jeopardy/$id/teams': typeof AdminEventsJeopardyIdTeamsRoute + '/admin/events/jeopardy/$id/users': typeof AdminEventsJeopardyIdUsersRoute + '/admin/events/jeopardy/$id/writeups': typeof AdminEventsJeopardyIdWriteupsRoute + '/service/awdp/runs/$runId/writeup': typeof ServiceAwdpRunsRunIdWriteupRoute + '/service/events/awd/$id/gameboxes': typeof ServiceEventsAwdIdGameboxesRoute + '/service/events/awd/$id/scoreboard': typeof ServiceEventsAwdIdScoreboardRoute + '/service/events/awd/$id/ssh': typeof ServiceEventsAwdIdSshRoute + '/service/events/awd/$id/wireguard': typeof ServiceEventsAwdIdWireguardRoute + '/service/events/awdp/$id/announcement': typeof ServiceEventsAwdpIdAnnouncementRoute + '/service/events/awdp/$id/gameboxes': typeof ServiceEventsAwdpIdGameboxesRoute + '/service/events/awdp/$id/rounds': typeof ServiceEventsAwdpIdRoundsRoute + '/service/events/awdp/$id/scoreboard': typeof ServiceEventsAwdpIdScoreboardRoute + '/service/events/awdp/$id/trend': typeof ServiceEventsAwdpIdTrendRoute + '/service/events/jeopardy/$id/announcement': typeof ServiceEventsJeopardyIdAnnouncementRoute + '/service/events/jeopardy/$id/challenges': typeof ServiceEventsJeopardyIdChallengesRoute + '/service/events/jeopardy/$id/instances': typeof ServiceEventsJeopardyIdInstancesRoute + '/service/events/jeopardy/$id/scoreboard': typeof ServiceEventsJeopardyIdScoreboardRoute + '/service/events/jeopardy/$id/trend': typeof ServiceEventsJeopardyIdTrendRoute + '/admin/events/awd/$id': typeof AdminEventsAwdIdIndexRoute + '/admin/events/awdp/$id': typeof AdminEventsAwdpIdIndexRoute + '/admin/events/jeopardy/$id': typeof AdminEventsJeopardyIdIndexRoute + '/service/awdp/runs/$runId': typeof ServiceAwdpRunsRunIdIndexRoute + '/service/events/awd/$id': typeof ServiceEventsAwdIdIndexRoute + '/service/events/awdp/$id': typeof ServiceEventsAwdpIdIndexRoute + '/service/events/jeopardy/$id': typeof ServiceEventsJeopardyIdIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/admin': typeof AdminRouteRouteWithChildren + '/service': typeof ServiceRouteRouteWithChildren + '/register': typeof RegisterRoute + '/reset': typeof ResetRoute + '/reset_password': typeof Reset_passwordRoute + '/admin/docker': typeof AdminDockerRouteRouteWithChildren + '/service/discussions': typeof ServiceDiscussionsRouteRouteWithChildren + '/admin/announcements': typeof AdminAnnouncementsRoute + '/admin/challenges': typeof AdminChallengesRoute + '/admin/dashboard': typeof AdminDashboardRoute + '/admin/database': typeof AdminDatabaseRoute + '/admin/discussions': typeof AdminDiscussionsRoute + '/admin/logs': typeof AdminLogsRoute + '/admin/scheduled_tasks': typeof AdminScheduled_tasksRoute + '/admin/settings': typeof AdminSettingsRoute + '/admin/super_admins': typeof AdminSuper_adminsRoute + '/admin/terminal': typeof AdminTerminalRoute + '/admin/users': typeof AdminUsersRoute + '/admin/version': typeof AdminVersionRoute + '/admin/weapons': typeof AdminWeaponsRoute + '/service/announcements': typeof ServiceAnnouncementsRoute + '/service/gameboxes': typeof ServiceGameboxesRoute + '/service/instances': typeof ServiceInstancesRoute + '/service/profile': typeof ServiceProfileRoute + '/service/solves': typeof ServiceSolvesRoute + '/service/top': typeof ServiceTopRoute + '/service/weapons': typeof ServiceWeaponsRoute + '/admin/': typeof AdminIndexRoute + '/service/': typeof ServiceIndexRoute + '/service/challenges/$id': typeof ServiceChallengesIdRouteRouteWithChildren + '/admin/awd/gameboxes': typeof AdminAwdGameboxesRoute + '/admin/awd/network': typeof AdminAwdNetworkRoute + '/admin/challenge_sets/$id': typeof AdminChallenge_setsIdRoute + '/admin/docker/images': typeof AdminDockerImagesRoute + '/admin/docker/networks': typeof AdminDockerNetworksRoute + '/service/discussions/$id': typeof ServiceDiscussionsIdRoute + '/service/discussions/my': typeof ServiceDiscussionsMyRoute + '/service/writeups/$id': typeof ServiceWriteupsIdRoute + '/admin/challenge_sets/': typeof AdminChallenge_setsIndexRoute + '/admin/docker/': typeof AdminDockerIndexRoute + '/admin/events/': typeof AdminEventsIndexRoute + '/service/challenge_sets/': typeof ServiceChallenge_setsIndexRoute + '/service/challenges/': typeof ServiceChallengesIndexRoute + '/service/discussions/': typeof ServiceDiscussionsIndexRoute + '/service/events/': typeof ServiceEventsIndexRoute + '/service/writeups/': typeof ServiceWriteupsIndexRoute + '/admin/events/awd/$id': typeof AdminEventsAwdIdRouteRouteWithChildren + '/admin/events/awdp/$id': typeof AdminEventsAwdpIdRouteRouteWithChildren + '/admin/events/jeopardy/$id': typeof AdminEventsJeopardyIdRouteRouteWithChildren + '/service/awdp/runs/$runId': typeof ServiceAwdpRunsRunIdRouteRouteWithChildren + '/service/events/awd/$id': typeof ServiceEventsAwdIdRouteRouteWithChildren + '/service/events/awdp/$id': typeof ServiceEventsAwdpIdRouteRouteWithChildren + '/service/events/jeopardy/$id': typeof ServiceEventsJeopardyIdRouteRouteWithChildren + '/service/challenges/$id/writeup': typeof ServiceChallengesIdWriteupRoute + '/service/challenge_sets/$id/': typeof ServiceChallenge_setsIdIndexRoute + '/service/challenges/$id/': typeof ServiceChallengesIdIndexRoute + '/admin/events/awd/$id/announcements': typeof AdminEventsAwdIdAnnouncementsRoute + '/admin/events/awd/$id/configure': typeof AdminEventsAwdIdConfigureRoute + '/admin/events/awd/$id/gameboxes': typeof AdminEventsAwdIdGameboxesRoute + '/admin/events/awd/$id/instance': typeof AdminEventsAwdIdInstanceRoute + '/admin/events/awd/$id/logs': typeof AdminEventsAwdIdLogsRoute + '/admin/events/awd/$id/network': typeof AdminEventsAwdIdNetworkRoute + '/admin/events/awd/$id/ops': typeof AdminEventsAwdIdOpsRoute + '/admin/events/awd/$id/teams': typeof AdminEventsAwdIdTeamsRoute + '/admin/events/awd/$id/writeups': typeof AdminEventsAwdIdWriteupsRoute + '/admin/events/awdp/$id/announcements': typeof AdminEventsAwdpIdAnnouncementsRoute + '/admin/events/awdp/$id/configure': typeof AdminEventsAwdpIdConfigureRoute + '/admin/events/awdp/$id/data_present': typeof AdminEventsAwdpIdData_presentRoute + '/admin/events/awdp/$id/gameboxes': typeof AdminEventsAwdpIdGameboxesRoute + '/admin/events/awdp/$id/instance': typeof AdminEventsAwdpIdInstanceRoute + '/admin/events/awdp/$id/logs': typeof AdminEventsAwdpIdLogsRoute + '/admin/events/awdp/$id/ops': typeof AdminEventsAwdpIdOpsRoute + '/admin/events/awdp/$id/scoreboard': typeof AdminEventsAwdpIdScoreboardRoute + '/admin/events/awdp/$id/teams': typeof AdminEventsAwdpIdTeamsRoute + '/admin/events/awdp/$id/writeups': typeof AdminEventsAwdpIdWriteupsRoute + '/admin/events/jeopardy/$id/announcements': typeof AdminEventsJeopardyIdAnnouncementsRoute + '/admin/events/jeopardy/$id/data_present': typeof AdminEventsJeopardyIdData_presentRoute + '/admin/events/jeopardy/$id/instance': typeof AdminEventsJeopardyIdInstanceRoute + '/admin/events/jeopardy/$id/logs': typeof AdminEventsJeopardyIdLogsRoute + '/admin/events/jeopardy/$id/teams': typeof AdminEventsJeopardyIdTeamsRoute + '/admin/events/jeopardy/$id/users': typeof AdminEventsJeopardyIdUsersRoute + '/admin/events/jeopardy/$id/writeups': typeof AdminEventsJeopardyIdWriteupsRoute + '/service/awdp/runs/$runId/writeup': typeof ServiceAwdpRunsRunIdWriteupRoute + '/service/events/awd/$id/gameboxes': typeof ServiceEventsAwdIdGameboxesRoute + '/service/events/awd/$id/scoreboard': typeof ServiceEventsAwdIdScoreboardRoute + '/service/events/awd/$id/ssh': typeof ServiceEventsAwdIdSshRoute + '/service/events/awd/$id/wireguard': typeof ServiceEventsAwdIdWireguardRoute + '/service/events/awdp/$id/announcement': typeof ServiceEventsAwdpIdAnnouncementRoute + '/service/events/awdp/$id/gameboxes': typeof ServiceEventsAwdpIdGameboxesRoute + '/service/events/awdp/$id/rounds': typeof ServiceEventsAwdpIdRoundsRoute + '/service/events/awdp/$id/scoreboard': typeof ServiceEventsAwdpIdScoreboardRoute + '/service/events/awdp/$id/trend': typeof ServiceEventsAwdpIdTrendRoute + '/service/events/jeopardy/$id/announcement': typeof ServiceEventsJeopardyIdAnnouncementRoute + '/service/events/jeopardy/$id/challenges': typeof ServiceEventsJeopardyIdChallengesRoute + '/service/events/jeopardy/$id/instances': typeof ServiceEventsJeopardyIdInstancesRoute + '/service/events/jeopardy/$id/scoreboard': typeof ServiceEventsJeopardyIdScoreboardRoute + '/service/events/jeopardy/$id/trend': typeof ServiceEventsJeopardyIdTrendRoute + '/admin/events/awd/$id/': typeof AdminEventsAwdIdIndexRoute + '/admin/events/awdp/$id/': typeof AdminEventsAwdpIdIndexRoute + '/admin/events/jeopardy/$id/': typeof AdminEventsJeopardyIdIndexRoute + '/service/awdp/runs/$runId/': typeof ServiceAwdpRunsRunIdIndexRoute + '/service/events/awd/$id/': typeof ServiceEventsAwdIdIndexRoute + '/service/events/awdp/$id/': typeof ServiceEventsAwdpIdIndexRoute + '/service/events/jeopardy/$id/': typeof ServiceEventsJeopardyIdIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/admin' + | '/service' + | '/register' + | '/reset' + | '/reset_password' + | '/admin/docker' + | '/service/discussions' + | '/admin/announcements' + | '/admin/challenges' + | '/admin/dashboard' + | '/admin/database' + | '/admin/discussions' + | '/admin/logs' + | '/admin/scheduled_tasks' + | '/admin/settings' + | '/admin/super_admins' + | '/admin/terminal' + | '/admin/users' + | '/admin/version' + | '/admin/weapons' + | '/service/announcements' + | '/service/gameboxes' + | '/service/instances' + | '/service/profile' + | '/service/solves' + | '/service/top' + | '/service/weapons' + | '/admin/' + | '/service/' + | '/service/challenges/$id' + | '/admin/awd/gameboxes' + | '/admin/awd/network' + | '/admin/challenge_sets/$id' + | '/admin/docker/images' + | '/admin/docker/networks' + | '/service/discussions/$id' + | '/service/discussions/my' + | '/service/writeups/$id' + | '/admin/challenge_sets/' + | '/admin/docker/' + | '/admin/events/' + | '/service/challenge_sets/' + | '/service/challenges/' + | '/service/discussions/' + | '/service/events/' + | '/service/writeups/' + | '/admin/events/awd/$id' + | '/admin/events/awdp/$id' + | '/admin/events/jeopardy/$id' + | '/service/awdp/runs/$runId' + | '/service/events/awd/$id' + | '/service/events/awdp/$id' + | '/service/events/jeopardy/$id' + | '/service/challenges/$id/writeup' + | '/service/challenge_sets/$id/' + | '/service/challenges/$id/' + | '/admin/events/awd/$id/announcements' + | '/admin/events/awd/$id/configure' + | '/admin/events/awd/$id/gameboxes' + | '/admin/events/awd/$id/instance' + | '/admin/events/awd/$id/logs' + | '/admin/events/awd/$id/network' + | '/admin/events/awd/$id/ops' + | '/admin/events/awd/$id/teams' + | '/admin/events/awd/$id/writeups' + | '/admin/events/awdp/$id/announcements' + | '/admin/events/awdp/$id/configure' + | '/admin/events/awdp/$id/data_present' + | '/admin/events/awdp/$id/gameboxes' + | '/admin/events/awdp/$id/instance' + | '/admin/events/awdp/$id/logs' + | '/admin/events/awdp/$id/ops' + | '/admin/events/awdp/$id/scoreboard' + | '/admin/events/awdp/$id/teams' + | '/admin/events/awdp/$id/writeups' + | '/admin/events/jeopardy/$id/announcements' + | '/admin/events/jeopardy/$id/data_present' + | '/admin/events/jeopardy/$id/instance' + | '/admin/events/jeopardy/$id/logs' + | '/admin/events/jeopardy/$id/teams' + | '/admin/events/jeopardy/$id/users' + | '/admin/events/jeopardy/$id/writeups' + | '/service/awdp/runs/$runId/writeup' + | '/service/events/awd/$id/gameboxes' + | '/service/events/awd/$id/scoreboard' + | '/service/events/awd/$id/ssh' + | '/service/events/awd/$id/wireguard' + | '/service/events/awdp/$id/announcement' + | '/service/events/awdp/$id/gameboxes' + | '/service/events/awdp/$id/rounds' + | '/service/events/awdp/$id/scoreboard' + | '/service/events/awdp/$id/trend' + | '/service/events/jeopardy/$id/announcement' + | '/service/events/jeopardy/$id/challenges' + | '/service/events/jeopardy/$id/instances' + | '/service/events/jeopardy/$id/scoreboard' + | '/service/events/jeopardy/$id/trend' + | '/admin/events/awd/$id/' + | '/admin/events/awdp/$id/' + | '/admin/events/jeopardy/$id/' + | '/service/awdp/runs/$runId/' + | '/service/events/awd/$id/' + | '/service/events/awdp/$id/' + | '/service/events/jeopardy/$id/' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/register' + | '/reset' + | '/reset_password' + | '/admin/announcements' + | '/admin/challenges' + | '/admin/dashboard' + | '/admin/database' + | '/admin/discussions' + | '/admin/logs' + | '/admin/scheduled_tasks' + | '/admin/settings' + | '/admin/super_admins' + | '/admin/terminal' + | '/admin/users' + | '/admin/version' + | '/admin/weapons' + | '/service/announcements' + | '/service/gameboxes' + | '/service/instances' + | '/service/profile' + | '/service/solves' + | '/service/top' + | '/service/weapons' + | '/admin' + | '/service' + | '/admin/awd/gameboxes' + | '/admin/awd/network' + | '/admin/challenge_sets/$id' + | '/admin/docker/images' + | '/admin/docker/networks' + | '/service/discussions/$id' + | '/service/discussions/my' + | '/service/writeups/$id' + | '/admin/challenge_sets' + | '/admin/docker' + | '/admin/events' + | '/service/challenge_sets' + | '/service/challenges' + | '/service/discussions' + | '/service/events' + | '/service/writeups' + | '/service/challenges/$id/writeup' + | '/service/challenge_sets/$id' + | '/service/challenges/$id' + | '/admin/events/awd/$id/announcements' + | '/admin/events/awd/$id/configure' + | '/admin/events/awd/$id/gameboxes' + | '/admin/events/awd/$id/instance' + | '/admin/events/awd/$id/logs' + | '/admin/events/awd/$id/network' + | '/admin/events/awd/$id/ops' + | '/admin/events/awd/$id/teams' + | '/admin/events/awd/$id/writeups' + | '/admin/events/awdp/$id/announcements' + | '/admin/events/awdp/$id/configure' + | '/admin/events/awdp/$id/data_present' + | '/admin/events/awdp/$id/gameboxes' + | '/admin/events/awdp/$id/instance' + | '/admin/events/awdp/$id/logs' + | '/admin/events/awdp/$id/ops' + | '/admin/events/awdp/$id/scoreboard' + | '/admin/events/awdp/$id/teams' + | '/admin/events/awdp/$id/writeups' + | '/admin/events/jeopardy/$id/announcements' + | '/admin/events/jeopardy/$id/data_present' + | '/admin/events/jeopardy/$id/instance' + | '/admin/events/jeopardy/$id/logs' + | '/admin/events/jeopardy/$id/teams' + | '/admin/events/jeopardy/$id/users' + | '/admin/events/jeopardy/$id/writeups' + | '/service/awdp/runs/$runId/writeup' + | '/service/events/awd/$id/gameboxes' + | '/service/events/awd/$id/scoreboard' + | '/service/events/awd/$id/ssh' + | '/service/events/awd/$id/wireguard' + | '/service/events/awdp/$id/announcement' + | '/service/events/awdp/$id/gameboxes' + | '/service/events/awdp/$id/rounds' + | '/service/events/awdp/$id/scoreboard' + | '/service/events/awdp/$id/trend' + | '/service/events/jeopardy/$id/announcement' + | '/service/events/jeopardy/$id/challenges' + | '/service/events/jeopardy/$id/instances' + | '/service/events/jeopardy/$id/scoreboard' + | '/service/events/jeopardy/$id/trend' + | '/admin/events/awd/$id' + | '/admin/events/awdp/$id' + | '/admin/events/jeopardy/$id' + | '/service/awdp/runs/$runId' + | '/service/events/awd/$id' + | '/service/events/awdp/$id' + | '/service/events/jeopardy/$id' + id: + | '__root__' + | '/' + | '/admin' + | '/service' + | '/register' + | '/reset' + | '/reset_password' + | '/admin/docker' + | '/service/discussions' + | '/admin/announcements' + | '/admin/challenges' + | '/admin/dashboard' + | '/admin/database' + | '/admin/discussions' + | '/admin/logs' + | '/admin/scheduled_tasks' + | '/admin/settings' + | '/admin/super_admins' + | '/admin/terminal' + | '/admin/users' + | '/admin/version' + | '/admin/weapons' + | '/service/announcements' + | '/service/gameboxes' + | '/service/instances' + | '/service/profile' + | '/service/solves' + | '/service/top' + | '/service/weapons' + | '/admin/' + | '/service/' + | '/service/challenges/$id' + | '/admin/awd/gameboxes' + | '/admin/awd/network' + | '/admin/challenge_sets/$id' + | '/admin/docker/images' + | '/admin/docker/networks' + | '/service/discussions/$id' + | '/service/discussions/my' + | '/service/writeups/$id' + | '/admin/challenge_sets/' + | '/admin/docker/' + | '/admin/events/' + | '/service/challenge_sets/' + | '/service/challenges/' + | '/service/discussions/' + | '/service/events/' + | '/service/writeups/' + | '/admin/events/awd/$id' + | '/admin/events/awdp/$id' + | '/admin/events/jeopardy/$id' + | '/service/awdp/runs/$runId' + | '/service/events/awd/$id' + | '/service/events/awdp/$id' + | '/service/events/jeopardy/$id' + | '/service/challenges/$id/writeup' + | '/service/challenge_sets/$id/' + | '/service/challenges/$id/' + | '/admin/events/awd/$id/announcements' + | '/admin/events/awd/$id/configure' + | '/admin/events/awd/$id/gameboxes' + | '/admin/events/awd/$id/instance' + | '/admin/events/awd/$id/logs' + | '/admin/events/awd/$id/network' + | '/admin/events/awd/$id/ops' + | '/admin/events/awd/$id/teams' + | '/admin/events/awd/$id/writeups' + | '/admin/events/awdp/$id/announcements' + | '/admin/events/awdp/$id/configure' + | '/admin/events/awdp/$id/data_present' + | '/admin/events/awdp/$id/gameboxes' + | '/admin/events/awdp/$id/instance' + | '/admin/events/awdp/$id/logs' + | '/admin/events/awdp/$id/ops' + | '/admin/events/awdp/$id/scoreboard' + | '/admin/events/awdp/$id/teams' + | '/admin/events/awdp/$id/writeups' + | '/admin/events/jeopardy/$id/announcements' + | '/admin/events/jeopardy/$id/data_present' + | '/admin/events/jeopardy/$id/instance' + | '/admin/events/jeopardy/$id/logs' + | '/admin/events/jeopardy/$id/teams' + | '/admin/events/jeopardy/$id/users' + | '/admin/events/jeopardy/$id/writeups' + | '/service/awdp/runs/$runId/writeup' + | '/service/events/awd/$id/gameboxes' + | '/service/events/awd/$id/scoreboard' + | '/service/events/awd/$id/ssh' + | '/service/events/awd/$id/wireguard' + | '/service/events/awdp/$id/announcement' + | '/service/events/awdp/$id/gameboxes' + | '/service/events/awdp/$id/rounds' + | '/service/events/awdp/$id/scoreboard' + | '/service/events/awdp/$id/trend' + | '/service/events/jeopardy/$id/announcement' + | '/service/events/jeopardy/$id/challenges' + | '/service/events/jeopardy/$id/instances' + | '/service/events/jeopardy/$id/scoreboard' + | '/service/events/jeopardy/$id/trend' + | '/admin/events/awd/$id/' + | '/admin/events/awdp/$id/' + | '/admin/events/jeopardy/$id/' + | '/service/awdp/runs/$runId/' + | '/service/events/awd/$id/' + | '/service/events/awdp/$id/' + | '/service/events/jeopardy/$id/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AdminRouteRoute: typeof AdminRouteRouteWithChildren + ServiceRouteRoute: typeof ServiceRouteRouteWithChildren + RegisterRoute: typeof RegisterRoute + ResetRoute: typeof ResetRoute + Reset_passwordRoute: typeof Reset_passwordRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/reset_password': { + id: '/reset_password' + path: '/reset_password' + fullPath: '/reset_password' + preLoaderRoute: typeof Reset_passwordRouteImport + parentRoute: typeof rootRouteImport + } + '/reset': { + id: '/reset' + path: '/reset' + fullPath: '/reset' + preLoaderRoute: typeof ResetRouteImport + parentRoute: typeof rootRouteImport + } + '/register': { + id: '/register' + path: '/register' + fullPath: '/register' + preLoaderRoute: typeof RegisterRouteImport + parentRoute: typeof rootRouteImport + } + '/service': { + id: '/service' + path: '/service' + fullPath: '/service' + preLoaderRoute: typeof ServiceRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/admin': { + id: '/admin' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AdminRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/service/': { + id: '/service/' + path: '/' + fullPath: '/service/' + preLoaderRoute: typeof ServiceIndexRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/admin/': { + id: '/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof AdminIndexRouteImport + parentRoute: typeof AdminRouteRoute + } + '/service/weapons': { + id: '/service/weapons' + path: '/weapons' + fullPath: '/service/weapons' + preLoaderRoute: typeof ServiceWeaponsRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/top': { + id: '/service/top' + path: '/top' + fullPath: '/service/top' + preLoaderRoute: typeof ServiceTopRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/solves': { + id: '/service/solves' + path: '/solves' + fullPath: '/service/solves' + preLoaderRoute: typeof ServiceSolvesRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/profile': { + id: '/service/profile' + path: '/profile' + fullPath: '/service/profile' + preLoaderRoute: typeof ServiceProfileRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/instances': { + id: '/service/instances' + path: '/instances' + fullPath: '/service/instances' + preLoaderRoute: typeof ServiceInstancesRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/gameboxes': { + id: '/service/gameboxes' + path: '/gameboxes' + fullPath: '/service/gameboxes' + preLoaderRoute: typeof ServiceGameboxesRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/announcements': { + id: '/service/announcements' + path: '/announcements' + fullPath: '/service/announcements' + preLoaderRoute: typeof ServiceAnnouncementsRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/admin/weapons': { + id: '/admin/weapons' + path: '/weapons' + fullPath: '/admin/weapons' + preLoaderRoute: typeof AdminWeaponsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/version': { + id: '/admin/version' + path: '/version' + fullPath: '/admin/version' + preLoaderRoute: typeof AdminVersionRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/users': { + id: '/admin/users' + path: '/users' + fullPath: '/admin/users' + preLoaderRoute: typeof AdminUsersRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/terminal': { + id: '/admin/terminal' + path: '/terminal' + fullPath: '/admin/terminal' + preLoaderRoute: typeof AdminTerminalRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/super_admins': { + id: '/admin/super_admins' + path: '/super_admins' + fullPath: '/admin/super_admins' + preLoaderRoute: typeof AdminSuper_adminsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/settings': { + id: '/admin/settings' + path: '/settings' + fullPath: '/admin/settings' + preLoaderRoute: typeof AdminSettingsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/scheduled_tasks': { + id: '/admin/scheduled_tasks' + path: '/scheduled_tasks' + fullPath: '/admin/scheduled_tasks' + preLoaderRoute: typeof AdminScheduled_tasksRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/logs': { + id: '/admin/logs' + path: '/logs' + fullPath: '/admin/logs' + preLoaderRoute: typeof AdminLogsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/discussions': { + id: '/admin/discussions' + path: '/discussions' + fullPath: '/admin/discussions' + preLoaderRoute: typeof AdminDiscussionsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/database': { + id: '/admin/database' + path: '/database' + fullPath: '/admin/database' + preLoaderRoute: typeof AdminDatabaseRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/dashboard': { + id: '/admin/dashboard' + path: '/dashboard' + fullPath: '/admin/dashboard' + preLoaderRoute: typeof AdminDashboardRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/challenges': { + id: '/admin/challenges' + path: '/challenges' + fullPath: '/admin/challenges' + preLoaderRoute: typeof AdminChallengesRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/announcements': { + id: '/admin/announcements' + path: '/announcements' + fullPath: '/admin/announcements' + preLoaderRoute: typeof AdminAnnouncementsRouteImport + parentRoute: typeof AdminRouteRoute + } + '/service/discussions': { + id: '/service/discussions' + path: '/discussions' + fullPath: '/service/discussions' + preLoaderRoute: typeof ServiceDiscussionsRouteRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/admin/docker': { + id: '/admin/docker' + path: '/docker' + fullPath: '/admin/docker' + preLoaderRoute: typeof AdminDockerRouteRouteImport + parentRoute: typeof AdminRouteRoute + } + '/service/writeups/': { + id: '/service/writeups/' + path: '/writeups' + fullPath: '/service/writeups/' + preLoaderRoute: typeof ServiceWriteupsIndexRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/events/': { + id: '/service/events/' + path: '/events' + fullPath: '/service/events/' + preLoaderRoute: typeof ServiceEventsIndexRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/discussions/': { + id: '/service/discussions/' + path: '/' + fullPath: '/service/discussions/' + preLoaderRoute: typeof ServiceDiscussionsIndexRouteImport + parentRoute: typeof ServiceDiscussionsRouteRoute + } + '/service/challenges/': { + id: '/service/challenges/' + path: '/challenges' + fullPath: '/service/challenges/' + preLoaderRoute: typeof ServiceChallengesIndexRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/challenge_sets/': { + id: '/service/challenge_sets/' + path: '/challenge_sets' + fullPath: '/service/challenge_sets/' + preLoaderRoute: typeof ServiceChallenge_setsIndexRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/admin/events/': { + id: '/admin/events/' + path: '/events' + fullPath: '/admin/events/' + preLoaderRoute: typeof AdminEventsIndexRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/docker/': { + id: '/admin/docker/' + path: '/' + fullPath: '/admin/docker/' + preLoaderRoute: typeof AdminDockerIndexRouteImport + parentRoute: typeof AdminDockerRouteRoute + } + '/admin/challenge_sets/': { + id: '/admin/challenge_sets/' + path: '/challenge_sets' + fullPath: '/admin/challenge_sets/' + preLoaderRoute: typeof AdminChallenge_setsIndexRouteImport + parentRoute: typeof AdminRouteRoute + } + '/service/writeups/$id': { + id: '/service/writeups/$id' + path: '/writeups/$id' + fullPath: '/service/writeups/$id' + preLoaderRoute: typeof ServiceWriteupsIdRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/discussions/my': { + id: '/service/discussions/my' + path: '/my' + fullPath: '/service/discussions/my' + preLoaderRoute: typeof ServiceDiscussionsMyRouteImport + parentRoute: typeof ServiceDiscussionsRouteRoute + } + '/service/discussions/$id': { + id: '/service/discussions/$id' + path: '/$id' + fullPath: '/service/discussions/$id' + preLoaderRoute: typeof ServiceDiscussionsIdRouteImport + parentRoute: typeof ServiceDiscussionsRouteRoute + } + '/admin/docker/networks': { + id: '/admin/docker/networks' + path: '/networks' + fullPath: '/admin/docker/networks' + preLoaderRoute: typeof AdminDockerNetworksRouteImport + parentRoute: typeof AdminDockerRouteRoute + } + '/admin/docker/images': { + id: '/admin/docker/images' + path: '/images' + fullPath: '/admin/docker/images' + preLoaderRoute: typeof AdminDockerImagesRouteImport + parentRoute: typeof AdminDockerRouteRoute + } + '/admin/challenge_sets/$id': { + id: '/admin/challenge_sets/$id' + path: '/challenge_sets/$id' + fullPath: '/admin/challenge_sets/$id' + preLoaderRoute: typeof AdminChallenge_setsIdRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/awd/network': { + id: '/admin/awd/network' + path: '/awd/network' + fullPath: '/admin/awd/network' + preLoaderRoute: typeof AdminAwdNetworkRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/awd/gameboxes': { + id: '/admin/awd/gameboxes' + path: '/awd/gameboxes' + fullPath: '/admin/awd/gameboxes' + preLoaderRoute: typeof AdminAwdGameboxesRouteImport + parentRoute: typeof AdminRouteRoute + } + '/service/challenges/$id': { + id: '/service/challenges/$id' + path: '/challenges/$id' + fullPath: '/service/challenges/$id' + preLoaderRoute: typeof ServiceChallengesIdRouteRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/challenges/$id/': { + id: '/service/challenges/$id/' + path: '/' + fullPath: '/service/challenges/$id/' + preLoaderRoute: typeof ServiceChallengesIdIndexRouteImport + parentRoute: typeof ServiceChallengesIdRouteRoute + } + '/service/challenge_sets/$id/': { + id: '/service/challenge_sets/$id/' + path: '/challenge_sets/$id' + fullPath: '/service/challenge_sets/$id/' + preLoaderRoute: typeof ServiceChallenge_setsIdIndexRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/challenges/$id/writeup': { + id: '/service/challenges/$id/writeup' + path: '/writeup' + fullPath: '/service/challenges/$id/writeup' + preLoaderRoute: typeof ServiceChallengesIdWriteupRouteImport + parentRoute: typeof ServiceChallengesIdRouteRoute + } + '/service/events/jeopardy/$id': { + id: '/service/events/jeopardy/$id' + path: '/events/jeopardy/$id' + fullPath: '/service/events/jeopardy/$id' + preLoaderRoute: typeof ServiceEventsJeopardyIdRouteRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/events/awdp/$id': { + id: '/service/events/awdp/$id' + path: '/events/awdp/$id' + fullPath: '/service/events/awdp/$id' + preLoaderRoute: typeof ServiceEventsAwdpIdRouteRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/events/awd/$id': { + id: '/service/events/awd/$id' + path: '/events/awd/$id' + fullPath: '/service/events/awd/$id' + preLoaderRoute: typeof ServiceEventsAwdIdRouteRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/service/awdp/runs/$runId': { + id: '/service/awdp/runs/$runId' + path: '/awdp/runs/$runId' + fullPath: '/service/awdp/runs/$runId' + preLoaderRoute: typeof ServiceAwdpRunsRunIdRouteRouteImport + parentRoute: typeof ServiceRouteRoute + } + '/admin/events/jeopardy/$id': { + id: '/admin/events/jeopardy/$id' + path: '/events/jeopardy/$id' + fullPath: '/admin/events/jeopardy/$id' + preLoaderRoute: typeof AdminEventsJeopardyIdRouteRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/events/awdp/$id': { + id: '/admin/events/awdp/$id' + path: '/events/awdp/$id' + fullPath: '/admin/events/awdp/$id' + preLoaderRoute: typeof AdminEventsAwdpIdRouteRouteImport + parentRoute: typeof AdminRouteRoute + } + '/admin/events/awd/$id': { + id: '/admin/events/awd/$id' + path: '/events/awd/$id' + fullPath: '/admin/events/awd/$id' + preLoaderRoute: typeof AdminEventsAwdIdRouteRouteImport + parentRoute: typeof AdminRouteRoute + } + '/service/events/jeopardy/$id/': { + id: '/service/events/jeopardy/$id/' + path: '/' + fullPath: '/service/events/jeopardy/$id/' + preLoaderRoute: typeof ServiceEventsJeopardyIdIndexRouteImport + parentRoute: typeof ServiceEventsJeopardyIdRouteRoute + } + '/service/events/awdp/$id/': { + id: '/service/events/awdp/$id/' + path: '/' + fullPath: '/service/events/awdp/$id/' + preLoaderRoute: typeof ServiceEventsAwdpIdIndexRouteImport + parentRoute: typeof ServiceEventsAwdpIdRouteRoute + } + '/service/events/awd/$id/': { + id: '/service/events/awd/$id/' + path: '/' + fullPath: '/service/events/awd/$id/' + preLoaderRoute: typeof ServiceEventsAwdIdIndexRouteImport + parentRoute: typeof ServiceEventsAwdIdRouteRoute + } + '/service/awdp/runs/$runId/': { + id: '/service/awdp/runs/$runId/' + path: '/' + fullPath: '/service/awdp/runs/$runId/' + preLoaderRoute: typeof ServiceAwdpRunsRunIdIndexRouteImport + parentRoute: typeof ServiceAwdpRunsRunIdRouteRoute + } + '/admin/events/jeopardy/$id/': { + id: '/admin/events/jeopardy/$id/' + path: '/' + fullPath: '/admin/events/jeopardy/$id/' + preLoaderRoute: typeof AdminEventsJeopardyIdIndexRouteImport + parentRoute: typeof AdminEventsJeopardyIdRouteRoute + } + '/admin/events/awdp/$id/': { + id: '/admin/events/awdp/$id/' + path: '/' + fullPath: '/admin/events/awdp/$id/' + preLoaderRoute: typeof AdminEventsAwdpIdIndexRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awd/$id/': { + id: '/admin/events/awd/$id/' + path: '/' + fullPath: '/admin/events/awd/$id/' + preLoaderRoute: typeof AdminEventsAwdIdIndexRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/service/events/jeopardy/$id/trend': { + id: '/service/events/jeopardy/$id/trend' + path: '/trend' + fullPath: '/service/events/jeopardy/$id/trend' + preLoaderRoute: typeof ServiceEventsJeopardyIdTrendRouteImport + parentRoute: typeof ServiceEventsJeopardyIdRouteRoute + } + '/service/events/jeopardy/$id/scoreboard': { + id: '/service/events/jeopardy/$id/scoreboard' + path: '/scoreboard' + fullPath: '/service/events/jeopardy/$id/scoreboard' + preLoaderRoute: typeof ServiceEventsJeopardyIdScoreboardRouteImport + parentRoute: typeof ServiceEventsJeopardyIdRouteRoute + } + '/service/events/jeopardy/$id/instances': { + id: '/service/events/jeopardy/$id/instances' + path: '/instances' + fullPath: '/service/events/jeopardy/$id/instances' + preLoaderRoute: typeof ServiceEventsJeopardyIdInstancesRouteImport + parentRoute: typeof ServiceEventsJeopardyIdRouteRoute + } + '/service/events/jeopardy/$id/challenges': { + id: '/service/events/jeopardy/$id/challenges' + path: '/challenges' + fullPath: '/service/events/jeopardy/$id/challenges' + preLoaderRoute: typeof ServiceEventsJeopardyIdChallengesRouteImport + parentRoute: typeof ServiceEventsJeopardyIdRouteRoute + } + '/service/events/jeopardy/$id/announcement': { + id: '/service/events/jeopardy/$id/announcement' + path: '/announcement' + fullPath: '/service/events/jeopardy/$id/announcement' + preLoaderRoute: typeof ServiceEventsJeopardyIdAnnouncementRouteImport + parentRoute: typeof ServiceEventsJeopardyIdRouteRoute + } + '/service/events/awdp/$id/trend': { + id: '/service/events/awdp/$id/trend' + path: '/trend' + fullPath: '/service/events/awdp/$id/trend' + preLoaderRoute: typeof ServiceEventsAwdpIdTrendRouteImport + parentRoute: typeof ServiceEventsAwdpIdRouteRoute + } + '/service/events/awdp/$id/scoreboard': { + id: '/service/events/awdp/$id/scoreboard' + path: '/scoreboard' + fullPath: '/service/events/awdp/$id/scoreboard' + preLoaderRoute: typeof ServiceEventsAwdpIdScoreboardRouteImport + parentRoute: typeof ServiceEventsAwdpIdRouteRoute + } + '/service/events/awdp/$id/rounds': { + id: '/service/events/awdp/$id/rounds' + path: '/rounds' + fullPath: '/service/events/awdp/$id/rounds' + preLoaderRoute: typeof ServiceEventsAwdpIdRoundsRouteImport + parentRoute: typeof ServiceEventsAwdpIdRouteRoute + } + '/service/events/awdp/$id/gameboxes': { + id: '/service/events/awdp/$id/gameboxes' + path: '/gameboxes' + fullPath: '/service/events/awdp/$id/gameboxes' + preLoaderRoute: typeof ServiceEventsAwdpIdGameboxesRouteImport + parentRoute: typeof ServiceEventsAwdpIdRouteRoute + } + '/service/events/awdp/$id/announcement': { + id: '/service/events/awdp/$id/announcement' + path: '/announcement' + fullPath: '/service/events/awdp/$id/announcement' + preLoaderRoute: typeof ServiceEventsAwdpIdAnnouncementRouteImport + parentRoute: typeof ServiceEventsAwdpIdRouteRoute + } + '/service/events/awd/$id/wireguard': { + id: '/service/events/awd/$id/wireguard' + path: '/wireguard' + fullPath: '/service/events/awd/$id/wireguard' + preLoaderRoute: typeof ServiceEventsAwdIdWireguardRouteImport + parentRoute: typeof ServiceEventsAwdIdRouteRoute + } + '/service/events/awd/$id/ssh': { + id: '/service/events/awd/$id/ssh' + path: '/ssh' + fullPath: '/service/events/awd/$id/ssh' + preLoaderRoute: typeof ServiceEventsAwdIdSshRouteImport + parentRoute: typeof ServiceEventsAwdIdRouteRoute + } + '/service/events/awd/$id/scoreboard': { + id: '/service/events/awd/$id/scoreboard' + path: '/scoreboard' + fullPath: '/service/events/awd/$id/scoreboard' + preLoaderRoute: typeof ServiceEventsAwdIdScoreboardRouteImport + parentRoute: typeof ServiceEventsAwdIdRouteRoute + } + '/service/events/awd/$id/gameboxes': { + id: '/service/events/awd/$id/gameboxes' + path: '/gameboxes' + fullPath: '/service/events/awd/$id/gameboxes' + preLoaderRoute: typeof ServiceEventsAwdIdGameboxesRouteImport + parentRoute: typeof ServiceEventsAwdIdRouteRoute + } + '/service/awdp/runs/$runId/writeup': { + id: '/service/awdp/runs/$runId/writeup' + path: '/writeup' + fullPath: '/service/awdp/runs/$runId/writeup' + preLoaderRoute: typeof ServiceAwdpRunsRunIdWriteupRouteImport + parentRoute: typeof ServiceAwdpRunsRunIdRouteRoute + } + '/admin/events/jeopardy/$id/writeups': { + id: '/admin/events/jeopardy/$id/writeups' + path: '/writeups' + fullPath: '/admin/events/jeopardy/$id/writeups' + preLoaderRoute: typeof AdminEventsJeopardyIdWriteupsRouteImport + parentRoute: typeof AdminEventsJeopardyIdRouteRoute + } + '/admin/events/jeopardy/$id/users': { + id: '/admin/events/jeopardy/$id/users' + path: '/users' + fullPath: '/admin/events/jeopardy/$id/users' + preLoaderRoute: typeof AdminEventsJeopardyIdUsersRouteImport + parentRoute: typeof AdminEventsJeopardyIdRouteRoute + } + '/admin/events/jeopardy/$id/teams': { + id: '/admin/events/jeopardy/$id/teams' + path: '/teams' + fullPath: '/admin/events/jeopardy/$id/teams' + preLoaderRoute: typeof AdminEventsJeopardyIdTeamsRouteImport + parentRoute: typeof AdminEventsJeopardyIdRouteRoute + } + '/admin/events/jeopardy/$id/logs': { + id: '/admin/events/jeopardy/$id/logs' + path: '/logs' + fullPath: '/admin/events/jeopardy/$id/logs' + preLoaderRoute: typeof AdminEventsJeopardyIdLogsRouteImport + parentRoute: typeof AdminEventsJeopardyIdRouteRoute + } + '/admin/events/jeopardy/$id/instance': { + id: '/admin/events/jeopardy/$id/instance' + path: '/instance' + fullPath: '/admin/events/jeopardy/$id/instance' + preLoaderRoute: typeof AdminEventsJeopardyIdInstanceRouteImport + parentRoute: typeof AdminEventsJeopardyIdRouteRoute + } + '/admin/events/jeopardy/$id/data_present': { + id: '/admin/events/jeopardy/$id/data_present' + path: '/data_present' + fullPath: '/admin/events/jeopardy/$id/data_present' + preLoaderRoute: typeof AdminEventsJeopardyIdData_presentRouteImport + parentRoute: typeof AdminEventsJeopardyIdRouteRoute + } + '/admin/events/jeopardy/$id/announcements': { + id: '/admin/events/jeopardy/$id/announcements' + path: '/announcements' + fullPath: '/admin/events/jeopardy/$id/announcements' + preLoaderRoute: typeof AdminEventsJeopardyIdAnnouncementsRouteImport + parentRoute: typeof AdminEventsJeopardyIdRouteRoute + } + '/admin/events/awdp/$id/writeups': { + id: '/admin/events/awdp/$id/writeups' + path: '/writeups' + fullPath: '/admin/events/awdp/$id/writeups' + preLoaderRoute: typeof AdminEventsAwdpIdWriteupsRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/teams': { + id: '/admin/events/awdp/$id/teams' + path: '/teams' + fullPath: '/admin/events/awdp/$id/teams' + preLoaderRoute: typeof AdminEventsAwdpIdTeamsRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/scoreboard': { + id: '/admin/events/awdp/$id/scoreboard' + path: '/scoreboard' + fullPath: '/admin/events/awdp/$id/scoreboard' + preLoaderRoute: typeof AdminEventsAwdpIdScoreboardRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/ops': { + id: '/admin/events/awdp/$id/ops' + path: '/ops' + fullPath: '/admin/events/awdp/$id/ops' + preLoaderRoute: typeof AdminEventsAwdpIdOpsRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/logs': { + id: '/admin/events/awdp/$id/logs' + path: '/logs' + fullPath: '/admin/events/awdp/$id/logs' + preLoaderRoute: typeof AdminEventsAwdpIdLogsRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/instance': { + id: '/admin/events/awdp/$id/instance' + path: '/instance' + fullPath: '/admin/events/awdp/$id/instance' + preLoaderRoute: typeof AdminEventsAwdpIdInstanceRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/gameboxes': { + id: '/admin/events/awdp/$id/gameboxes' + path: '/gameboxes' + fullPath: '/admin/events/awdp/$id/gameboxes' + preLoaderRoute: typeof AdminEventsAwdpIdGameboxesRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/data_present': { + id: '/admin/events/awdp/$id/data_present' + path: '/data_present' + fullPath: '/admin/events/awdp/$id/data_present' + preLoaderRoute: typeof AdminEventsAwdpIdData_presentRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/configure': { + id: '/admin/events/awdp/$id/configure' + path: '/configure' + fullPath: '/admin/events/awdp/$id/configure' + preLoaderRoute: typeof AdminEventsAwdpIdConfigureRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awdp/$id/announcements': { + id: '/admin/events/awdp/$id/announcements' + path: '/announcements' + fullPath: '/admin/events/awdp/$id/announcements' + preLoaderRoute: typeof AdminEventsAwdpIdAnnouncementsRouteImport + parentRoute: typeof AdminEventsAwdpIdRouteRoute + } + '/admin/events/awd/$id/writeups': { + id: '/admin/events/awd/$id/writeups' + path: '/writeups' + fullPath: '/admin/events/awd/$id/writeups' + preLoaderRoute: typeof AdminEventsAwdIdWriteupsRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/admin/events/awd/$id/teams': { + id: '/admin/events/awd/$id/teams' + path: '/teams' + fullPath: '/admin/events/awd/$id/teams' + preLoaderRoute: typeof AdminEventsAwdIdTeamsRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/admin/events/awd/$id/ops': { + id: '/admin/events/awd/$id/ops' + path: '/ops' + fullPath: '/admin/events/awd/$id/ops' + preLoaderRoute: typeof AdminEventsAwdIdOpsRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/admin/events/awd/$id/network': { + id: '/admin/events/awd/$id/network' + path: '/network' + fullPath: '/admin/events/awd/$id/network' + preLoaderRoute: typeof AdminEventsAwdIdNetworkRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/admin/events/awd/$id/logs': { + id: '/admin/events/awd/$id/logs' + path: '/logs' + fullPath: '/admin/events/awd/$id/logs' + preLoaderRoute: typeof AdminEventsAwdIdLogsRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/admin/events/awd/$id/instance': { + id: '/admin/events/awd/$id/instance' + path: '/instance' + fullPath: '/admin/events/awd/$id/instance' + preLoaderRoute: typeof AdminEventsAwdIdInstanceRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/admin/events/awd/$id/gameboxes': { + id: '/admin/events/awd/$id/gameboxes' + path: '/gameboxes' + fullPath: '/admin/events/awd/$id/gameboxes' + preLoaderRoute: typeof AdminEventsAwdIdGameboxesRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/admin/events/awd/$id/configure': { + id: '/admin/events/awd/$id/configure' + path: '/configure' + fullPath: '/admin/events/awd/$id/configure' + preLoaderRoute: typeof AdminEventsAwdIdConfigureRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + '/admin/events/awd/$id/announcements': { + id: '/admin/events/awd/$id/announcements' + path: '/announcements' + fullPath: '/admin/events/awd/$id/announcements' + preLoaderRoute: typeof AdminEventsAwdIdAnnouncementsRouteImport + parentRoute: typeof AdminEventsAwdIdRouteRoute + } + } +} + +interface AdminDockerRouteRouteChildren { + AdminDockerImagesRoute: typeof AdminDockerImagesRoute + AdminDockerNetworksRoute: typeof AdminDockerNetworksRoute + AdminDockerIndexRoute: typeof AdminDockerIndexRoute +} + +const AdminDockerRouteRouteChildren: AdminDockerRouteRouteChildren = { + AdminDockerImagesRoute: AdminDockerImagesRoute, + AdminDockerNetworksRoute: AdminDockerNetworksRoute, + AdminDockerIndexRoute: AdminDockerIndexRoute, +} + +const AdminDockerRouteRouteWithChildren = + AdminDockerRouteRoute._addFileChildren(AdminDockerRouteRouteChildren) + +interface AdminEventsAwdIdRouteRouteChildren { + AdminEventsAwdIdAnnouncementsRoute: typeof AdminEventsAwdIdAnnouncementsRoute + AdminEventsAwdIdConfigureRoute: typeof AdminEventsAwdIdConfigureRoute + AdminEventsAwdIdGameboxesRoute: typeof AdminEventsAwdIdGameboxesRoute + AdminEventsAwdIdInstanceRoute: typeof AdminEventsAwdIdInstanceRoute + AdminEventsAwdIdLogsRoute: typeof AdminEventsAwdIdLogsRoute + AdminEventsAwdIdNetworkRoute: typeof AdminEventsAwdIdNetworkRoute + AdminEventsAwdIdOpsRoute: typeof AdminEventsAwdIdOpsRoute + AdminEventsAwdIdTeamsRoute: typeof AdminEventsAwdIdTeamsRoute + AdminEventsAwdIdWriteupsRoute: typeof AdminEventsAwdIdWriteupsRoute + AdminEventsAwdIdIndexRoute: typeof AdminEventsAwdIdIndexRoute +} + +const AdminEventsAwdIdRouteRouteChildren: AdminEventsAwdIdRouteRouteChildren = { + AdminEventsAwdIdAnnouncementsRoute: AdminEventsAwdIdAnnouncementsRoute, + AdminEventsAwdIdConfigureRoute: AdminEventsAwdIdConfigureRoute, + AdminEventsAwdIdGameboxesRoute: AdminEventsAwdIdGameboxesRoute, + AdminEventsAwdIdInstanceRoute: AdminEventsAwdIdInstanceRoute, + AdminEventsAwdIdLogsRoute: AdminEventsAwdIdLogsRoute, + AdminEventsAwdIdNetworkRoute: AdminEventsAwdIdNetworkRoute, + AdminEventsAwdIdOpsRoute: AdminEventsAwdIdOpsRoute, + AdminEventsAwdIdTeamsRoute: AdminEventsAwdIdTeamsRoute, + AdminEventsAwdIdWriteupsRoute: AdminEventsAwdIdWriteupsRoute, + AdminEventsAwdIdIndexRoute: AdminEventsAwdIdIndexRoute, +} + +const AdminEventsAwdIdRouteRouteWithChildren = + AdminEventsAwdIdRouteRoute._addFileChildren( + AdminEventsAwdIdRouteRouteChildren, + ) + +interface AdminEventsAwdpIdRouteRouteChildren { + AdminEventsAwdpIdAnnouncementsRoute: typeof AdminEventsAwdpIdAnnouncementsRoute + AdminEventsAwdpIdConfigureRoute: typeof AdminEventsAwdpIdConfigureRoute + AdminEventsAwdpIdData_presentRoute: typeof AdminEventsAwdpIdData_presentRoute + AdminEventsAwdpIdGameboxesRoute: typeof AdminEventsAwdpIdGameboxesRoute + AdminEventsAwdpIdInstanceRoute: typeof AdminEventsAwdpIdInstanceRoute + AdminEventsAwdpIdLogsRoute: typeof AdminEventsAwdpIdLogsRoute + AdminEventsAwdpIdOpsRoute: typeof AdminEventsAwdpIdOpsRoute + AdminEventsAwdpIdScoreboardRoute: typeof AdminEventsAwdpIdScoreboardRoute + AdminEventsAwdpIdTeamsRoute: typeof AdminEventsAwdpIdTeamsRoute + AdminEventsAwdpIdWriteupsRoute: typeof AdminEventsAwdpIdWriteupsRoute + AdminEventsAwdpIdIndexRoute: typeof AdminEventsAwdpIdIndexRoute +} + +const AdminEventsAwdpIdRouteRouteChildren: AdminEventsAwdpIdRouteRouteChildren = + { + AdminEventsAwdpIdAnnouncementsRoute: AdminEventsAwdpIdAnnouncementsRoute, + AdminEventsAwdpIdConfigureRoute: AdminEventsAwdpIdConfigureRoute, + AdminEventsAwdpIdData_presentRoute: AdminEventsAwdpIdData_presentRoute, + AdminEventsAwdpIdGameboxesRoute: AdminEventsAwdpIdGameboxesRoute, + AdminEventsAwdpIdInstanceRoute: AdminEventsAwdpIdInstanceRoute, + AdminEventsAwdpIdLogsRoute: AdminEventsAwdpIdLogsRoute, + AdminEventsAwdpIdOpsRoute: AdminEventsAwdpIdOpsRoute, + AdminEventsAwdpIdScoreboardRoute: AdminEventsAwdpIdScoreboardRoute, + AdminEventsAwdpIdTeamsRoute: AdminEventsAwdpIdTeamsRoute, + AdminEventsAwdpIdWriteupsRoute: AdminEventsAwdpIdWriteupsRoute, + AdminEventsAwdpIdIndexRoute: AdminEventsAwdpIdIndexRoute, + } + +const AdminEventsAwdpIdRouteRouteWithChildren = + AdminEventsAwdpIdRouteRoute._addFileChildren( + AdminEventsAwdpIdRouteRouteChildren, + ) + +interface AdminEventsJeopardyIdRouteRouteChildren { + AdminEventsJeopardyIdAnnouncementsRoute: typeof AdminEventsJeopardyIdAnnouncementsRoute + AdminEventsJeopardyIdData_presentRoute: typeof AdminEventsJeopardyIdData_presentRoute + AdminEventsJeopardyIdInstanceRoute: typeof AdminEventsJeopardyIdInstanceRoute + AdminEventsJeopardyIdLogsRoute: typeof AdminEventsJeopardyIdLogsRoute + AdminEventsJeopardyIdTeamsRoute: typeof AdminEventsJeopardyIdTeamsRoute + AdminEventsJeopardyIdUsersRoute: typeof AdminEventsJeopardyIdUsersRoute + AdminEventsJeopardyIdWriteupsRoute: typeof AdminEventsJeopardyIdWriteupsRoute + AdminEventsJeopardyIdIndexRoute: typeof AdminEventsJeopardyIdIndexRoute +} + +const AdminEventsJeopardyIdRouteRouteChildren: AdminEventsJeopardyIdRouteRouteChildren = + { + AdminEventsJeopardyIdAnnouncementsRoute: + AdminEventsJeopardyIdAnnouncementsRoute, + AdminEventsJeopardyIdData_presentRoute: + AdminEventsJeopardyIdData_presentRoute, + AdminEventsJeopardyIdInstanceRoute: AdminEventsJeopardyIdInstanceRoute, + AdminEventsJeopardyIdLogsRoute: AdminEventsJeopardyIdLogsRoute, + AdminEventsJeopardyIdTeamsRoute: AdminEventsJeopardyIdTeamsRoute, + AdminEventsJeopardyIdUsersRoute: AdminEventsJeopardyIdUsersRoute, + AdminEventsJeopardyIdWriteupsRoute: AdminEventsJeopardyIdWriteupsRoute, + AdminEventsJeopardyIdIndexRoute: AdminEventsJeopardyIdIndexRoute, + } + +const AdminEventsJeopardyIdRouteRouteWithChildren = + AdminEventsJeopardyIdRouteRoute._addFileChildren( + AdminEventsJeopardyIdRouteRouteChildren, + ) + +interface AdminRouteRouteChildren { + AdminDockerRouteRoute: typeof AdminDockerRouteRouteWithChildren + AdminAnnouncementsRoute: typeof AdminAnnouncementsRoute + AdminChallengesRoute: typeof AdminChallengesRoute + AdminDashboardRoute: typeof AdminDashboardRoute + AdminDatabaseRoute: typeof AdminDatabaseRoute + AdminDiscussionsRoute: typeof AdminDiscussionsRoute + AdminLogsRoute: typeof AdminLogsRoute + AdminScheduled_tasksRoute: typeof AdminScheduled_tasksRoute + AdminSettingsRoute: typeof AdminSettingsRoute + AdminSuper_adminsRoute: typeof AdminSuper_adminsRoute + AdminTerminalRoute: typeof AdminTerminalRoute + AdminUsersRoute: typeof AdminUsersRoute + AdminVersionRoute: typeof AdminVersionRoute + AdminWeaponsRoute: typeof AdminWeaponsRoute + AdminIndexRoute: typeof AdminIndexRoute + AdminAwdGameboxesRoute: typeof AdminAwdGameboxesRoute + AdminAwdNetworkRoute: typeof AdminAwdNetworkRoute + AdminChallenge_setsIdRoute: typeof AdminChallenge_setsIdRoute + AdminChallenge_setsIndexRoute: typeof AdminChallenge_setsIndexRoute + AdminEventsIndexRoute: typeof AdminEventsIndexRoute + AdminEventsAwdIdRouteRoute: typeof AdminEventsAwdIdRouteRouteWithChildren + AdminEventsAwdpIdRouteRoute: typeof AdminEventsAwdpIdRouteRouteWithChildren + AdminEventsJeopardyIdRouteRoute: typeof AdminEventsJeopardyIdRouteRouteWithChildren +} + +const AdminRouteRouteChildren: AdminRouteRouteChildren = { + AdminDockerRouteRoute: AdminDockerRouteRouteWithChildren, + AdminAnnouncementsRoute: AdminAnnouncementsRoute, + AdminChallengesRoute: AdminChallengesRoute, + AdminDashboardRoute: AdminDashboardRoute, + AdminDatabaseRoute: AdminDatabaseRoute, + AdminDiscussionsRoute: AdminDiscussionsRoute, + AdminLogsRoute: AdminLogsRoute, + AdminScheduled_tasksRoute: AdminScheduled_tasksRoute, + AdminSettingsRoute: AdminSettingsRoute, + AdminSuper_adminsRoute: AdminSuper_adminsRoute, + AdminTerminalRoute: AdminTerminalRoute, + AdminUsersRoute: AdminUsersRoute, + AdminVersionRoute: AdminVersionRoute, + AdminWeaponsRoute: AdminWeaponsRoute, + AdminIndexRoute: AdminIndexRoute, + AdminAwdGameboxesRoute: AdminAwdGameboxesRoute, + AdminAwdNetworkRoute: AdminAwdNetworkRoute, + AdminChallenge_setsIdRoute: AdminChallenge_setsIdRoute, + AdminChallenge_setsIndexRoute: AdminChallenge_setsIndexRoute, + AdminEventsIndexRoute: AdminEventsIndexRoute, + AdminEventsAwdIdRouteRoute: AdminEventsAwdIdRouteRouteWithChildren, + AdminEventsAwdpIdRouteRoute: AdminEventsAwdpIdRouteRouteWithChildren, + AdminEventsJeopardyIdRouteRoute: AdminEventsJeopardyIdRouteRouteWithChildren, +} + +const AdminRouteRouteWithChildren = AdminRouteRoute._addFileChildren( + AdminRouteRouteChildren, +) + +interface ServiceDiscussionsRouteRouteChildren { + ServiceDiscussionsIdRoute: typeof ServiceDiscussionsIdRoute + ServiceDiscussionsMyRoute: typeof ServiceDiscussionsMyRoute + ServiceDiscussionsIndexRoute: typeof ServiceDiscussionsIndexRoute +} + +const ServiceDiscussionsRouteRouteChildren: ServiceDiscussionsRouteRouteChildren = + { + ServiceDiscussionsIdRoute: ServiceDiscussionsIdRoute, + ServiceDiscussionsMyRoute: ServiceDiscussionsMyRoute, + ServiceDiscussionsIndexRoute: ServiceDiscussionsIndexRoute, + } + +const ServiceDiscussionsRouteRouteWithChildren = + ServiceDiscussionsRouteRoute._addFileChildren( + ServiceDiscussionsRouteRouteChildren, + ) + +interface ServiceChallengesIdRouteRouteChildren { + ServiceChallengesIdWriteupRoute: typeof ServiceChallengesIdWriteupRoute + ServiceChallengesIdIndexRoute: typeof ServiceChallengesIdIndexRoute +} + +const ServiceChallengesIdRouteRouteChildren: ServiceChallengesIdRouteRouteChildren = + { + ServiceChallengesIdWriteupRoute: ServiceChallengesIdWriteupRoute, + ServiceChallengesIdIndexRoute: ServiceChallengesIdIndexRoute, + } + +const ServiceChallengesIdRouteRouteWithChildren = + ServiceChallengesIdRouteRoute._addFileChildren( + ServiceChallengesIdRouteRouteChildren, + ) + +interface ServiceAwdpRunsRunIdRouteRouteChildren { + ServiceAwdpRunsRunIdWriteupRoute: typeof ServiceAwdpRunsRunIdWriteupRoute + ServiceAwdpRunsRunIdIndexRoute: typeof ServiceAwdpRunsRunIdIndexRoute +} + +const ServiceAwdpRunsRunIdRouteRouteChildren: ServiceAwdpRunsRunIdRouteRouteChildren = + { + ServiceAwdpRunsRunIdWriteupRoute: ServiceAwdpRunsRunIdWriteupRoute, + ServiceAwdpRunsRunIdIndexRoute: ServiceAwdpRunsRunIdIndexRoute, + } + +const ServiceAwdpRunsRunIdRouteRouteWithChildren = + ServiceAwdpRunsRunIdRouteRoute._addFileChildren( + ServiceAwdpRunsRunIdRouteRouteChildren, + ) + +interface ServiceEventsAwdIdRouteRouteChildren { + ServiceEventsAwdIdGameboxesRoute: typeof ServiceEventsAwdIdGameboxesRoute + ServiceEventsAwdIdScoreboardRoute: typeof ServiceEventsAwdIdScoreboardRoute + ServiceEventsAwdIdSshRoute: typeof ServiceEventsAwdIdSshRoute + ServiceEventsAwdIdWireguardRoute: typeof ServiceEventsAwdIdWireguardRoute + ServiceEventsAwdIdIndexRoute: typeof ServiceEventsAwdIdIndexRoute +} + +const ServiceEventsAwdIdRouteRouteChildren: ServiceEventsAwdIdRouteRouteChildren = + { + ServiceEventsAwdIdGameboxesRoute: ServiceEventsAwdIdGameboxesRoute, + ServiceEventsAwdIdScoreboardRoute: ServiceEventsAwdIdScoreboardRoute, + ServiceEventsAwdIdSshRoute: ServiceEventsAwdIdSshRoute, + ServiceEventsAwdIdWireguardRoute: ServiceEventsAwdIdWireguardRoute, + ServiceEventsAwdIdIndexRoute: ServiceEventsAwdIdIndexRoute, + } + +const ServiceEventsAwdIdRouteRouteWithChildren = + ServiceEventsAwdIdRouteRoute._addFileChildren( + ServiceEventsAwdIdRouteRouteChildren, + ) + +interface ServiceEventsAwdpIdRouteRouteChildren { + ServiceEventsAwdpIdAnnouncementRoute: typeof ServiceEventsAwdpIdAnnouncementRoute + ServiceEventsAwdpIdGameboxesRoute: typeof ServiceEventsAwdpIdGameboxesRoute + ServiceEventsAwdpIdRoundsRoute: typeof ServiceEventsAwdpIdRoundsRoute + ServiceEventsAwdpIdScoreboardRoute: typeof ServiceEventsAwdpIdScoreboardRoute + ServiceEventsAwdpIdTrendRoute: typeof ServiceEventsAwdpIdTrendRoute + ServiceEventsAwdpIdIndexRoute: typeof ServiceEventsAwdpIdIndexRoute +} + +const ServiceEventsAwdpIdRouteRouteChildren: ServiceEventsAwdpIdRouteRouteChildren = + { + ServiceEventsAwdpIdAnnouncementRoute: ServiceEventsAwdpIdAnnouncementRoute, + ServiceEventsAwdpIdGameboxesRoute: ServiceEventsAwdpIdGameboxesRoute, + ServiceEventsAwdpIdRoundsRoute: ServiceEventsAwdpIdRoundsRoute, + ServiceEventsAwdpIdScoreboardRoute: ServiceEventsAwdpIdScoreboardRoute, + ServiceEventsAwdpIdTrendRoute: ServiceEventsAwdpIdTrendRoute, + ServiceEventsAwdpIdIndexRoute: ServiceEventsAwdpIdIndexRoute, + } + +const ServiceEventsAwdpIdRouteRouteWithChildren = + ServiceEventsAwdpIdRouteRoute._addFileChildren( + ServiceEventsAwdpIdRouteRouteChildren, + ) + +interface ServiceEventsJeopardyIdRouteRouteChildren { + ServiceEventsJeopardyIdAnnouncementRoute: typeof ServiceEventsJeopardyIdAnnouncementRoute + ServiceEventsJeopardyIdChallengesRoute: typeof ServiceEventsJeopardyIdChallengesRoute + ServiceEventsJeopardyIdInstancesRoute: typeof ServiceEventsJeopardyIdInstancesRoute + ServiceEventsJeopardyIdScoreboardRoute: typeof ServiceEventsJeopardyIdScoreboardRoute + ServiceEventsJeopardyIdTrendRoute: typeof ServiceEventsJeopardyIdTrendRoute + ServiceEventsJeopardyIdIndexRoute: typeof ServiceEventsJeopardyIdIndexRoute +} + +const ServiceEventsJeopardyIdRouteRouteChildren: ServiceEventsJeopardyIdRouteRouteChildren = + { + ServiceEventsJeopardyIdAnnouncementRoute: + ServiceEventsJeopardyIdAnnouncementRoute, + ServiceEventsJeopardyIdChallengesRoute: + ServiceEventsJeopardyIdChallengesRoute, + ServiceEventsJeopardyIdInstancesRoute: + ServiceEventsJeopardyIdInstancesRoute, + ServiceEventsJeopardyIdScoreboardRoute: + ServiceEventsJeopardyIdScoreboardRoute, + ServiceEventsJeopardyIdTrendRoute: ServiceEventsJeopardyIdTrendRoute, + ServiceEventsJeopardyIdIndexRoute: ServiceEventsJeopardyIdIndexRoute, + } + +const ServiceEventsJeopardyIdRouteRouteWithChildren = + ServiceEventsJeopardyIdRouteRoute._addFileChildren( + ServiceEventsJeopardyIdRouteRouteChildren, + ) + +interface ServiceRouteRouteChildren { + ServiceDiscussionsRouteRoute: typeof ServiceDiscussionsRouteRouteWithChildren + ServiceAnnouncementsRoute: typeof ServiceAnnouncementsRoute + ServiceGameboxesRoute: typeof ServiceGameboxesRoute + ServiceInstancesRoute: typeof ServiceInstancesRoute + ServiceProfileRoute: typeof ServiceProfileRoute + ServiceSolvesRoute: typeof ServiceSolvesRoute + ServiceTopRoute: typeof ServiceTopRoute + ServiceWeaponsRoute: typeof ServiceWeaponsRoute + ServiceIndexRoute: typeof ServiceIndexRoute + ServiceChallengesIdRouteRoute: typeof ServiceChallengesIdRouteRouteWithChildren + ServiceWriteupsIdRoute: typeof ServiceWriteupsIdRoute + ServiceChallenge_setsIndexRoute: typeof ServiceChallenge_setsIndexRoute + ServiceChallengesIndexRoute: typeof ServiceChallengesIndexRoute + ServiceEventsIndexRoute: typeof ServiceEventsIndexRoute + ServiceWriteupsIndexRoute: typeof ServiceWriteupsIndexRoute + ServiceAwdpRunsRunIdRouteRoute: typeof ServiceAwdpRunsRunIdRouteRouteWithChildren + ServiceEventsAwdIdRouteRoute: typeof ServiceEventsAwdIdRouteRouteWithChildren + ServiceEventsAwdpIdRouteRoute: typeof ServiceEventsAwdpIdRouteRouteWithChildren + ServiceEventsJeopardyIdRouteRoute: typeof ServiceEventsJeopardyIdRouteRouteWithChildren + ServiceChallenge_setsIdIndexRoute: typeof ServiceChallenge_setsIdIndexRoute +} + +const ServiceRouteRouteChildren: ServiceRouteRouteChildren = { + ServiceDiscussionsRouteRoute: ServiceDiscussionsRouteRouteWithChildren, + ServiceAnnouncementsRoute: ServiceAnnouncementsRoute, + ServiceGameboxesRoute: ServiceGameboxesRoute, + ServiceInstancesRoute: ServiceInstancesRoute, + ServiceProfileRoute: ServiceProfileRoute, + ServiceSolvesRoute: ServiceSolvesRoute, + ServiceTopRoute: ServiceTopRoute, + ServiceWeaponsRoute: ServiceWeaponsRoute, + ServiceIndexRoute: ServiceIndexRoute, + ServiceChallengesIdRouteRoute: ServiceChallengesIdRouteRouteWithChildren, + ServiceWriteupsIdRoute: ServiceWriteupsIdRoute, + ServiceChallenge_setsIndexRoute: ServiceChallenge_setsIndexRoute, + ServiceChallengesIndexRoute: ServiceChallengesIndexRoute, + ServiceEventsIndexRoute: ServiceEventsIndexRoute, + ServiceWriteupsIndexRoute: ServiceWriteupsIndexRoute, + ServiceAwdpRunsRunIdRouteRoute: ServiceAwdpRunsRunIdRouteRouteWithChildren, + ServiceEventsAwdIdRouteRoute: ServiceEventsAwdIdRouteRouteWithChildren, + ServiceEventsAwdpIdRouteRoute: ServiceEventsAwdpIdRouteRouteWithChildren, + ServiceEventsJeopardyIdRouteRoute: + ServiceEventsJeopardyIdRouteRouteWithChildren, + ServiceChallenge_setsIdIndexRoute: ServiceChallenge_setsIdIndexRoute, +} + +const ServiceRouteRouteWithChildren = ServiceRouteRoute._addFileChildren( + ServiceRouteRouteChildren, +) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AdminRouteRoute: AdminRouteRouteWithChildren, + ServiceRouteRoute: ServiceRouteRouteWithChildren, + RegisterRoute: RegisterRoute, + ResetRoute: ResetRoute, + Reset_passwordRoute: Reset_passwordRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/frontends/default/src/router.tsx b/frontends/default/src/router.tsx new file mode 100644 index 00000000..029ec873 --- /dev/null +++ b/frontends/default/src/router.tsx @@ -0,0 +1,52 @@ +/** + * Default Frontend 自己的路由实例。 + * + * 关键点:**路由完全由前端拥有**。`@floatctf/frontend-runtime` 与后端都不知道 + * `/service/events/awd/$id` 这类路径的存在,所以另一个前端可以自由地改用 + * `/workspace/:id`。本文件与迁移前的 `@/main` 中创建 router 的部分逐字一致, + * 只是从 `main.tsx` 拆出来,好让 `@/api/client` 能在 401 时导航到既有入口。 + */ + +import { RouterProvider, createRouter } from "@tanstack/react-router"; + +import * as TanStackQueryProvider from "./integrations/tanstack-query/root-provider.tsx"; +import { RouteLoading } from "./components/RouteLoading.tsx"; +// 导入生成的路由树 +import { routeTree } from "./routeTree.gen"; + +export const TanStackQueryProviderContext = TanStackQueryProvider.getContext(); + +export const router = createRouter({ + routeTree, + context: { + ...TanStackQueryProviderContext, + }, + defaultPreload: "intent", + scrollRestoration: true, + defaultStructuralSharing: true, + defaultPreloadStaleTime: 0, + // 懒加载 chunk / loader 期间在内容区显示加载态,替代默认白屏。 + defaultPendingComponent: RouteLoading, + defaultPendingMs: 100, // 100ms 内完成的路由切换不闪烁加载态 + defaultPendingMinMs: 300, // 加载态至少展示 300ms,避免快速加载时闪一下 + // 路由加载失败 / 404 时给出提示,避免整页白屏。 + defaultErrorComponent: ({ error }: { error: Error }) => ( +
+

页面加载失败:{String(error)}

+
+ ), + defaultNotFoundComponent: () => ( +
+

404 · 页面不存在

+
+ ), +}); + +// 注册路由实例以获得类型安全 +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +export { RouterProvider }; diff --git a/apps/web/src/routes/__root.tsx b/frontends/default/src/routes/__root.tsx similarity index 100% rename from apps/web/src/routes/__root.tsx rename to frontends/default/src/routes/__root.tsx diff --git a/apps/web/src/routes/admin/announcements.tsx b/frontends/default/src/routes/admin/announcements.tsx similarity index 98% rename from apps/web/src/routes/admin/announcements.tsx rename to frontends/default/src/routes/admin/announcements.tsx index c78c9085..660207e5 100644 --- a/apps/web/src/routes/admin/announcements.tsx +++ b/frontends/default/src/routes/admin/announcements.tsx @@ -4,7 +4,7 @@ import { useReactive, useTitle } from "ahooks"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import { type Announcements } from "@/entity"; +import { type Announcements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "./route"; diff --git a/apps/web/src/routes/admin/awd/gameboxes.tsx b/frontends/default/src/routes/admin/awd/gameboxes.tsx similarity index 99% rename from apps/web/src/routes/admin/awd/gameboxes.tsx rename to frontends/default/src/routes/admin/awd/gameboxes.tsx index d6d4666a..6470d801 100644 --- a/apps/web/src/routes/admin/awd/gameboxes.tsx +++ b/frontends/default/src/routes/admin/awd/gameboxes.tsx @@ -23,8 +23,8 @@ import type { GameBoxCheckResult, GameBoxLibraryDto, GameBoxScanItem, -} from "@/api/awd"; -import { type QueryParams, type UniResponse } from "@/api/axios"; +} from "@floatctf/sdk"; +import { type QueryParams, type UniResponse } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { useSelectedRowIds } from "@/util"; import { AdminRouteGuard } from "../route"; diff --git a/apps/web/src/routes/admin/awd/network.tsx b/frontends/default/src/routes/admin/awd/network.tsx similarity index 99% rename from apps/web/src/routes/admin/awd/network.tsx rename to frontends/default/src/routes/admin/awd/network.tsx index ff6196f8..e14251ee 100644 --- a/apps/web/src/routes/admin/awd/network.tsx +++ b/frontends/default/src/routes/admin/awd/network.tsx @@ -22,8 +22,8 @@ import type { PlatformNetworkAllocation, PlatformNetworkHealth, PlatformNetworkSettings, -} from "@/api/awd"; -import type { QueryParams, UniResponse } from "@/api/axios"; +} from "@floatctf/sdk"; +import type { QueryParams, UniResponse } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { EMPTY_PLATFORM_NETWORK_FORM, diff --git a/apps/web/src/routes/admin/challenge_sets/$id.tsx b/frontends/default/src/routes/admin/challenge_sets/$id.tsx similarity index 98% rename from apps/web/src/routes/admin/challenge_sets/$id.tsx rename to frontends/default/src/routes/admin/challenge_sets/$id.tsx index a37a6ab2..7ee60784 100644 --- a/apps/web/src/routes/admin/challenge_sets/$id.tsx +++ b/frontends/default/src/routes/admin/challenge_sets/$id.tsx @@ -3,7 +3,7 @@ import { useCallback, useRef, useState } from "react"; import { adminApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; import { DatetimeToShow } from "@/util"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { AdminRouteGuard } from "@/routes/admin/route"; import { useSelectedRowIds } from "@/util"; import { Dialog } from "@primer/react/experimental"; diff --git a/apps/web/src/routes/admin/challenge_sets/index.tsx b/frontends/default/src/routes/admin/challenge_sets/index.tsx similarity index 97% rename from apps/web/src/routes/admin/challenge_sets/index.tsx rename to frontends/default/src/routes/admin/challenge_sets/index.tsx index d41e2402..b9630784 100644 --- a/apps/web/src/routes/admin/challenge_sets/index.tsx +++ b/frontends/default/src/routes/admin/challenge_sets/index.tsx @@ -4,7 +4,7 @@ import { useReactive } from "ahooks"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { ChallengeSets } from "@/entity"; +import type { ChallengeSets } from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { AdminRouteGuard } from "@/routes/admin/route"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/admin/challenges.tsx b/frontends/default/src/routes/admin/challenges.tsx similarity index 95% rename from apps/web/src/routes/admin/challenges.tsx rename to frontends/default/src/routes/admin/challenges.tsx index ce84e4eb..b18c31aa 100644 --- a/apps/web/src/routes/admin/challenges.tsx +++ b/frontends/default/src/routes/admin/challenges.tsx @@ -19,7 +19,7 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { adminApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; import { AdminRouteGuard } from "@/routes/admin/route"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { useSelectedRowIds } from "@/util"; export const Route = createFileRoute("/admin/challenges")({ @@ -355,30 +355,6 @@ function ImportButton() { ); } -export type ChallengeCheckResult = { - id: string; - challenge_name: string; - is_ok: boolean; - docker_image: boolean; - attachment: boolean; - /** static / attachment-only 题目(无 src/Dockerfile):没有镜像,无需 Build。 */ - static_content: boolean; -}; -export type BuildChallengeResult = { - challenge_name: string; - is_ok: boolean; - message: string; -}; -export type ChallengeScanItem = { - safe_name: string; - name: string | null; - version: string | null; - status: "added" | "skipped" | "error"; - message: string; -}; -export type ImportChallengeResponse = { - challenge: ChallengesListItem; -}; // 扫描 CHALLENGES_DIR 登记未入库 package(结果弹窗展示) export function ScanButton() { @@ -636,3 +612,16 @@ export function CheckButton({ ); } + +import type { + ChallengeCheckResult, + BuildChallengeResult, + ChallengeScanItem, + ImportChallengeResponse, +} from "@floatctf/sdk"; +export type { + ChallengeCheckResult, + BuildChallengeResult, + ChallengeScanItem, + ImportChallengeResponse, +}; diff --git a/apps/web/src/routes/admin/dashboard.tsx b/frontends/default/src/routes/admin/dashboard.tsx similarity index 94% rename from apps/web/src/routes/admin/dashboard.tsx rename to frontends/default/src/routes/admin/dashboard.tsx index 9a996387..f47fa3aa 100644 --- a/apps/web/src/routes/admin/dashboard.tsx +++ b/frontends/default/src/routes/admin/dashboard.tsx @@ -15,8 +15,8 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; -import type { DashboardSummary } from "@/api/admin/dashboard"; -import { systemInformationQueryOptions } from "@/api/queries"; +import type { DashboardSummary } from "@floatctf/sdk"; +import { systemInformationQueryOptions } from "@/api/react"; import { type EventStatus, EventStatusBadge, @@ -33,55 +33,10 @@ export const Route = createFileRoute("/admin/dashboard")({ await context.queryClient.ensureQueryData(systemInformationQueryOptions()); }, }); -export type SystemInformation = { - name?: string; - kernel_version?: string; - os_version?: string; - host_name?: string; - uptime: number; - total_memory: number; - used_memory: number; - total_swap: number; - used_swap: number; - avg_temp: number; - max_temp: number; - nb_cpu: number; - disks_info: DiskInformation[]; - network_interfaces: NetworkInterfaceInfo[]; - docker_info: DockerInformation; -}; -export type DiskInformation = { - name: string; - mount_point: string; - file_system: string; - total_space: number; - available_space: number; - used_space: number; - usage_percent: number; -}; -export type NetworkInterfaceInfo = { - name: string; - ip_addresses: string[]; - received: number; - transmitted: number; - recv_rate: number; - transmit_rate: number; -}; -export type DockerImageInfo = { - id: string; - repo_tags: string[]; - size: number; -}; -export type DockerInformation = { - image_count: number; - images: DockerImageInfo[]; - running_container_count: number; - total_disk: number; -}; // ───────────────────────────────────────────────────────────────────────────── // Primer 风格基础件(颜色走 @primer/css light 变量,暗色主题可复用) @@ -716,3 +671,18 @@ function RouteComponent() { ); } + +import type { + SystemInformation, + DiskInformation, + NetworkInterfaceInfo, + DockerImageInfo, + DockerInformation, +} from "@floatctf/sdk"; +export type { + SystemInformation, + DiskInformation, + NetworkInterfaceInfo, + DockerImageInfo, + DockerInformation, +}; diff --git a/apps/web/src/routes/admin/database.tsx b/frontends/default/src/routes/admin/database.tsx similarity index 94% rename from apps/web/src/routes/admin/database.tsx rename to frontends/default/src/routes/admin/database.tsx index 27983234..bbd4a07a 100644 --- a/apps/web/src/routes/admin/database.tsx +++ b/frontends/default/src/routes/admin/database.tsx @@ -11,18 +11,7 @@ export const Route = createFileRoute("/admin/database")({ component: RouteComponent, }); -export type SqlStatement = { - sql: string; -}; -export type SqlResult = { - sql_type: string; - // biome-ignore lint/suspicious/noExplicitAny: - rows: Record[]; - count: number; - rows_affected: number; - elapsed_ms: number; -}; function RouteComponent() { const sql_state = useReactive({ @@ -154,3 +143,12 @@ function SqlDataTable({ result }: { result: SqlResult }) { ); } + +import type { + SqlStatement, + SqlResult, +} from "@floatctf/sdk"; +export type { + SqlStatement, + SqlResult, +}; diff --git a/apps/web/src/routes/admin/discussions.tsx b/frontends/default/src/routes/admin/discussions.tsx similarity index 97% rename from apps/web/src/routes/admin/discussions.tsx rename to frontends/default/src/routes/admin/discussions.tsx index 2610ed24..2c4f0b38 100644 --- a/apps/web/src/routes/admin/discussions.tsx +++ b/frontends/default/src/routes/admin/discussions.tsx @@ -3,7 +3,7 @@ import { useTitle } from "ahooks"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Discussions } from "@/entity"; +import type { Discussions } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "./route"; diff --git a/apps/web/src/routes/admin/docker/images.tsx b/frontends/default/src/routes/admin/docker/images.tsx similarity index 100% rename from apps/web/src/routes/admin/docker/images.tsx rename to frontends/default/src/routes/admin/docker/images.tsx diff --git a/apps/web/src/routes/admin/docker/index.tsx b/frontends/default/src/routes/admin/docker/index.tsx similarity index 100% rename from apps/web/src/routes/admin/docker/index.tsx rename to frontends/default/src/routes/admin/docker/index.tsx diff --git a/apps/web/src/routes/admin/docker/networks.tsx b/frontends/default/src/routes/admin/docker/networks.tsx similarity index 100% rename from apps/web/src/routes/admin/docker/networks.tsx rename to frontends/default/src/routes/admin/docker/networks.tsx diff --git a/apps/web/src/routes/admin/docker/route.tsx b/frontends/default/src/routes/admin/docker/route.tsx similarity index 100% rename from apps/web/src/routes/admin/docker/route.tsx rename to frontends/default/src/routes/admin/docker/route.tsx diff --git a/apps/web/src/routes/admin/events/awd.$id/announcements.tsx b/frontends/default/src/routes/admin/events/awd.$id/announcements.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awd.$id/announcements.tsx rename to frontends/default/src/routes/admin/events/awd.$id/announcements.tsx index 3cc130d4..dc736476 100644 --- a/apps/web/src/routes/admin/events/awd.$id/announcements.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/announcements.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { TextInput, Textarea } from "@primer/react"; diff --git a/apps/web/src/routes/admin/events/awd.$id/configure.tsx b/frontends/default/src/routes/admin/events/awd.$id/configure.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awd.$id/configure.tsx rename to frontends/default/src/routes/admin/events/awd.$id/configure.tsx index a07335b2..df66bf53 100644 --- a/apps/web/src/routes/admin/events/awd.$id/configure.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/configure.tsx @@ -11,7 +11,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { type ChangeEvent, useEffect, useRef, useState } from "react"; import { adminApi } from "@/api"; -import type { AwdEventConfigInput } from "@/api/awd"; +import type { AwdEventConfigInput } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awd.$id/gameboxes.tsx b/frontends/default/src/routes/admin/events/awd.$id/gameboxes.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awd.$id/gameboxes.tsx rename to frontends/default/src/routes/admin/events/awd.$id/gameboxes.tsx index 9fdd3610..434cdb2a 100644 --- a/apps/web/src/routes/admin/events/awd.$id/gameboxes.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/gameboxes.tsx @@ -5,8 +5,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; import { adminApi } from "@/api"; -import type { EventGameBoxDto, GameBoxLibraryDto } from "@/api/awd"; -import type { QueryParams } from "@/api/axios"; +import type { EventGameBoxDto, GameBoxLibraryDto } from "@floatctf/sdk"; +import type { QueryParams } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { QUERY_KEY as LIB_QUERY_KEY } from "@/routes/admin/awd/gameboxes"; import { useSelectedRowIds } from "@/util"; diff --git a/apps/web/src/routes/admin/events/awd.$id/index.tsx b/frontends/default/src/routes/admin/events/awd.$id/index.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awd.$id/index.tsx rename to frontends/default/src/routes/admin/events/awd.$id/index.tsx index 3372ea8f..c69f980a 100644 --- a/apps/web/src/routes/admin/events/awd.$id/index.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/index.tsx @@ -9,7 +9,7 @@ import { createFileRoute } from "@tanstack/react-router"; import dayjs from "dayjs"; import { adminApi } from "@/api"; -import type { AwdEventStatus } from "@/api/awd"; +import type { AwdEventStatus } from "@floatctf/sdk"; import { AdminRouteGuard } from "../../route"; export const Route = createFileRoute("/admin/events/awd/$id/")({ diff --git a/apps/web/src/routes/admin/events/awd.$id/instance.tsx b/frontends/default/src/routes/admin/events/awd.$id/instance.tsx similarity index 100% rename from apps/web/src/routes/admin/events/awd.$id/instance.tsx rename to frontends/default/src/routes/admin/events/awd.$id/instance.tsx diff --git a/apps/web/src/routes/admin/events/awd.$id/logs.tsx b/frontends/default/src/routes/admin/events/awd.$id/logs.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awd.$id/logs.tsx rename to frontends/default/src/routes/admin/events/awd.$id/logs.tsx index c09f4599..dd6b797e 100644 --- a/apps/web/src/routes/admin/events/awd.$id/logs.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/logs.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventLogs } from "@/entity"; +import type { EventLogs } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { Label } from "@primer/react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/admin/events/awd.$id/network.tsx b/frontends/default/src/routes/admin/events/awd.$id/network.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awd.$id/network.tsx rename to frontends/default/src/routes/admin/events/awd.$id/network.tsx index 14be2092..57997776 100644 --- a/apps/web/src/routes/admin/events/awd.$id/network.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/network.tsx @@ -14,7 +14,7 @@ import type { AxiosError } from "axios"; import { type ChangeEvent, type ReactNode, useState } from "react"; import { adminApi } from "@/api"; -import type { EventNetworkInfo } from "@/api/awd"; +import type { EventNetworkInfo } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { EMPTY_MANUAL_ALLOCATION_FORM, diff --git a/apps/web/src/routes/admin/events/awd.$id/ops.tsx b/frontends/default/src/routes/admin/events/awd.$id/ops.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awd.$id/ops.tsx rename to frontends/default/src/routes/admin/events/awd.$id/ops.tsx index 0a2427de..b68e085f 100644 --- a/apps/web/src/routes/admin/events/awd.$id/ops.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/ops.tsx @@ -13,7 +13,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { adminApi } from "@/api"; -import type { AwdPrecheckRun, AwdScoreRow } from "@/api/awd"; +import type { AwdPrecheckRun, AwdScoreRow } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awd.$id/route.tsx b/frontends/default/src/routes/admin/events/awd.$id/route.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awd.$id/route.tsx rename to frontends/default/src/routes/admin/events/awd.$id/route.tsx index 411997c8..73b9a817 100644 --- a/apps/web/src/routes/admin/events/awd.$id/route.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/route.tsx @@ -14,8 +14,8 @@ import { type JeopardyEventChallenges as EventChallenges, EventFamily, type Events, -} from "@/entity"; -import { useAdminAwdEventStream } from "@/hooks/useAdminAwdEventStream"; +} from "@floatctf/sdk/entity"; +import { useAdminAwdEventStream } from "@/api/react"; import { AwdEventProgress, adminProgressState } from "@/components/awd/AwdEventProgress"; import { RouterUnderlineNavItem as RouterNavItem } from "@/components/RouterUnderlineNavItem"; diff --git a/apps/web/src/routes/admin/events/awd.$id/teams.tsx b/frontends/default/src/routes/admin/events/awd.$id/teams.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awd.$id/teams.tsx rename to frontends/default/src/routes/admin/events/awd.$id/teams.tsx index 28c82232..e9139ed6 100644 --- a/apps/web/src/routes/admin/events/awd.$id/teams.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/teams.tsx @@ -5,7 +5,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; -import type { EventTeamMemberRole, EventTeams } from "@/entity"; +import type { EventTeamMemberRole, EventTeams } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awd.$id/writeups.tsx b/frontends/default/src/routes/admin/events/awd.$id/writeups.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awd.$id/writeups.tsx rename to frontends/default/src/routes/admin/events/awd.$id/writeups.tsx index fd782aae..7ce743fa 100644 --- a/apps/web/src/routes/admin/events/awd.$id/writeups.tsx +++ b/frontends/default/src/routes/admin/events/awd.$id/writeups.tsx @@ -4,7 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventWriteup } from "@/entity"; +import type { EventWriteup } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/announcements.tsx b/frontends/default/src/routes/admin/events/awdp.$id/announcements.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/announcements.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/announcements.tsx index 41f0e795..06840feb 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/announcements.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/announcements.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { TextInput, Textarea } from "@primer/react"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/configure.tsx b/frontends/default/src/routes/admin/events/awdp.$id/configure.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awdp.$id/configure.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/configure.tsx index 015bd12f..7fe8576a 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/configure.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/configure.tsx @@ -16,7 +16,7 @@ import { useState, } from "react"; -import { type AwdpEventConfigDto, awdpAdminApi } from "@/api/awdp"; +import { type AwdpEventConfigDto, awdpAdminApi } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AwdpTimeline, diff --git a/apps/web/src/routes/admin/events/awdp.$id/data_present.tsx b/frontends/default/src/routes/admin/events/awdp.$id/data_present.tsx similarity index 99% rename from apps/web/src/routes/admin/events/awdp.$id/data_present.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/data_present.tsx index 46cb60bd..faabd7e8 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/data_present.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/data_present.tsx @@ -11,7 +11,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { useState } from "react"; -import { type AwdpDataPresent, awdpAdminApi } from "@/api/awdp"; +import { type AwdpDataPresent, awdpAdminApi } from "@floatctf/sdk"; import { RemainingTimer } from "@/routes/service/events/jeopardy.$id/route"; import { TrendChart } from "@/routes/service/events/jeopardy.$id/trend"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/gameboxes.tsx b/frontends/default/src/routes/admin/events/awdp.$id/gameboxes.tsx similarity index 96% rename from apps/web/src/routes/admin/events/awdp.$id/gameboxes.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/gameboxes.tsx index c88af287..9e7c637a 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/gameboxes.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/gameboxes.tsx @@ -5,9 +5,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; import { adminApi } from "@/api"; -import type { AwdpAdminEventGameBoxDto } from "@/api/awdp"; -import { awdpAdminApi } from "@/api/awdp"; -import type { QueryParams } from "@/api/axios"; +import type { AwdpAdminEventGameBoxDto } from "@floatctf/sdk"; +import { awdpAdminApi } from "@floatctf/sdk"; +import type { QueryParams } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { QUERY_KEY as LIB_QUERY_KEY } from "@/routes/admin/awd/gameboxes"; import { useSelectedRowIds } from "@/util"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/index.tsx b/frontends/default/src/routes/admin/events/awdp.$id/index.tsx similarity index 100% rename from apps/web/src/routes/admin/events/awdp.$id/index.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/index.tsx diff --git a/apps/web/src/routes/admin/events/awdp.$id/instance.tsx b/frontends/default/src/routes/admin/events/awdp.$id/instance.tsx similarity index 100% rename from apps/web/src/routes/admin/events/awdp.$id/instance.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/instance.tsx diff --git a/apps/web/src/routes/admin/events/awdp.$id/logs.tsx b/frontends/default/src/routes/admin/events/awdp.$id/logs.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/logs.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/logs.tsx index 214cb4ca..a4659e33 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/logs.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/logs.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventLogs } from "@/entity"; +import type { EventLogs } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { Label } from "@primer/react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/ops.tsx b/frontends/default/src/routes/admin/events/awdp.$id/ops.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awdp.$id/ops.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/ops.tsx index 6247d7fc..66f49e91 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/ops.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/ops.tsx @@ -2,7 +2,7 @@ import { Button, ButtonGroup, Label, Spinner, useConfirm } from "@primer/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { awdpAdminApi } from "@/api/awdp"; +import { awdpAdminApi } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/route.tsx b/frontends/default/src/routes/admin/events/awdp.$id/route.tsx similarity index 96% rename from apps/web/src/routes/admin/events/awdp.$id/route.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/route.tsx index 5df5d4d7..0f92387c 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/route.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/route.tsx @@ -4,8 +4,8 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"; import { createContext } from "react"; import { adminApi } from "@/api"; -import { awdpAdminApi } from "@/api/awdp"; -import { type Events, ParticipantMode } from "@/entity"; +import { awdpAdminApi } from "@floatctf/sdk"; +import { type Events, ParticipantMode } from "@floatctf/sdk/entity"; import { RouterUnderlineNavItem as RouterNavItem } from "@/components/RouterUnderlineNavItem"; export const Route = createFileRoute("/admin/events/awdp/$id")({ diff --git a/apps/web/src/routes/admin/events/awdp.$id/scoreboard.tsx b/frontends/default/src/routes/admin/events/awdp.$id/scoreboard.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/scoreboard.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/scoreboard.tsx index 5fef6a3c..c879395b 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/scoreboard.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/scoreboard.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; -import { type AwdpScoreRow, awdpAdminApi } from "@/api/awdp"; +import { type AwdpScoreRow, awdpAdminApi } from "@floatctf/sdk"; import { AdminRouteGuard } from "../../route"; /** diff --git a/apps/web/src/routes/admin/events/awdp.$id/teams.tsx b/frontends/default/src/routes/admin/events/awdp.$id/teams.tsx similarity index 98% rename from apps/web/src/routes/admin/events/awdp.$id/teams.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/teams.tsx index 16e06531..657321b5 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/teams.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/teams.tsx @@ -6,7 +6,7 @@ import { Fragment } from "react"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventTeamMemberRole, EventTeams } from "@/entity"; +import type { EventTeamMemberRole, EventTeams } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/awdp.$id/writeups.tsx b/frontends/default/src/routes/admin/events/awdp.$id/writeups.tsx similarity index 97% rename from apps/web/src/routes/admin/events/awdp.$id/writeups.tsx rename to frontends/default/src/routes/admin/events/awdp.$id/writeups.tsx index dbed8d52..10ecd5a5 100644 --- a/apps/web/src/routes/admin/events/awdp.$id/writeups.tsx +++ b/frontends/default/src/routes/admin/events/awdp.$id/writeups.tsx @@ -4,7 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventWriteup } from "@/entity"; +import type { EventWriteup } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/events/index.tsx b/frontends/default/src/routes/admin/events/index.tsx similarity index 99% rename from apps/web/src/routes/admin/events/index.tsx rename to frontends/default/src/routes/admin/events/index.tsx index 241c9cdf..43909351 100644 --- a/apps/web/src/routes/admin/events/index.tsx +++ b/frontends/default/src/routes/admin/events/index.tsx @@ -14,7 +14,7 @@ import { useState } from "react"; import { adminApi } from "@/api"; import { EventStatusBadge, GenericTable } from "@/components"; import { UnderlineNavButton } from "@/components/UnderlineNavButton"; -import { EventFamily, type Events, ParticipantMode } from "@/entity"; +import { EventFamily, type Events, ParticipantMode } from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { AdminRouteGuard } from "@/routes/admin/route"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/announcements.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/announcements.tsx similarity index 97% rename from apps/web/src/routes/admin/events/jeopardy.$id/announcements.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/announcements.tsx index 7277e713..7c0862c3 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/announcements.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/announcements.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { TextInput, Textarea } from "@primer/react"; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/data_present.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/data_present.tsx similarity index 91% rename from apps/web/src/routes/admin/events/jeopardy.$id/data_present.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/data_present.tsx index da1ec83d..9d4dd68b 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/data_present.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/data_present.tsx @@ -10,16 +10,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { adminApi } from "@/api"; -import type { Events } from "@/entity"; +import type { Events } from "@floatctf/sdk/entity"; import { RemainingTimer } from "@/routes/service/events/jeopardy.$id/route"; -import { - ScoreBoard, - type ScoreboardItem, -} from "@/routes/service/events/jeopardy.$id/scoreboard"; -import { - TrendChart, - type TrendItem, -} from "@/routes/service/events/jeopardy.$id/trend"; +import { ScoreBoard } from "@/routes/service/events/jeopardy.$id/scoreboard"; +import { TrendChart } from "@/routes/service/events/jeopardy.$id/trend"; +import type { ScoreboardItem } from "@floatctf/sdk"; +import type { TrendItem } from "@floatctf/sdk"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; @@ -28,32 +24,9 @@ export const Route = createFileRoute("/admin/events/jeopardy/$id/data_present")( loader: AdminRouteGuard, }); -export type DataEventChallenge = { - name: string; - category: string; - points: number; - solved_count: number; - solved_percent: number; -}; -export type DataEventChallengeSolve = { - user_nickname: string; - challenge_name: string; - challenge_category: string; - created_at: string; // NaiveDateTime → string - bonus_points: number; -}; // 你需要在别处定义 Event, ScoreboardItem, TrendItem 的 TS 类型 -export type DataPresent = { - event: Events; // 对应 events::Model - user_count: number; - team_count: number; - solved_recent_15: DataEventChallengeSolve[]; - event_challenges: DataEventChallenge[]; - scoreboard_top10: ScoreboardItem[]; - trend: TrendItem[]; -}; function RouteComponent() { const [isFull, setIsFull] = useState(false); @@ -279,3 +252,14 @@ function EventChallengesView({ data }: { data: DataEventChallenge[] }) { ); } + +import type { + DataEventChallenge, + DataEventChallengeSolve, + DataPresent, +} from "@floatctf/sdk"; +export type { + DataEventChallenge, + DataEventChallengeSolve, + DataPresent, +}; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/index.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/index.tsx similarity index 97% rename from apps/web/src/routes/admin/events/jeopardy.$id/index.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/index.tsx index 93f425d1..dad1f123 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/index.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/index.tsx @@ -17,8 +17,8 @@ import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { adminApi } from "@/api"; import { ActionSelect, GenericTable, useMsgBanner } from "@/components"; -import type { Challenges } from "@/entity"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { Challenges } from "@floatctf/sdk/entity"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { CheckButton } from "@/routes/admin/challenges"; import { DatetimeToShow, useSelectedRowIds } from "@/util"; import { AdminRouteGuard } from "../../route"; @@ -29,17 +29,7 @@ export const Route = createFileRoute("/admin/events/jeopardy/$id/")({ loader: AdminRouteGuard, }); -export type EventChallenge = { - event_id: string; - challenge_id: string; - hidden: boolean; - points: number; -}; -export type EventChallengeResult = { - event_challenge: EventChallenge; - challenge: Challenges; -}; function RouteComponent() { const event = useContext(EventContext); @@ -474,3 +464,12 @@ function OpenChallengesButton({ ); } + +import type { + EventChallenge, + AdminEventChallengeResult as EventChallengeResult, +} from "@floatctf/sdk"; +export type { + EventChallenge, + EventChallengeResult, +}; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/instance.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/instance.tsx similarity index 100% rename from apps/web/src/routes/admin/events/jeopardy.$id/instance.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/instance.tsx diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/logs.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/logs.tsx similarity index 98% rename from apps/web/src/routes/admin/events/jeopardy.$id/logs.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/logs.tsx index f4ed5cd4..32423494 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/logs.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/logs.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventLogs } from "@/entity"; +import type { EventLogs } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { Label } from "@primer/react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/route.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/route.tsx similarity index 99% rename from apps/web/src/routes/admin/events/jeopardy.$id/route.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/route.tsx index 39cd43da..c8e741e1 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/route.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/route.tsx @@ -9,7 +9,7 @@ import { type JeopardyEventChallenges as EventChallenges, ParticipantMode, type Events, -} from "@/entity"; +} from "@floatctf/sdk/entity"; import { RouterUnderlineNavItem as RouterNavItem } from "@/components/RouterUnderlineNavItem"; export const Route = createFileRoute("/admin/events/jeopardy/$id")({ diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/teams.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/teams.tsx similarity index 94% rename from apps/web/src/routes/admin/events/jeopardy.$id/teams.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/teams.tsx index 8f704002..c22d1a28 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/teams.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/teams.tsx @@ -6,7 +6,7 @@ import { Fragment } from "react"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventTeamMemberRole, EventTeams } from "@/entity"; +import type { EventTeamMemberRole, EventTeams } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; @@ -15,19 +15,7 @@ export const Route = createFileRoute("/admin/events/jeopardy/$id/teams")({ loader: AdminRouteGuard, }); -export type TeamMemberResult = { - username: string; - nickname: string; - role: EventTeamMemberRole; - points: number; -}; -export type TeamResult = { - id: string; - team: EventTeams; - captain: string; - members: TeamMemberResult[]; -}; function RouteComponent() { const { id } = Route.useParams(); @@ -176,3 +164,12 @@ function RouteComponent() { /> ); } + +import type { + TeamMemberResult, + TeamResult, +} from "@floatctf/sdk"; +export type { + TeamMemberResult, + TeamResult, +}; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/users.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/users.tsx similarity index 97% rename from apps/web/src/routes/admin/events/jeopardy.$id/users.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/users.tsx index 41c9cc25..3d5d6f6c 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/users.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/users.tsx @@ -5,7 +5,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { ActionSelect, GenericTable, useMsgBanner } from "@/components"; -import type { EventUsers, Users } from "@/entity"; +import type { EventUsers, Users } from "@floatctf/sdk/entity"; import { DatetimeToShow, useSelectedRowIds } from "@/util"; import { useCallback, useRef, useState } from "react"; import { AdminRouteGuard } from "../../route"; @@ -15,11 +15,6 @@ export const Route = createFileRoute("/admin/events/jeopardy/$id/users")({ loader: AdminRouteGuard, }); -export type EventUserResult = { - id: string; - user: Users; - event_user: EventUsers; -}; function RouteComponent() { const { id } = Route.useParams(); @@ -226,3 +221,10 @@ function AddUserButton({ ); } + +import type { + EventUserResult, +} from "@floatctf/sdk"; +export type { + EventUserResult, +}; diff --git a/apps/web/src/routes/admin/events/jeopardy.$id/writeups.tsx b/frontends/default/src/routes/admin/events/jeopardy.$id/writeups.tsx similarity index 98% rename from apps/web/src/routes/admin/events/jeopardy.$id/writeups.tsx rename to frontends/default/src/routes/admin/events/jeopardy.$id/writeups.tsx index 17c8cc03..a5dc10c4 100644 --- a/apps/web/src/routes/admin/events/jeopardy.$id/writeups.tsx +++ b/frontends/default/src/routes/admin/events/jeopardy.$id/writeups.tsx @@ -4,7 +4,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { EventWriteup } from "@/entity"; +import type { EventWriteup } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/admin/index.tsx b/frontends/default/src/routes/admin/index.tsx similarity index 100% rename from apps/web/src/routes/admin/index.tsx rename to frontends/default/src/routes/admin/index.tsx diff --git a/apps/web/src/routes/admin/logs.tsx b/frontends/default/src/routes/admin/logs.tsx similarity index 98% rename from apps/web/src/routes/admin/logs.tsx rename to frontends/default/src/routes/admin/logs.tsx index b0c09ccf..6c3a9fdb 100644 --- a/apps/web/src/routes/admin/logs.tsx +++ b/frontends/default/src/routes/admin/logs.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Logs } from "@/entity"; +import type { Logs } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { Label } from "@primer/react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/admin/route.tsx b/frontends/default/src/routes/admin/route.tsx similarity index 100% rename from apps/web/src/routes/admin/route.tsx rename to frontends/default/src/routes/admin/route.tsx diff --git a/apps/web/src/routes/admin/scheduled_tasks.tsx b/frontends/default/src/routes/admin/scheduled_tasks.tsx similarity index 99% rename from apps/web/src/routes/admin/scheduled_tasks.tsx rename to frontends/default/src/routes/admin/scheduled_tasks.tsx index 92350ce4..5c862a26 100644 --- a/apps/web/src/routes/admin/scheduled_tasks.tsx +++ b/frontends/default/src/routes/admin/scheduled_tasks.tsx @@ -1,7 +1,7 @@ import { adminApi } from "@/api"; import { GenericTable } from "@/components"; import { UnderlineNavButton } from "@/components/UnderlineNavButton"; -import type { ScheduledTasks } from "@/entity"; +import type { ScheduledTasks } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { CheckIcon, PlayIcon, XIcon } from "@primer/octicons-react"; import { diff --git a/apps/web/src/routes/admin/settings.tsx b/frontends/default/src/routes/admin/settings.tsx similarity index 97% rename from apps/web/src/routes/admin/settings.tsx rename to frontends/default/src/routes/admin/settings.tsx index 1da66ac4..6b9260b6 100644 --- a/apps/web/src/routes/admin/settings.tsx +++ b/frontends/default/src/routes/admin/settings.tsx @@ -3,10 +3,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { useReactive, useTitle } from "ahooks"; import { adminApi } from "@/api"; -import type { SettingsDto } from "@/api/admin/settings"; +import type { SettingsDto } from "@floatctf/sdk"; import { GenericTable } from "@/components"; import { MyTruncate } from "@/components/Truncate"; -import { SettingValueType } from "@/entity"; +import { SettingValueType } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { AdminRouteGuard } from "./route"; diff --git a/apps/web/src/routes/admin/super_admins.tsx b/frontends/default/src/routes/admin/super_admins.tsx similarity index 98% rename from apps/web/src/routes/admin/super_admins.tsx rename to frontends/default/src/routes/admin/super_admins.tsx index b6539203..ce5fbe2d 100644 --- a/apps/web/src/routes/admin/super_admins.tsx +++ b/frontends/default/src/routes/admin/super_admins.tsx @@ -13,7 +13,7 @@ import { useState } from "react"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { SuperAdmin } from "@/entity/super_admin"; +import type { SuperAdmin } from "@floatctf/sdk/entity/super_admin"; import { AdminRouteGuard } from "@/routes/admin/route"; export const Route = createFileRoute("/admin/super_admins")({ diff --git a/apps/web/src/routes/admin/terminal.tsx b/frontends/default/src/routes/admin/terminal.tsx similarity index 99% rename from apps/web/src/routes/admin/terminal.tsx rename to frontends/default/src/routes/admin/terminal.tsx index c1efcc42..f5920521 100644 --- a/apps/web/src/routes/admin/terminal.tsx +++ b/frontends/default/src/routes/admin/terminal.tsx @@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import { useEffect, useRef } from "react"; import "@xterm/xterm/css/xterm.css"; -import { admin_api } from "@/api/axios"; +import { admin_api } from "@floatctf/sdk"; import { ADMIN_API_URL } from "@/config"; export const Route = createFileRoute("/admin/terminal")({ diff --git a/apps/web/src/routes/admin/users.tsx b/frontends/default/src/routes/admin/users.tsx similarity index 97% rename from apps/web/src/routes/admin/users.tsx rename to frontends/default/src/routes/admin/users.tsx index 4c4af110..5305ce1a 100644 --- a/apps/web/src/routes/admin/users.tsx +++ b/frontends/default/src/routes/admin/users.tsx @@ -4,7 +4,7 @@ import { useReactive } from "ahooks"; import { adminApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Users } from "@/entity"; +import type { Users } from "@floatctf/sdk/entity"; import { AdminRouteGuard } from "@/routes/admin/route"; export const Route = createFileRoute("/admin/users")({ diff --git a/apps/web/src/routes/admin/version.tsx b/frontends/default/src/routes/admin/version.tsx similarity index 100% rename from apps/web/src/routes/admin/version.tsx rename to frontends/default/src/routes/admin/version.tsx diff --git a/apps/web/src/routes/admin/weapons.tsx b/frontends/default/src/routes/admin/weapons.tsx similarity index 99% rename from apps/web/src/routes/admin/weapons.tsx rename to frontends/default/src/routes/admin/weapons.tsx index 9aee2c53..c35b6423 100644 --- a/apps/web/src/routes/admin/weapons.tsx +++ b/frontends/default/src/routes/admin/weapons.tsx @@ -1,6 +1,6 @@ import { adminApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; -import type { Weapons } from "@/entity/weapons"; +import type { Weapons } from "@floatctf/sdk/entity/weapons"; import { DatetimeToShow } from "@/util"; import { CheckIcon } from "@primer/octicons-react"; import { ActionList, Stack, TextInput, ToggleSwitch } from "@primer/react"; diff --git a/apps/web/src/routes/index.tsx b/frontends/default/src/routes/index.tsx similarity index 100% rename from apps/web/src/routes/index.tsx rename to frontends/default/src/routes/index.tsx diff --git a/apps/web/src/routes/register.tsx b/frontends/default/src/routes/register.tsx similarity index 100% rename from apps/web/src/routes/register.tsx rename to frontends/default/src/routes/register.tsx diff --git a/apps/web/src/routes/reset.tsx b/frontends/default/src/routes/reset.tsx similarity index 100% rename from apps/web/src/routes/reset.tsx rename to frontends/default/src/routes/reset.tsx diff --git a/apps/web/src/routes/reset_password.tsx b/frontends/default/src/routes/reset_password.tsx similarity index 100% rename from apps/web/src/routes/reset_password.tsx rename to frontends/default/src/routes/reset_password.tsx diff --git a/apps/web/src/routes/service/announcements.tsx b/frontends/default/src/routes/service/announcements.tsx similarity index 97% rename from apps/web/src/routes/service/announcements.tsx rename to frontends/default/src/routes/service/announcements.tsx index d457ff1d..99d148bc 100644 --- a/apps/web/src/routes/service/announcements.tsx +++ b/frontends/default/src/routes/service/announcements.tsx @@ -3,7 +3,7 @@ import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Announcements } from "@/entity"; +import type { Announcements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; export const Route = createFileRoute("/service/announcements")({ diff --git a/apps/web/src/routes/service/awdp/runs.$runId/index.tsx b/frontends/default/src/routes/service/awdp/runs.$runId/index.tsx similarity index 98% rename from apps/web/src/routes/service/awdp/runs.$runId/index.tsx rename to frontends/default/src/routes/service/awdp/runs.$runId/index.tsx index 9438ab7b..3517f586 100644 --- a/apps/web/src/routes/service/awdp/runs.$runId/index.tsx +++ b/frontends/default/src/routes/service/awdp/runs.$runId/index.tsx @@ -5,14 +5,14 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useTitle } from "ahooks"; import { useMemo } from "react"; -import { awdpRunApi } from "@/api/awdpRuns"; +import { awdpRunApi } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { AwdpWorkbench, type AwdpWorkbenchViewModel, buildAwdpHistory, } from "@/components/awdp/AwdpWorkbench"; -import { useAwdpRunStream } from "@/hooks/useAwdpRunStream"; +import { useAwdpRunStream } from "@/api/react"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/awdp/runs/$runId/")({ diff --git a/apps/web/src/routes/service/awdp/runs.$runId/route.tsx b/frontends/default/src/routes/service/awdp/runs.$runId/route.tsx similarity index 100% rename from apps/web/src/routes/service/awdp/runs.$runId/route.tsx rename to frontends/default/src/routes/service/awdp/runs.$runId/route.tsx diff --git a/apps/web/src/routes/service/awdp/runs.$runId/writeup.tsx b/frontends/default/src/routes/service/awdp/runs.$runId/writeup.tsx similarity index 98% rename from apps/web/src/routes/service/awdp/runs.$runId/writeup.tsx rename to frontends/default/src/routes/service/awdp/runs.$runId/writeup.tsx index 96bcd0a9..fb119103 100644 --- a/apps/web/src/routes/service/awdp/runs.$runId/writeup.tsx +++ b/frontends/default/src/routes/service/awdp/runs.$runId/writeup.tsx @@ -2,7 +2,7 @@ import { Spinner, Truncate } from "@primer/react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { awdpRunApi } from "@/api/awdpRuns"; +import { awdpRunApi } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { useAuthStore } from "@/stores/AuthStore"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/challenge_sets/$id/index.tsx b/frontends/default/src/routes/service/challenge_sets/$id/index.tsx similarity index 97% rename from apps/web/src/routes/service/challenge_sets/$id/index.tsx rename to frontends/default/src/routes/service/challenge_sets/$id/index.tsx index b077d5d1..eb6e55c0 100644 --- a/apps/web/src/routes/service/challenge_sets/$id/index.tsx +++ b/frontends/default/src/routes/service/challenge_sets/$id/index.tsx @@ -1,5 +1,5 @@ import { TriangleDownIcon } from "@primer/octicons-react"; -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { Button, Label, SelectPanel, Spinner, TextInput } from "@primer/react"; import { Banner, DataTable, Dialog, Table } from "@primer/react/experimental"; @@ -11,10 +11,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { RemainingTimer } from "../../challenges/$id"; import { serviceApi } from "@/api"; -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { DatetimeToShow } from "@/util"; import type { AxiosError } from "axios"; diff --git a/apps/web/src/routes/service/challenge_sets/index.tsx b/frontends/default/src/routes/service/challenge_sets/index.tsx similarity index 95% rename from apps/web/src/routes/service/challenge_sets/index.tsx rename to frontends/default/src/routes/service/challenge_sets/index.tsx index 5fbd1e0c..d897fabb 100644 --- a/apps/web/src/routes/service/challenge_sets/index.tsx +++ b/frontends/default/src/routes/service/challenge_sets/index.tsx @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { ChallengeSets } from "@/entity"; +import type { ChallengeSets } from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/challenges/$id/index.tsx b/frontends/default/src/routes/service/challenges/$id/index.tsx similarity index 98% rename from apps/web/src/routes/service/challenges/$id/index.tsx rename to frontends/default/src/routes/service/challenges/$id/index.tsx index f36ca676..f5ded022 100644 --- a/apps/web/src/routes/service/challenges/$id/index.tsx +++ b/frontends/default/src/routes/service/challenges/$id/index.tsx @@ -1,5 +1,5 @@ import { Button, Label, ProgressBar, Spinner, TextInput } from "@primer/react"; -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; @@ -13,7 +13,7 @@ import { serviceApi } from "@/api"; import { challengeInstanceQueryOptions, challengeQueryOptions, -} from "@/api/queries"; +} from "@/api/react"; import { useMsgBanner } from "@/components"; import { ServiceRouteGuard } from "@/routes/service/route"; diff --git a/apps/web/src/routes/service/challenges/$id/route.tsx b/frontends/default/src/routes/service/challenges/$id/route.tsx similarity index 100% rename from apps/web/src/routes/service/challenges/$id/route.tsx rename to frontends/default/src/routes/service/challenges/$id/route.tsx diff --git a/apps/web/src/routes/service/challenges/$id/writeup.tsx b/frontends/default/src/routes/service/challenges/$id/writeup.tsx similarity index 93% rename from apps/web/src/routes/service/challenges/$id/writeup.tsx rename to frontends/default/src/routes/service/challenges/$id/writeup.tsx index a1e55d83..aa0f5dd6 100644 --- a/apps/web/src/routes/service/challenges/$id/writeup.tsx +++ b/frontends/default/src/routes/service/challenges/$id/writeup.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { serviceApi } from "@/api"; -import type { ChallengeWriteup, Challenges } from "@/entity"; +import type { ChallengeWriteup, Challenges } from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; @@ -11,14 +11,6 @@ export const Route = createFileRoute("/service/challenges/$id/writeup")({ component: RouteComponent, }); -export type ChallengeWriteupResult = { - id: string; - nickname: string; - avatar?: string; - email: string; - challenge: Challenges; - writeup: ChallengeWriteup; -}; function RouteComponent() { const { id } = Route.useParams(); const { data, isLoading, isError } = useQuery({ @@ -106,3 +98,10 @@ function RouteComponent() { ); } + +import type { + ChallengeWriteupResult, +} from "@floatctf/sdk"; +export type { + ChallengeWriteupResult, +}; diff --git a/apps/web/src/routes/service/challenges/index.tsx b/frontends/default/src/routes/service/challenges/index.tsx similarity index 97% rename from apps/web/src/routes/service/challenges/index.tsx rename to frontends/default/src/routes/service/challenges/index.tsx index 434708e6..2eee0239 100644 --- a/apps/web/src/routes/service/challenges/index.tsx +++ b/frontends/default/src/routes/service/challenges/index.tsx @@ -5,7 +5,7 @@ import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; import { AppLink } from "@/navigation"; import { ServiceRouteGuard } from "@/routes/service/route"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import { DatetimeToShow } from "@/util"; import { CheckIcon } from "@primer/octicons-react"; diff --git a/apps/web/src/routes/service/discussions/$id.tsx b/frontends/default/src/routes/service/discussions/$id.tsx similarity index 99% rename from apps/web/src/routes/service/discussions/$id.tsx rename to frontends/default/src/routes/service/discussions/$id.tsx index e01335f6..6be63429 100644 --- a/apps/web/src/routes/service/discussions/$id.tsx +++ b/frontends/default/src/routes/service/discussions/$id.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from "react"; import { serviceApi } from "@/api"; import { MDPlusEditor, useMsgBanner } from "@/components"; -import type { DiscussionComments, Discussions } from "@/entity"; +import type { DiscussionComments, Discussions } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; export const Route = createFileRoute("/service/discussions/$id")({ diff --git a/apps/web/src/routes/service/discussions/index.tsx b/frontends/default/src/routes/service/discussions/index.tsx similarity index 97% rename from apps/web/src/routes/service/discussions/index.tsx rename to frontends/default/src/routes/service/discussions/index.tsx index 3dce9168..13d8776d 100644 --- a/apps/web/src/routes/service/discussions/index.tsx +++ b/frontends/default/src/routes/service/discussions/index.tsx @@ -5,7 +5,7 @@ import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { DiscussionWithAuthor } from "@/api/service/discussions"; +import type { DiscussionWithAuthor } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/discussions/my.tsx b/frontends/default/src/routes/service/discussions/my.tsx similarity index 98% rename from apps/web/src/routes/service/discussions/my.tsx rename to frontends/default/src/routes/service/discussions/my.tsx index ba92447a..3c7fefb0 100644 --- a/apps/web/src/routes/service/discussions/my.tsx +++ b/frontends/default/src/routes/service/discussions/my.tsx @@ -6,7 +6,7 @@ import { useReactive, useTitle } from "ahooks"; import { serviceApi } from "@/api"; import { GenericTable, useMsgBanner } from "@/components"; -import type { DiscussionWithAuthor } from "@/api/service/discussions"; +import type { DiscussionWithAuthor } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/discussions/route.tsx b/frontends/default/src/routes/service/discussions/route.tsx similarity index 100% rename from apps/web/src/routes/service/discussions/route.tsx rename to frontends/default/src/routes/service/discussions/route.tsx diff --git a/apps/web/src/routes/service/events/awd.$id/gameboxes.tsx b/frontends/default/src/routes/service/events/awd.$id/gameboxes.tsx similarity index 97% rename from apps/web/src/routes/service/events/awd.$id/gameboxes.tsx rename to frontends/default/src/routes/service/events/awd.$id/gameboxes.tsx index 3ed51681..b1ee2e58 100644 --- a/apps/web/src/routes/service/events/awd.$id/gameboxes.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/gameboxes.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Button, Label, Spinner, useConfirm } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -7,8 +7,8 @@ import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import type { AxiosError } from "axios"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; -import type { AwdGameBox } from "@/api/awd"; +import { awdPlayerApi } from "@floatctf/sdk"; +import type { AwdGameBox } from "@floatctf/sdk"; import { useMsgBanner } from "@/components"; import { ServiceRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/service/events/awd.$id/index.tsx b/frontends/default/src/routes/service/events/awd.$id/index.tsx similarity index 99% rename from apps/web/src/routes/service/events/awd.$id/index.tsx rename to frontends/default/src/routes/service/events/awd.$id/index.tsx index 32235541..92eb4ab6 100644 --- a/apps/web/src/routes/service/events/awd.$id/index.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/index.tsx @@ -7,7 +7,7 @@ import dayjs from "dayjs"; import { Fragment, type FormEvent, useState } from "react"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; +import { awdPlayerApi } from "@floatctf/sdk"; import { EVENT_STATUS_LABEL, SubmitWriteup, @@ -17,7 +17,7 @@ import { import { AWD_PLAYER_QUERY_KEYS, invalidateAwdQueries, -} from "@/hooks/awdInvalidation"; +} from "@floatctf/react"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awd/$id/")({ diff --git a/apps/web/src/routes/service/events/awd.$id/route.tsx b/frontends/default/src/routes/service/events/awd.$id/route.tsx similarity index 96% rename from apps/web/src/routes/service/events/awd.$id/route.tsx rename to frontends/default/src/routes/service/events/awd.$id/route.tsx index 009ca718..92e9f5f8 100644 --- a/apps/web/src/routes/service/events/awd.$id/route.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/route.tsx @@ -7,13 +7,13 @@ import { useTitle } from "ahooks"; import { createContext } from "react"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; +import { awdPlayerApi } from "@floatctf/sdk"; import { RouterUnderlineNavItem } from "@/components/RouterUnderlineNavItem"; import { AwdEventProgress, playerProgressState, } from "@/components/awd/AwdEventProgress"; -import { useAwdEventStream } from "@/hooks/useAwdEventStream"; +import { useAwdEventStream } from "@/api/react"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awd/$id")({ diff --git a/apps/web/src/routes/service/events/awd.$id/scoreboard.tsx b/frontends/default/src/routes/service/events/awd.$id/scoreboard.tsx similarity index 97% rename from apps/web/src/routes/service/events/awd.$id/scoreboard.tsx rename to frontends/default/src/routes/service/events/awd.$id/scoreboard.tsx index 9b2cc470..712389e8 100644 --- a/apps/web/src/routes/service/events/awd.$id/scoreboard.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/scoreboard.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; import { useQuery } from "@tanstack/react-query"; @@ -7,7 +7,7 @@ import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import type { AxiosError } from "axios"; import { serviceApi } from "@/api"; -import type { AwdScoreRow } from "@/api/awd"; +import type { AwdScoreRow } from "@floatctf/sdk"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awd/$id/scoreboard")({ diff --git a/apps/web/src/routes/service/events/awd.$id/ssh.tsx b/frontends/default/src/routes/service/events/awd.$id/ssh.tsx similarity index 99% rename from apps/web/src/routes/service/events/awd.$id/ssh.tsx rename to frontends/default/src/routes/service/events/awd.$id/ssh.tsx index b942b36d..5515a76a 100644 --- a/apps/web/src/routes/service/events/awd.$id/ssh.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/ssh.tsx @@ -11,7 +11,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; +import { awdPlayerApi } from "@floatctf/sdk"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awd/$id/ssh")({ diff --git a/apps/web/src/routes/service/events/awd.$id/wireguard.tsx b/frontends/default/src/routes/service/events/awd.$id/wireguard.tsx similarity index 99% rename from apps/web/src/routes/service/events/awd.$id/wireguard.tsx rename to frontends/default/src/routes/service/events/awd.$id/wireguard.tsx index d61f47b9..aef9b89e 100644 --- a/apps/web/src/routes/service/events/awd.$id/wireguard.tsx +++ b/frontends/default/src/routes/service/events/awd.$id/wireguard.tsx @@ -6,7 +6,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; import { serviceApi } from "@/api"; -import { awdPlayerApi } from "@/api/awd"; +import { awdPlayerApi } from "@floatctf/sdk"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awd/$id/wireguard")({ diff --git a/apps/web/src/routes/service/events/awdp.$id/-workbench.tsx b/frontends/default/src/routes/service/events/awdp.$id/-workbench.tsx similarity index 98% rename from apps/web/src/routes/service/events/awdp.$id/-workbench.tsx rename to frontends/default/src/routes/service/events/awdp.$id/-workbench.tsx index 7bb7c3ae..cb89113d 100644 --- a/apps/web/src/routes/service/events/awdp.$id/-workbench.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/-workbench.tsx @@ -1,7 +1,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; -import { type AwdpOverview, awdpPlayerApi } from "@/api/awdp"; +import { type AwdpOverview, awdpPlayerApi } from "@floatctf/sdk"; import { AwdpWorkbench, type AwdpWorkbenchViewModel } from "@/components/awdp/AwdpWorkbench"; /** diff --git a/apps/web/src/routes/service/events/awdp.$id/announcement.tsx b/frontends/default/src/routes/service/events/awdp.$id/announcement.tsx similarity index 94% rename from apps/web/src/routes/service/events/awdp.$id/announcement.tsx rename to frontends/default/src/routes/service/events/awdp.$id/announcement.tsx index 2af9fa30..003ed118 100644 --- a/apps/web/src/routes/service/events/awdp.$id/announcement.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/announcement.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; import { useQuery } from "@tanstack/react-query"; @@ -7,7 +7,7 @@ import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import type { AxiosError } from "axios"; import { serviceApi } from "@/api"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; import { ServiceRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/service/events/awdp.$id/gameboxes.tsx b/frontends/default/src/routes/service/events/awdp.$id/gameboxes.tsx similarity index 100% rename from apps/web/src/routes/service/events/awdp.$id/gameboxes.tsx rename to frontends/default/src/routes/service/events/awdp.$id/gameboxes.tsx diff --git a/apps/web/src/routes/service/events/awdp.$id/index.tsx b/frontends/default/src/routes/service/events/awdp.$id/index.tsx similarity index 99% rename from apps/web/src/routes/service/events/awdp.$id/index.tsx rename to frontends/default/src/routes/service/events/awdp.$id/index.tsx index d3e0177a..edad4235 100644 --- a/apps/web/src/routes/service/events/awdp.$id/index.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/index.tsx @@ -14,7 +14,7 @@ import { computeEventStatus, useMsgInlineBanner, } from "@/components"; -import { ParticipantMode } from "@/entity"; +import { ParticipantMode } from "@floatctf/sdk/entity"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awdp/$id/")({ diff --git a/apps/web/src/routes/service/events/awdp.$id/rounds.tsx b/frontends/default/src/routes/service/events/awdp.$id/rounds.tsx similarity index 99% rename from apps/web/src/routes/service/events/awdp.$id/rounds.tsx rename to frontends/default/src/routes/service/events/awdp.$id/rounds.tsx index 3ac821fe..c6c67708 100644 --- a/apps/web/src/routes/service/events/awdp.$id/rounds.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/rounds.tsx @@ -9,7 +9,7 @@ import { awdpPlayerApi, type AwdpEvaluationDto, type AwdpRoundDto, -} from "@/api/awdp"; +} from "@floatctf/sdk"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awdp/$id/rounds")({ diff --git a/apps/web/src/routes/service/events/awdp.$id/route.tsx b/frontends/default/src/routes/service/events/awdp.$id/route.tsx similarity index 97% rename from apps/web/src/routes/service/events/awdp.$id/route.tsx rename to frontends/default/src/routes/service/events/awdp.$id/route.tsx index 9a08a787..7834b4cf 100644 --- a/apps/web/src/routes/service/events/awdp.$id/route.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/route.tsx @@ -8,7 +8,7 @@ import { createContext } from "react"; import { serviceApi } from "@/api"; import { RouterUnderlineNavItem } from "@/components/RouterUnderlineNavItem"; import { AwdpEventProgress } from "@/components/awdp/AwdpEventProgress"; -import { useAwdpEventStream } from "@/hooks/useAwdpEventStream"; +import { useAwdpEventStream } from "@/api/react"; import { ServiceRouteGuard } from "../../route"; export const Route = createFileRoute("/service/events/awdp/$id")({ diff --git a/apps/web/src/routes/service/events/awdp.$id/scoreboard.tsx b/frontends/default/src/routes/service/events/awdp.$id/scoreboard.tsx similarity index 90% rename from apps/web/src/routes/service/events/awdp.$id/scoreboard.tsx rename to frontends/default/src/routes/service/events/awdp.$id/scoreboard.tsx index 1c6ce059..e2d27f05 100644 --- a/apps/web/src/routes/service/events/awdp.$id/scoreboard.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/scoreboard.tsx @@ -1,10 +1,10 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import type { AxiosError } from "axios"; -import { type AwdpScoreboardDetail, awdpPlayerApi } from "@/api/awdp"; +import { type AwdpScoreboardDetail, awdpPlayerApi } from "@floatctf/sdk"; import { AwdpScoreboardView } from "@/components/awdp/AwdpScoreboard"; import { ServiceRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/service/events/awdp.$id/trend.tsx b/frontends/default/src/routes/service/events/awdp.$id/trend.tsx similarity index 90% rename from apps/web/src/routes/service/events/awdp.$id/trend.tsx rename to frontends/default/src/routes/service/events/awdp.$id/trend.tsx index 630e2887..2c87f6e9 100644 --- a/apps/web/src/routes/service/events/awdp.$id/trend.tsx +++ b/frontends/default/src/routes/service/events/awdp.$id/trend.tsx @@ -3,8 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import type { AxiosError } from "axios"; -import { type AwdpTrendItem, awdpPlayerApi } from "@/api/awdp"; -import type { UniResponse } from "@/api/axios"; +import { type AwdpTrendItem, awdpPlayerApi } from "@floatctf/sdk"; +import type { UniResponse } from "@floatctf/sdk"; import { TrendChart } from "@/routes/service/events/jeopardy.$id/trend"; import { ServiceRouteGuard } from "../../route"; diff --git a/apps/web/src/routes/service/events/index.tsx b/frontends/default/src/routes/service/events/index.tsx similarity index 90% rename from apps/web/src/routes/service/events/index.tsx rename to frontends/default/src/routes/service/events/index.tsx index fdb62943..80ff5739 100644 --- a/apps/web/src/routes/service/events/index.tsx +++ b/frontends/default/src/routes/service/events/index.tsx @@ -9,7 +9,7 @@ import { type EventTeams, EventFamily, type Events, -} from "@/entity"; +} from "@floatctf/sdk/entity"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; @@ -19,22 +19,8 @@ export const Route = createFileRoute("/service/events/")({ const filterKeys = ["title", "family", "allow_join"]; -export type EventInfo = { - id: string; - event: Events; - team_result?: EventTeamResult; - joined: boolean; -}; -export type EventTeamMemberResult = { - member_name: string; - member: EventTeamMembers; -}; -export type EventTeamResult = { - team: EventTeams; - members: EventTeamMemberResult[]; -}; function RouteComponent() { useTitle("Events | FloatCTF"); @@ -144,3 +130,14 @@ function RouteComponent() { /> ); } + +import type { + EventInfo, + EventTeamMemberResult, + EventTeamResult, +} from "@floatctf/sdk"; +export type { + EventInfo, + EventTeamMemberResult, + EventTeamResult, +}; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/announcement.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/announcement.tsx similarity index 94% rename from apps/web/src/routes/service/events/jeopardy.$id/announcement.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/announcement.tsx index a56c4712..81fe9562 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/announcement.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/announcement.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; import { useQuery } from "@tanstack/react-query"; @@ -7,7 +7,7 @@ import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; import type { AxiosError } from "axios"; import { serviceApi } from "@/api"; -import type { EventAnnouncements } from "@/entity"; +import type { EventAnnouncements } from "@floatctf/sdk/entity"; import { DatetimeToShow } from "@/util"; export const Route = createFileRoute( diff --git a/apps/web/src/routes/service/events/jeopardy.$id/challenges.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/challenges.tsx similarity index 95% rename from apps/web/src/routes/service/events/jeopardy.$id/challenges.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/challenges.tsx index 4d6a6583..c1b3c40c 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/challenges.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/challenges.tsx @@ -1,4 +1,4 @@ -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { CheckIcon, SparkleFillIcon, @@ -8,7 +8,7 @@ import { } from "@primer/octicons-react"; import { Button, Label, SelectPanel, Spinner, TextInput } from "@primer/react"; -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Banner, DataTable, Dialog, Table } from "@primer/react/experimental"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; @@ -20,7 +20,7 @@ import { RemainingTimer } from "../../challenges/$id"; import { serviceApi } from "@/api"; import { useMsgBanner } from "@/components"; -import type { ChallengesListItem } from "@/types/challengeDto"; +import type { ChallengesListItem } from "@floatctf/sdk"; import type { AxiosError } from "axios"; export const Route = createFileRoute("/service/events/jeopardy/$id/challenges")( { @@ -28,15 +28,6 @@ export const Route = createFileRoute("/service/events/jeopardy/$id/challenges")( }, ); -export type EventChallengeResult = { - /** 行唯一标识(后端不返回,前端由 challenge.id 合成,供 DataTable 行 key)。 */ - id: string; - challenge: ChallengesListItem; - current_points: number; - solved_count: number; - solved: boolean; - solved_no: number; -}; function RouteComponent() { const { id } = Route.useParams(); @@ -404,3 +395,10 @@ function ChallengeDialog({ ); } + +import type { + EventChallengeResult, +} from "@floatctf/sdk"; +export type { + EventChallengeResult, +}; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/index.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/index.tsx similarity index 98% rename from apps/web/src/routes/service/events/jeopardy.$id/index.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/index.tsx index 6642ea4b..9cfc6fd5 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/index.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/index.tsx @@ -8,9 +8,9 @@ import { Fragment, type FormEvent, useMemo, useState } from "react"; import { match } from "ts-pattern"; import { serviceApi } from "@/api"; -import { eventInfoQueryOptions } from "@/api/queries/service/events"; +import { eventInfoQueryOptions } from "@/api/react"; import { SubmitWriteup, useMsgInlineBanner } from "@/components"; -import { ParticipantMode } from "@/entity"; +import { ParticipantMode } from "@floatctf/sdk/entity"; import type { EventInfo } from ".."; export const Route = createFileRoute("/service/events/jeopardy/$id/")({ diff --git a/apps/web/src/routes/service/events/jeopardy.$id/instances.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/instances.tsx similarity index 91% rename from apps/web/src/routes/service/events/jeopardy.$id/instances.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/instances.tsx index 291debb1..9b8dc18b 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/instances.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/instances.tsx @@ -1,5 +1,5 @@ -import type { UniResponse } from "@/api/axios"; -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { UniResponse } from "@floatctf/sdk"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { Button, Spinner } from "@primer/react"; import { DataTable, Table } from "@primer/react/experimental"; @@ -13,12 +13,6 @@ import { useMsgBanner } from "@/components"; import { DatetimeToShow } from "@/util"; -export type EventInstanceResult = { - id: string; - instance: Instances; - challenge_name: string; - user_nickname: string; -}; export const Route = createFileRoute("/service/events/jeopardy/$id/instances")({ component: RouteComponent, }); @@ -119,3 +113,10 @@ function RouteComponent() { ); } + +import type { + EventInstanceResult, +} from "@floatctf/sdk"; +export type { + EventInstanceResult, +}; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/route.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/route.tsx similarity index 98% rename from apps/web/src/routes/service/events/jeopardy.$id/route.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/route.tsx index 4cc6a81f..57e341b1 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/route.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/route.tsx @@ -5,7 +5,7 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useTitle } from "ahooks"; import { useEffect, useState } from "react"; -import { eventInfoQueryOptions } from "@/api/queries/service/events"; +import { eventInfoQueryOptions } from "@/api/react"; import { RouterUnderlineNavItem as RouterNavItem } from "@/components/RouterUnderlineNavItem"; import { DatetimeToShow } from "@/util"; import dayjs from "dayjs"; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/scoreboard.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/scoreboard.tsx similarity index 94% rename from apps/web/src/routes/service/events/jeopardy.$id/scoreboard.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/scoreboard.tsx index 2c25226c..584a793b 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/scoreboard.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/scoreboard.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { CheckIcon, SparkleFillIcon, @@ -19,21 +19,7 @@ export const Route = createFileRoute("/service/events/jeopardy/$id/scoreboard")( component: RouteComponent, }, ); -export type ChallengeScoreboard = { - name: string; - solved: boolean; - solved_no: number; -}; -export type ScoreboardItem = { - id: string; - no: number; - name: string; - avatar?: string; - score: number; - solved_count: number; - challenges: ChallengeScoreboard[]; -}; function RouteComponent() { const { id } = Route.useParams(); @@ -175,3 +161,12 @@ export function ScoreBoard({ ); } + +import type { + ChallengeScoreboard, + ScoreboardItem, +} from "@floatctf/sdk"; +export type { + ChallengeScoreboard, + ScoreboardItem, +}; diff --git a/apps/web/src/routes/service/events/jeopardy.$id/trend.tsx b/frontends/default/src/routes/service/events/jeopardy.$id/trend.tsx similarity index 96% rename from apps/web/src/routes/service/events/jeopardy.$id/trend.tsx rename to frontends/default/src/routes/service/events/jeopardy.$id/trend.tsx index ad67f4a0..7380fd80 100644 --- a/apps/web/src/routes/service/events/jeopardy.$id/trend.tsx +++ b/frontends/default/src/routes/service/events/jeopardy.$id/trend.tsx @@ -1,4 +1,4 @@ -import type { UniResponse } from "@/api/axios"; +import type { UniResponse } from "@floatctf/sdk"; import { Spinner } from "@primer/react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; @@ -10,16 +10,7 @@ import { DatetimeToShow } from "@/util"; export const Route = createFileRoute("/service/events/jeopardy/$id/trend")({ component: RouteComponent, }); -export type TrendPoint = { - name: string; - score: number; // total score - time: string; // NaiveDateTime 可以用 ISO 字符串 -}; -export type TrendItem = { - name: string; - points: TrendPoint[]; -}; function RouteComponent() { const { id } = Route.useParams(); const { data, isLoading, isError, error } = useQuery< @@ -202,3 +193,12 @@ export const TrendChart: React.FC = ({ data, className }) => { ); }; + +import type { + TrendPoint, + TrendItem, +} from "@floatctf/sdk"; +export type { + TrendPoint, + TrendItem, +}; diff --git a/apps/web/src/routes/service/gameboxes.tsx b/frontends/default/src/routes/service/gameboxes.tsx similarity index 97% rename from apps/web/src/routes/service/gameboxes.tsx rename to frontends/default/src/routes/service/gameboxes.tsx index 04c22a8e..cb7cb99f 100644 --- a/apps/web/src/routes/service/gameboxes.tsx +++ b/frontends/default/src/routes/service/gameboxes.tsx @@ -2,7 +2,7 @@ import { useMutation } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useTitle } from "ahooks"; -import { type GameBoxCatalogDto, awdpRunApi } from "@/api/awdpRuns"; +import { type GameBoxCatalogDto, awdpRunApi } from "@floatctf/sdk"; import { GenericTable, useMsgBanner } from "@/components"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/index.tsx b/frontends/default/src/routes/service/index.tsx similarity index 100% rename from apps/web/src/routes/service/index.tsx rename to frontends/default/src/routes/service/index.tsx diff --git a/apps/web/src/routes/service/instances.tsx b/frontends/default/src/routes/service/instances.tsx similarity index 97% rename from apps/web/src/routes/service/instances.tsx rename to frontends/default/src/routes/service/instances.tsx index 96d26e8b..1c428dca 100644 --- a/apps/web/src/routes/service/instances.tsx +++ b/frontends/default/src/routes/service/instances.tsx @@ -1,6 +1,6 @@ import { Button, Label } from "@primer/react"; -import type { UniResponse } from "@/api/axios"; -import type { InstancesDto as Instances } from "@/api/service/instances"; +import type { UniResponse } from "@floatctf/sdk"; +import type { InstancesDto as Instances } from "@floatctf/sdk"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/service/profile.tsx b/frontends/default/src/routes/service/profile.tsx similarity index 99% rename from apps/web/src/routes/service/profile.tsx rename to frontends/default/src/routes/service/profile.tsx index 3e31156d..10f176b1 100644 --- a/apps/web/src/routes/service/profile.tsx +++ b/frontends/default/src/routes/service/profile.tsx @@ -8,7 +8,7 @@ import { useEffect, useRef, useState } from "react"; import { serviceApi } from "@/api"; import { useMsgBanner } from "@/components"; -import type { Users } from "@/entity"; +import type { Users } from "@floatctf/sdk/entity"; import { diffToPatch } from "@/util"; export const Route = createFileRoute("/service/profile")({ diff --git a/apps/web/src/routes/service/route.tsx b/frontends/default/src/routes/service/route.tsx similarity index 100% rename from apps/web/src/routes/service/route.tsx rename to frontends/default/src/routes/service/route.tsx diff --git a/apps/web/src/routes/service/solves.tsx b/frontends/default/src/routes/service/solves.tsx similarity index 96% rename from apps/web/src/routes/service/solves.tsx rename to frontends/default/src/routes/service/solves.tsx index d268dcc8..7126747d 100644 --- a/apps/web/src/routes/service/solves.tsx +++ b/frontends/default/src/routes/service/solves.tsx @@ -4,7 +4,7 @@ import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { SolveResult } from "@/api/service/solves"; +import type { SolveResult } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/top.tsx b/frontends/default/src/routes/service/top.tsx similarity index 94% rename from apps/web/src/routes/service/top.tsx rename to frontends/default/src/routes/service/top.tsx index 5fce4237..4bf8b769 100644 --- a/apps/web/src/routes/service/top.tsx +++ b/frontends/default/src/routes/service/top.tsx @@ -10,13 +10,6 @@ export const Route = createFileRoute("/service/top")({ component: RouteComponent, }); -export type TopUser = { - no: number; - nickname: string; - avatar?: string; - solved_count: number; - solved_last_at: string; -}; function RouteComponent() { useTitle("Top | FloatCTF"); @@ -78,3 +71,10 @@ function RouteComponent() { /> ); } + +import type { + TopUser, +} from "@floatctf/sdk"; +export type { + TopUser, +}; diff --git a/apps/web/src/routes/service/weapons.tsx b/frontends/default/src/routes/service/weapons.tsx similarity index 97% rename from apps/web/src/routes/service/weapons.tsx rename to frontends/default/src/routes/service/weapons.tsx index 3dfbc87d..baab52f1 100644 --- a/apps/web/src/routes/service/weapons.tsx +++ b/frontends/default/src/routes/service/weapons.tsx @@ -1,6 +1,6 @@ import { serviceApi } from "@/api"; import { GenericTable } from "@/components"; -import type { Weapons } from "@/entity/weapons"; +import type { Weapons } from "@floatctf/sdk/entity/weapons"; import { DatetimeToShow } from "@/util"; import { CheckIcon } from "@primer/octicons-react"; import { createFileRoute } from "@tanstack/react-router"; diff --git a/apps/web/src/routes/service/writeups/$id.tsx b/frontends/default/src/routes/service/writeups/$id.tsx similarity index 97% rename from apps/web/src/routes/service/writeups/$id.tsx rename to frontends/default/src/routes/service/writeups/$id.tsx index 16633103..7aeaed9b 100644 --- a/apps/web/src/routes/service/writeups/$id.tsx +++ b/frontends/default/src/routes/service/writeups/$id.tsx @@ -5,7 +5,7 @@ import MDEditor from "@uiw/react-md-editor"; import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; -import type { UnifiedWriteupDetail } from "@/api/service/challenges"; +import type { UnifiedWriteupDetail } from "@floatctf/sdk"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/routes/service/writeups/index.tsx b/frontends/default/src/routes/service/writeups/index.tsx similarity index 97% rename from apps/web/src/routes/service/writeups/index.tsx rename to frontends/default/src/routes/service/writeups/index.tsx index b0a34e40..23e97daa 100644 --- a/apps/web/src/routes/service/writeups/index.tsx +++ b/frontends/default/src/routes/service/writeups/index.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { useTitle } from "ahooks"; import { serviceApi } from "@/api"; -import type { UnifiedWriteupResult } from "@/api/service/challenges"; +import type { UnifiedWriteupResult } from "@floatctf/sdk"; import { GenericTable } from "@/components"; import { AppLink } from "@/navigation"; import { DatetimeToShow } from "@/util"; diff --git a/apps/web/src/stores/AuthStore.ts b/frontends/default/src/stores/AuthStore.ts similarity index 100% rename from apps/web/src/stores/AuthStore.ts rename to frontends/default/src/stores/AuthStore.ts diff --git a/apps/web/src/style.css b/frontends/default/src/style.css similarity index 100% rename from apps/web/src/style.css rename to frontends/default/src/style.css diff --git a/apps/web/src/util.ts b/frontends/default/src/util.ts similarity index 100% rename from apps/web/src/util.ts rename to frontends/default/src/util.ts diff --git a/frontends/default/tsconfig.json b/frontends/default/tsconfig.json new file mode 100644 index 00000000..4f3321d4 --- /dev/null +++ b/frontends/default/tsconfig.json @@ -0,0 +1,29 @@ +{ + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"], + "compilerOptions": { + "target": "ES2022", + "jsx": "react-jsx", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/frontends/default/vite.config.ts b/frontends/default/vite.config.ts new file mode 100644 index 00000000..906c497c --- /dev/null +++ b/frontends/default/vite.config.ts @@ -0,0 +1,198 @@ +import { defineConfig, type Plugin } from "vitest/config"; +import viteReact from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import pkg from "./package.json"; + +const FRONTEND_ID = "default"; +const FRONTEND_NAME = "FloatCTF Default Frontend"; +/** 与 packages/frontend-runtime/src/version.ts 保持一致(契约 major)。 */ +const FRONTEND_RUNTIME_CONTRACT = "1"; +const API_CONTRACT = "1"; +/** 生产制品入口固定文件名:注册表与 Caddy 缓存策略都依赖它的确定性。 */ +const ENTRY_FILE = "assets/frontend.js"; +const STYLE_FILE = "assets/frontend.css"; + +function manifestObject(styles: string[]) { + return { + schemaVersion: 1, + id: FRONTEND_ID, + name: FRONTEND_NAME, + version: pkg.version, + description: "FloatCTF 官方前端(当前完整 UI)", + author: "FloatCTF", + compatibility: { + frontendRuntime: FRONTEND_RUNTIME_CONTRACT, + apiContract: API_CONTRACT, + }, + entry: ENTRY_FILE, + styles, + }; +} + +/** + * 构建结束后写出 `frontend.json`,并用 `@floatctf/frontend-runtime` 的**真实校验器** + * 校验它。这样"能构建但装不上"(路径写成绝对路径、契约版本写错、ID 非法)在构建期 + * 就会失败,而不是等运维在服务器上安装时才发现。 + */ +function emitFrontendManifest(): Plugin { + return { + name: "floatctf:emit-frontend-manifest", + apply: "build", + async closeBundle() { + const outDir = resolve(__dirname, "dist"); + const styles = existsSync(resolve(outDir, STYLE_FILE)) ? [STYLE_FILE] : []; + const manifest = manifestObject(styles); + const { parseFrontendManifest } = await import("@floatctf/frontend-runtime"); + const parsed = parseFrontendManifest(manifest); + if (!parsed.ok) { + throw new Error( + `generated frontend.json does not satisfy the frontend runtime contract:\n${parsed.errors.join("\n")}`, + ); + } + if (!existsSync(resolve(outDir, ENTRY_FILE))) { + throw new Error(`frontend entry was not emitted: ${ENTRY_FILE}`); + } + mkdirSync(outDir, { recursive: true }); + writeFileSync( + resolve(outDir, "frontend.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + }, + }; +} + +/** + * DEV ONLY:让开发服务器同时提供"本地已安装前端注册表"。 + * + * 生产环境这份文件由 Caddy 从 `$FLOATCTF_HOME/frontends/registry.json` 提供。 + * 开发环境没有安装目录,但管理端的"前端选择器"要真实读取注册表,因此这里 + * 按注册表的**真实 schema** 生成一份只含 default 的开发注册表 —— 选择器在 dev + * 下走的是与生产完全相同的代码路径与数据格式。 + */ +function devRegistry(): Plugin { + const registry = { + schemaVersion: 1, + updatedAt: new Date(0).toISOString(), + frontends: { + [FRONTEND_ID]: { + id: FRONTEND_ID, + currentVersion: pkg.version, + protected: true, + versions: { + [pkg.version]: { + version: pkg.version, + name: FRONTEND_NAME, + description: "FloatCTF 官方前端(当前完整 UI)", + author: "FloatCTF", + compatibility: { + frontendRuntime: FRONTEND_RUNTIME_CONTRACT, + apiContract: API_CONTRACT, + }, + entry: ENTRY_FILE, + styles: [STYLE_FILE], + installedAt: new Date(0).toISOString(), + }, + }, + }, + }, + }; + return { + name: "floatctf:dev-registry", + apply: "serve", + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (!req.url?.startsWith("/__floatctf/frontends/registry.json")) { + next(); + return; + } + res.setHeader("Content-Type", "application/json"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(registry)); + }); + }, + }; +} + +export default defineConfig({ + // 生产制品由 bootstrap 从 /__floatctf/frontends/// 动态 import, + // 因此所有内部引用必须是相对路径(绝不写死站点根)。 + base: "./", + define: { + "import.meta.env.VITE_APP_VERSION": JSON.stringify(pkg.version), + }, + plugins: [ + // Vitest 下关闭路由代码分割:分割后的页面是懒加载组件, + // 单测里渲染路由页面会一直停在 Suspense fallback。开发/构建仍开启。 + TanStackRouterVite({ autoCodeSplitting: !process.env.VITEST }), + viteReact(), + tailwindcss(), + devRegistry(), + emitFrontendManifest(), + ], + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, + server: { + // host: true 让 dev server 监听 0.0.0.0, + // 这样 Caddy 容器可通过 host-gateway (172.17.0.1:13000) 反向代理。 + host: true, + watch: { + ignored: ["**/routeTree.gen.ts"], + }, + // 启动时预编译常用页面,避免开发时首次点击标签页要等编译。 + warmup: { + clientFiles: [ + "./src/routes/service/index.tsx", + "./src/routes/service/top.tsx", + "./src/routes/service/challenges/index.tsx", + "./src/routes/admin/index.tsx", + "./src/routes/admin/dashboard.tsx", + "./src/routes/admin/challenges.tsx", + ], + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + target: "esnext", + // 单文件 CSS:bootstrap 需要按 frontend.json 显式注入,文件名必须确定。 + cssCodeSplit: false, + // 前端自带框架与依赖(v1 不做共享 React 单例 / 模块联邦)。 + lib: { + entry: resolve(__dirname, "src/entry.tsx"), + formats: ["es"], + fileName: () => "assets/frontend.js", + }, + rollupOptions: { + output: { + entryFileNames: ENTRY_FILE, + chunkFileNames: "assets/[name]-[hash].js", + assetFileNames: (assetInfo) => + assetInfo.names?.some((name) => name.endsWith(".css")) + ? STYLE_FILE + : "assets/[name][extname]", + }, + }, + }, + test: { + // 与迁移前的 apps/web 一致:默认 Node 环境,需要 DOM 的用例在文件首行 + // 加 `// @vitest-environment jsdom`。 + environment: "node", + }, +}); + +/** package.json 的版本同时用于 manifest 与注册表,避免两处漂移。 */ +export const frontendPackageVersion = pkg.version; +export const frontendEntryFile = ENTRY_FILE; +export const frontendStyleFile = STYLE_FILE; + +// 让 IDE/测试能引用到读取到的 manifest(构建期由 emitFrontendManifest 校验)。 +export function readBuiltManifest(distDir = resolve(__dirname, "dist")) { + const raw = readFileSync(resolve(distDir, "frontend.json"), "utf8"); + return JSON.parse(raw) as Record; +} diff --git a/mise.toml b/mise.toml index 7ed03b2d..238cd928 100644 --- a/mise.toml +++ b/mise.toml @@ -72,10 +72,68 @@ watchexec --restart --exts rs,toml \ """ # 前端 +# 开发模式:先构建 workspace 包(frontend-runtime / sdk / react 的 dist 是前端 +# 解析 `@floatctf/*` 的真实产物),再启动 Default Frontend 的 Vite(HMR)。 +# 生产模式完全不经过这里:apps/web bootstrap → Caddy → 已安装的版本化制品。 [tasks."dev:web"] -description = "启动 Web 开发服务器" -dir = "apps/web" -run = "pnpm dev" +description = "构建 workspace 包并启动 Default Frontend 开发服务器(Vite HMR)" +run = """ +pnpm run build:packages +pnpm --filter @floatctf/frontend-default dev +""" + +# 只在开发 workspace 包本身时用:tsc --watch 持续刷新 packages/*/dist。 +[tasks."dev:packages"] +description = "以 watch 模式重建 workspace 包(改动 packages/* 时用)" +run = "pnpm run dev:packages" + +# Web 类型检查(与 CI 的 `vite build && tsc` 对齐;biome lint 抓不到类型错误) +[tasks."web:typecheck"] +description = "Web TypeScript 类型检查(全部 web 包 tsc --noEmit)" +run = """ +pnpm --filter @floatctf/frontend-runtime exec tsc -p tsconfig.build.json --noEmit +pnpm --filter @floatctf/sdk exec tsc -p tsconfig.build.json --noEmit +pnpm --filter @floatctf/react exec tsc -p tsconfig.build.json --noEmit +pnpm --filter @floatctf/web exec tsc --noEmit +pnpm --filter @floatctf/frontend-default exec tsc --noEmit +""" + +# 前端平台架构边界守卫(SDK 必须框架无关、默认前端不得逃出自身包、bootstrap 不得 +# 带上默认前端……见 docs/frontend/ARCHITECTURE.md)。 +[tasks."web:architecture"] +description = "校验可插拔前端架构边界与契约版本一致性" +run = "./scripts/check-architecture.sh" + +# 静态检查 +[tasks.lint] +description = "执行 Rust 和 Web 静态检查" +run = [ + "cargo clippy --workspace --all-targets --all-features", + "pnpm --filter @floatctf/frontend-runtime lint", + "pnpm --filter @floatctf/sdk lint", + "pnpm --filter @floatctf/react lint", + "pnpm --filter @floatctf/web lint", + "pnpm --filter @floatctf/frontend-default lint", + { task = "web:typecheck" }, + { task = "web:architecture" }, +] + +# 测试 +[tasks.test] +description = "执行 Rust 和 Web 测试" +run = [ + "./scripts/test-rust.sh", + "pnpm run test:web", +] + +# 构建 +[tasks.build] +description = "构建 API、workspace 包、bootstrap 与 Default Frontend" +run = [ + "cargo build --workspace --release", + "pnpm run build:packages", + "pnpm run build:web", +] # 唯一开发入口:基础设施 healthy → migrations → API watch + Vite。 [tasks.dev] @@ -107,42 +165,11 @@ run = [ description = "检查 Rust 格式" run = "cargo fmt --all -- --check" -# Web 类型检查(与 CI 的 `vite build && tsc` 对齐;biome lint 抓不到类型错误) -[tasks."web:typecheck"] -description = "Web TypeScript 类型检查(tsc --noEmit)" -dir = "apps/web" -run = "pnpm exec tsc --noEmit" - -# 静态检查 -[tasks.lint] -description = "执行 Rust 和 Web 静态检查" -run = [ - "cargo clippy --workspace --all-targets --all-features", - "pnpm --filter @floatctf/web lint", - { task = "web:typecheck" }, -] - -# 测试 -[tasks.test] -description = "执行 Rust 和 Web 测试" -run = [ - "./scripts/test-rust.sh", - "pnpm --filter @floatctf/web test", -] - # 检查 [tasks.check] description = "检查整个项目" depends = ["fmt", "lint", "test"] -# 构建 -[tasks.build] -description = "构建 API 和 Web" -run = [ - "cargo build --workspace --release", - "pnpm --filter @floatctf/web build", -] - [tasks."db:migration:new"] description = "创建新的数据库 migration" run = """ diff --git a/package.json b/package.json index c9f401a3..2080fbbf 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,12 @@ "private": true, "packageManager": "pnpm@11.20.0", "scripts": { - "dev:web": "pnpm --filter @floatctf/web dev", - "build:web": "pnpm --filter @floatctf/web build", - "lint:web": "pnpm --filter @floatctf/web lint", - "test:web": "pnpm --filter @floatctf/web test --passWithNoTests", - "check:web": "pnpm --filter @floatctf/web lint" + "build:packages": "pnpm --filter @floatctf/frontend-runtime --filter @floatctf/sdk --filter @floatctf/react build", + "dev:packages": "pnpm --parallel --filter @floatctf/frontend-runtime --filter @floatctf/sdk --filter @floatctf/react dev", + "dev:web": "pnpm --filter @floatctf/frontend-default dev", + "build:web": "pnpm --filter @floatctf/web build && pnpm --filter @floatctf/frontend-default build", + "lint:web": "pnpm --filter @floatctf/frontend-runtime --filter @floatctf/sdk --filter @floatctf/react --filter @floatctf/web --filter @floatctf/frontend-default lint", + "test:web": "pnpm --filter @floatctf/frontend-runtime --filter @floatctf/sdk --filter @floatctf/react --filter @floatctf/web --filter @floatctf/frontend-default test" }, "keywords": [], "author": "", diff --git a/packages/frontend-runtime/README.md b/packages/frontend-runtime/README.md new file mode 100644 index 00000000..577f20cd --- /dev/null +++ b/packages/frontend-runtime/README.md @@ -0,0 +1,27 @@ +# @floatctf/frontend-runtime + +FloatCTF pluggable frontend **artifact / runtime contract**. Framework-independent: +it does not depend on React, Vue, Svelte, Primer, a router, or a state library. + +- `frontend.json` manifest schema + strict validation (`parseFrontendManifest`) +- installed-frontend registry schema + resolution (`parseRegistry`, `resolveFrontend`) +- runtime module contract (`FloatCTFFrontendModule.mount(context)`) +- the bootstrap loader with break-glass fallback (`bootstrapFrontend`) + +Docs: [`docs/frontend/ARCHITECTURE.md`](../../docs/frontend/ARCHITECTURE.md), +[`docs/frontend/ARTIFACT.md`](../../docs/frontend/ARTIFACT.md). + +```ts +import { bootstrapFrontend } from "@floatctf/frontend-runtime"; + +await bootstrapFrontend({ root: document.getElementById("app")! }); +``` + +Contract versions are independent constants: + +| constant | meaning | +|---|---| +| `FRONTEND_RUNTIME_VERSION` | `mount(context)` / `frontend.json` contract major | +| `API_CONTRACT_VERSION` | frontend-visible HTTP API contract major | +| `FRONTEND_REGISTRY_SCHEMA_VERSION` | local `registry.json` schema version | +| `FRONTEND_MANIFEST_SCHEMA_VERSION` | `frontend.json` document schema version | diff --git a/packages/frontend-runtime/package.json b/packages/frontend-runtime/package.json new file mode 100644 index 00000000..617a93a8 --- /dev/null +++ b/packages/frontend-runtime/package.json @@ -0,0 +1,34 @@ +{ + "name": "@floatctf/frontend-runtime", + "version": "1.0.0", + "description": "FloatCTF pluggable frontend artifact/runtime contract (framework independent)", + "license": "AGPL-3.0-only", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", + "test": "vitest run", + "lint": "biome lint src", + "typecheck": "tsc -p tsconfig.build.json --noEmit" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.5" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/frontend-runtime/src/__tests__/bootstrap.test.ts b/packages/frontend-runtime/src/__tests__/bootstrap.test.ts new file mode 100644 index 00000000..242c3718 --- /dev/null +++ b/packages/frontend-runtime/src/__tests__/bootstrap.test.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { bootstrapFrontend } from "../bootstrap"; +import type { FloatCTFFrontendModule } from "../module"; + +const REGISTRY = { + schemaVersion: 1, + updatedAt: "2026-10-07T00:00:00.000Z", + frontends: { + default: { + id: "default", + currentVersion: "1.0.0", + protected: true, + versions: { + "1.0.0": { + version: "1.0.0", + name: "Default Frontend", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/frontend.js", + styles: ["assets/frontend.css"], + installedAt: "2026-10-07T00:00:00.000Z", + }, + }, + }, + cyberpunk: { + id: "cyberpunk", + currentVersion: "1.0.0", + versions: { + "1.0.0": { + version: "1.0.0", + name: "Cyberpunk", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/frontend.js", + styles: ["assets/frontend.css"], + installedAt: "2026-10-07T00:00:00.000Z", + }, + }, + }, + "legacy-runtime": { + id: "legacy-runtime", + currentVersion: "1.0.0", + versions: { + "1.0.0": { + version: "1.0.0", + name: "Legacy", + compatibility: { frontendRuntime: "9", apiContract: "9" }, + entry: "assets/frontend.js", + styles: [], + installedAt: "2026-10-07T00:00:00.000Z", + }, + }, + }, + }, +}; + +function makeFetch(options: { + activeFrontend?: string; + bootstrapStatus?: number; + registryStatus?: number; +}): typeof fetch { + const activeFrontend = options.activeFrontend ?? "default"; + return (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/frontend")) { + if (options.bootstrapStatus && options.bootstrapStatus !== 200) { + return new Response("nope", { status: options.bootstrapStatus }); + } + return new Response( + JSON.stringify({ + code: 0, + message: "ok", + data: { + active_frontend: activeFrontend, + platform_version: "1.0.0", + api_contract_version: "1", + frontend_runtime_version: "1", + capabilities: ["awd", "awdp", "jeopardy"], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (options.registryStatus && options.registryStatus !== 200) { + return new Response("nope", { status: options.registryStatus }); + } + return new Response(JSON.stringify(REGISTRY), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; +} + +function mountableModule(label: string): FloatCTFFrontendModule { + return { + mount: (context) => { + context.root.textContent = label; + }, + }; +} + +describe("bootstrapFrontend", () => { + let root: HTMLElement; + let emergency: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = '
'; + root = document.getElementById("app") as HTMLElement; + emergency = document.getElementById("emergency") as HTMLElement; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.head.innerHTML = ""; + }); + + it("loads the active frontend and mounts it with a safe context", async () => { + const contexts: unknown[] = []; + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async () => ({ + mount: (context: any) => { + contexts.push(context); + context.root.textContent = "cyberpunk"; + }, + }), + loadStyle: () => document.createElement("link"), + }); + + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("cyberpunk"); + expect(root.textContent).toBe("cyberpunk"); + expect(contexts).toHaveLength(1); + const context = contexts[0] as Record; + expect(context.apiBaseUrl).toBe("/api"); + expect(context.assetBaseUrl).toBe("/__floatctf/frontends/cyberpunk/1.0.0"); + expect(context.capabilities).toEqual(["awd", "awdp", "jeopardy"]); + // 契约上不得出现路由/页面概念 + expect(context).not.toHaveProperty("router"); + expect(context).not.toHaveProperty("queryClient"); + }); + + it("imports only registry-derived same-origin URLs", async () => { + const imported: string[] = []; + await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => { + imported.push(url); + return mountableModule("x"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(imported).toEqual(["/__floatctf/frontends/cyberpunk/1.0.0/assets/frontend.js"]); + }); + + it("falls back to default when active frontend is not installed", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "not-installed" }), + importModule: async (url) => { + if (url.includes("/default/")) return mountableModule("default"); + throw new Error("should not be imported"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(root.textContent).toBe("default"); + expect(result.diagnostics.errors.join("\n")).toContain("is not installed"); + }); + + it("falls back to default when the active frontend's dynamic import fails", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => { + if (url.includes("/cyberpunk/")) throw new Error("chunk load error"); + return mountableModule("default"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.attempted).toEqual(["cyberpunk", "default"]); + expect(result.diagnostics.errors.join("\n")).toContain("chunk load error"); + }); + + it("falls back to default when the active frontend throws during mount", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => { + if (url.includes("/cyberpunk/")) { + return { + mount: () => { + throw new Error("mount exploded"); + }, + }; + } + return mountableModule("default"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + }); + + it("falls back to default when the active frontend is incompatible", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "legacy-runtime" }), + importModule: async () => mountableModule("default"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.errors.join("\n")).toContain("requires frontend runtime 9"); + }); + + it("renders the emergency UI when even the default frontend fails", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async () => { + throw new Error("all chunks are gone"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(false); + expect(emergency.querySelector("[data-floatctf-bootstrap-error]")).not.toBeNull(); + expect(emergency.textContent).toContain("FloatCTF 界面加载失败"); + expect(emergency.textContent).toContain("all chunks are gone"); + }); + + it("renders the emergency UI when the registry is unavailable", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ registryStatus: 500 }), + importModule: async () => mountableModule("default"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(false); + expect(emergency.querySelector("[data-floatctf-bootstrap-error]")).not.toBeNull(); + }); + + it("uses the default frontend when /api/frontend is unavailable", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ bootstrapStatus: 500 }), + importModule: async () => mountableModule("default"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.errors.join("\n")).toContain("bootstrap info unavailable"); + }); + + describe("?frontend= break-glass override", () => { + it("honours an installed override id regardless of FRONTEND_ACTIVE", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + overrideFrontendId: "default", + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => + url.includes("/default/") ? mountableModule("default") : mountableModule("cyberpunk"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.overrideFrontendId).toBe("default"); + expect(result.diagnostics.attempted).toEqual(["default"]); + }); + + it("ignores an override that is not installed", async () => { + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + overrideFrontendId: "attacker-frontend", + fetchImpl: makeFetch({ activeFrontend: "default" }), + importModule: async () => mountableModule("default"), + loadStyle: () => document.createElement("link"), + }); + expect(result.mounted).toBe(true); + expect(result.frontendId).toBe("default"); + expect(result.diagnostics.overrideFrontendId).toBeUndefined(); + expect(result.diagnostics.errors.join("\n")).toContain("ignored ?frontend=attacker-frontend"); + }); + + it("ignores an unsafe override value", async () => { + const imported: string[] = []; + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + overrideFrontendId: "../../etc/passwd", + fetchImpl: makeFetch({ activeFrontend: "default" }), + importModule: async (url) => { + imported.push(url); + return mountableModule("default"); + }, + loadStyle: () => document.createElement("link"), + }); + expect(result.frontendId).toBe("default"); + expect(imported.every((url) => !url.includes(".."))).toBe(true); + }); + }); + + it("injects the registry-declared stylesheet and removes the failed frontend's styles on fallback", async () => { + const injected: string[] = []; + const result = await bootstrapFrontend({ + root, + emergencyHost: emergency, + fetchImpl: makeFetch({ activeFrontend: "cyberpunk" }), + importModule: async (url) => { + if (url.includes("/cyberpunk/")) throw new Error("boom"); + return mountableModule("default"); + }, + loadStyle: (url) => { + injected.push(url); + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + document.head.appendChild(link); + return link; + }, + }); + expect(result.frontendId).toBe("default"); + expect(injected).toEqual([ + "/__floatctf/frontends/cyberpunk/1.0.0/assets/frontend.css", + "/__floatctf/frontends/default/1.0.0/assets/frontend.css", + ]); + const remaining = Array.from(document.head.querySelectorAll("link[rel=stylesheet]")).map( + (link) => (link as HTMLLinkElement).getAttribute("href"), + ); + expect(remaining).toEqual(["/__floatctf/frontends/default/1.0.0/assets/frontend.css"]); + }); +}); diff --git a/packages/frontend-runtime/src/__tests__/manifest.test.ts b/packages/frontend-runtime/src/__tests__/manifest.test.ts new file mode 100644 index 00000000..f927fd5b --- /dev/null +++ b/packages/frontend-runtime/src/__tests__/manifest.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; + +import { parseFrontendManifest } from "../manifest"; + +function validManifest(overrides: Record = {}) { + return { + schemaVersion: 1, + id: "cyberpunk", + name: "Cyberpunk Frontend", + version: "1.2.0", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/frontend.js", + styles: ["assets/frontend.css"], + ...overrides, + }; +} + +describe("parseFrontendManifest", () => { + it("accepts a valid manifest", () => { + const result = parseFrontendManifest(validManifest()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.id).toBe("cyberpunk"); + expect(result.manifest.entry).toBe("assets/frontend.js"); + expect(result.manifest.styles).toEqual(["assets/frontend.css"]); + }); + + it("accepts a JSON string input", () => { + const result = parseFrontendManifest(JSON.stringify(validManifest())); + expect(result.ok).toBe(true); + }); + + it("rejects an unsupported schemaVersion", () => { + const result = parseFrontendManifest(validManifest({ schemaVersion: 2 })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("unsupported schemaVersion"); + }); + + it("rejects a missing schemaVersion", () => { + const manifest = validManifest(); + // biome-ignore lint/performance/noDelete: 测试需要构造缺字段输入 + delete (manifest as Record).schemaVersion; + const result = parseFrontendManifest(manifest); + expect(result.ok).toBe(false); + }); + + it.each([ + "Cyberpunk", + "../evil", + "a/b", + "-leading-dash", + "", + "with space", + "UPPER", + ])("rejects unsafe frontend id %j", (id) => { + const result = parseFrontendManifest(validManifest({ id })); + expect(result.ok).toBe(false); + }); + + it.each(["1.0", "v1.0.0", "1", "not-a-version", "01.2.3"])( + "rejects invalid semver version %j", + (version) => { + const result = parseFrontendManifest(validManifest({ version })); + expect(result.ok).toBe(false); + }, + ); + + it("accepts prerelease semver", () => { + const result = parseFrontendManifest(validManifest({ version: "1.2.0-rc.1" })); + expect(result.ok).toBe(true); + }); + + it.each([ + "../outside.js", + "assets/../../outside.js", + "/absolute.js", + "https://cdn.example.com/frontend.js", + "data:text/javascript,alert(1)", + "assets\\windows.js", + "./assets/frontend.js", + "assets//frontend.js", + ])("rejects traversal / absolute / scheme entry %j", (entry) => { + const result = parseFrontendManifest(validManifest({ entry })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("entry"); + }); + + it.each([ + "../outside.css", + "/absolute.css", + "https://cdn.example.com/style.css", + "assets/../../outside.css", + ])("rejects traversal / absolute style %j", (style) => { + const result = parseFrontendManifest(validManifest({ styles: [style] })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("styles[0]"); + }); + + it("rejects an incompatible frontend runtime major", () => { + const result = parseFrontendManifest( + validManifest({ + compatibility: { frontendRuntime: "2", apiContract: "1" }, + }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("compatibility.frontendRuntime"); + }); + + it("rejects an incompatible API contract major", () => { + const result = parseFrontendManifest( + validManifest({ + compatibility: { frontendRuntime: "1", apiContract: "3" }, + }), + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("compatibility.apiContract"); + }); + + it("rejects semver-range style compatibility constraints (v1 is integer major only)", () => { + const result = parseFrontendManifest( + validManifest({ + compatibility: { frontendRuntime: ">=1 <2", apiContract: "1" }, + }), + ); + expect(result.ok).toBe(false); + }); + + it("rejects unknown top-level fields (fail-closed)", () => { + const result = parseFrontendManifest(validManifest({ sidebar: true })); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("unknown field"); + }); + + it("treats compatibility.sdk as informational", () => { + const result = parseFrontendManifest( + validManifest({ + compatibility: { frontendRuntime: "1", apiContract: "1", sdk: "1.0.0" }, + }), + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.warnings.join("\n")).toContain("informational"); + }); + + it("rejects malformed JSON strings", () => { + const result = parseFrontendManifest("{not json"); + expect(result.ok).toBe(false); + }); + + it("rejects a non-object manifest", () => { + expect(parseFrontendManifest("[]").ok).toBe(false); + expect(parseFrontendManifest("null").ok).toBe(false); + }); + + it("validates against injected contract versions", () => { + const result = parseFrontendManifest( + validManifest({ compatibility: { frontendRuntime: "2", apiContract: "2" } }), + { frontendRuntimeVersion: "2", apiContractVersion: "2" }, + ); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/frontend-runtime/src/__tests__/registry.test.ts b/packages/frontend-runtime/src/__tests__/registry.test.ts new file mode 100644 index 00000000..8b769ed1 --- /dev/null +++ b/packages/frontend-runtime/src/__tests__/registry.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vitest"; + +import { + type FloatCTFRegistry, + emptyRegistry, + parseRegistry, + registerFrontendVersion, + resolveFrontend, +} from "../registry"; + +function versionEntry(overrides: Record = {}) { + return { + version: "1.0.0", + name: "Default Frontend", + compatibility: { frontendRuntime: "1", apiContract: "1" }, + entry: "assets/frontend.js", + styles: ["assets/frontend.css"], + installedAt: "2026-10-07T00:00:00.000Z", + ...overrides, + }; +} + +function validRegistry(): unknown { + return { + schemaVersion: 1, + updatedAt: "2026-10-07T00:00:00.000Z", + frontends: { + default: { + id: "default", + currentVersion: "1.0.0", + protected: true, + versions: { "1.0.0": versionEntry() }, + }, + cyberpunk: { + id: "cyberpunk", + currentVersion: "1.2.0", + versions: { + "1.1.0": versionEntry({ version: "1.1.0", name: "Cyberpunk" }), + "1.2.0": versionEntry({ version: "1.2.0", name: "Cyberpunk" }), + }, + }, + }, + }; +} + +describe("parseRegistry", () => { + it("accepts a valid registry and keeps explicit current versions", () => { + const result = parseRegistry(validRegistry()); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(Object.keys(result.registry.frontends).sort()).toEqual([ + "cyberpunk", + "default", + ]); + expect(result.registry.frontends.cyberpunk.currentVersion).toBe("1.2.0"); + }); + + it("rejects an unsupported registry schema version", () => { + const raw = validRegistry() as Record; + raw.schemaVersion = 2; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("unsupported schemaVersion"); + }); + + it("rejects a currentVersion that is not installed", () => { + const raw = validRegistry() as Record; + raw.frontends.cyberpunk.currentVersion = "9.9.9"; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("currentVersion"); + }); + + it("rejects traversal paths inside the registry", () => { + const raw = validRegistry() as Record; + raw.frontends.cyberpunk.versions["1.2.0"].entry = "../../../evil.js"; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + }); + + it("rejects unsafe frontend ids", () => { + const raw = validRegistry() as Record; + raw.frontends["../evil"] = raw.frontends.cyberpunk; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("not a safe id"); + }); + + it("rejects an empty frontend set", () => { + const raw = validRegistry() as Record; + raw.frontends = {}; + const result = parseRegistry(raw); + expect(result.ok).toBe(false); + }); + + it("rejects malformed JSON", () => { + expect(parseRegistry("{").ok).toBe(false); + }); +}); + +describe("registerFrontendVersion", () => { + const now = "2026-10-07T00:00:00.000Z"; + + it("rejects duplicate frontend/version registration", () => { + const base = emptyRegistry(now); + const first = registerFrontendVersion(base, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.0.0" }, + }); + expect(first.ok).toBe(true); + if (!first.ok) return; + const second = registerFrontendVersion(first.registry, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.0.0" }, + }); + expect(second.ok).toBe(false); + if (second.ok) return; + expect(second.error).toContain("already installed"); + }); + + it("keeps the existing currentVersion unless makeCurrent is requested", () => { + const base = emptyRegistry(now); + const first = registerFrontendVersion(base, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.0.0" }, + }); + if (!first.ok) throw new Error(first.error); + const second = registerFrontendVersion(first.registry, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.1.0" }, + }); + if (!second.ok) throw new Error(second.error); + expect(second.registry.frontends.cyberpunk.currentVersion).toBe("1.0.0"); + + const third = registerFrontendVersion( + second.registry, + { id: "cyberpunk", version: { ...versionEntry(), version: "1.1.0" } }, + false, + ); + expect(third.ok).toBe(false); + }); + + it("moves the pointer when makeCurrent is requested", () => { + const base = emptyRegistry(now); + const first = registerFrontendVersion(base, { + id: "cyberpunk", + version: { ...versionEntry(), version: "1.0.0" }, + }); + if (!first.ok) throw new Error(first.error); + const second = registerFrontendVersion( + first.registry, + { id: "cyberpunk", version: { ...versionEntry(), version: "1.1.0" } }, + true, + ); + if (!second.ok) throw new Error(second.error); + expect(second.registry.frontends.cyberpunk.currentVersion).toBe("1.1.0"); + }); + + it("rejects unsafe ids and invalid versions", () => { + const base = emptyRegistry(now); + expect( + registerFrontendVersion(base, { id: "../x", version: versionEntry() }).ok, + ).toBe(false); + expect( + registerFrontendVersion(base, { + id: "ok", + version: { ...versionEntry(), version: "1.0" }, + }).ok, + ).toBe(false); + }); +}); + +describe("resolveFrontend", () => { + function parsed(): FloatCTFRegistry { + const result = parseRegistry(validRegistry()); + if (!result.ok) throw new Error(result.errors.join("; ")); + return result.registry; + } + + it("resolves same-origin asset URLs from validated relative paths", () => { + const result = resolveFrontend(parsed(), { + requestedId: "cyberpunk", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.frontend.version).toBe("1.2.0"); + expect(result.frontend.entryUrl).toBe( + "/__floatctf/frontends/cyberpunk/1.2.0/assets/frontend.js", + ); + expect(result.frontend.styleUrls).toEqual([ + "/__floatctf/frontends/cyberpunk/1.2.0/assets/frontend.css", + ]); + }); + + it("fails when the requested frontend is not installed (caller falls back to default)", () => { + const result = resolveFrontend(parsed(), { + requestedId: "does-not-exist", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("is not installed"); + }); + + it("fails on an unsafe requested id without touching the filesystem layout", () => { + const result = resolveFrontend(parsed(), { + requestedId: "../../etc/passwd", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(false); + }); + + it("fails on incompatible runtime/API contract majors", () => { + const registry = parsed(); + registry.frontends.cyberpunk.versions["1.2.0"].compatibility = { + frontendRuntime: "2", + apiContract: "1", + }; + const result = resolveFrontend(registry, { + requestedId: "cyberpunk", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.errors.join("\n")).toContain("requires frontend runtime 2"); + + const registry2 = parsed(); + registry2.frontends.cyberpunk.versions["1.2.0"].compatibility = { + frontendRuntime: "1", + apiContract: "7", + }; + const result2 = resolveFrontend(registry2, { + requestedId: "cyberpunk", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result2.ok).toBe(false); + }); + + it("never picks by lexicographic ordering (9.0.0 is not chosen over 10.0.0)", () => { + const registry = parseRegistry({ + schemaVersion: 1, + updatedAt: "2026-10-07T00:00:00.000Z", + frontends: { + demo: { + id: "demo", + currentVersion: "9.0.0", + versions: { + "9.0.0": versionEntry({ version: "9.0.0" }), + "10.0.0": versionEntry({ version: "10.0.0" }), + }, + }, + }, + }); + expect(registry.ok).toBe(true); + if (!registry.ok) return; + const result = resolveFrontend(registry.registry, { + requestedId: "demo", + frontendBaseUrl: "/__floatctf/frontends", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.frontend.version).toBe("9.0.0"); + }); +}); diff --git a/packages/frontend-runtime/src/bootstrap.ts b/packages/frontend-runtime/src/bootstrap.ts new file mode 100644 index 00000000..aa022c08 --- /dev/null +++ b/packages/frontend-runtime/src/bootstrap.ts @@ -0,0 +1,320 @@ +/** + * FloatCTF 前端 bootstrap 加载器(框架无关,**不依赖 React**)。 + * + * 职责边界(与 apps/web 分工): + * - `apps/web` 是极薄的宿主:读 DOM/URL、调用本函数、渲染兜底 UI 的容器。 + * - 本函数负责:取公开 bootstrap 信息 → 读本地注册表 → 解析 → 兼容性校验 → + * 注入样式 → 动态 import 同源 ESM → `mount(context)` → 失败回退 → 兜底 UI。 + * + * 安全不变量: + * - **永不** import 任意远端 URL:只 import 由"已校验的相对路径 + 同源前缀"拼出的地址。 + * - `?frontend=` 只接受**注册表中已安装**的安全 ID,绝不直接拼进路径。 + * - 兜底 UI 只用 `textContent`,不把外部字符串当 HTML。 + */ + +import { + DEFAULT_API_BASE_URL, + DEFAULT_FRONTEND_BASE_URL, + DEFAULT_FRONTEND_ID, + DEFAULT_REGISTRY_URL, + API_CONTRACT_VERSION, + FRONTEND_RUNTIME_VERSION, +} from "./version.js"; +import { isSafeFrontendId } from "./paths.js"; +import { + type FloatCTFRegistry, + type ResolvedFrontend, + parseRegistry, + resolveFrontend, +} from "./registry.js"; +import { + type FloatCTFFrontendImport, + type FloatCTFFrontendModule, + normalizeFrontendModule, +} from "./module.js"; +import { + type BootstrapDiagnostics, + renderBootstrapEmergencyUi, +} from "./emergency.js"; + +/** `GET /api/frontend` 的响应(**仅**安全 bootstrap 元数据)。 */ +export interface FloatCTFBootstrapInfo { + active_frontend: string; + platform_version: string; + api_contract_version: string; + frontend_runtime_version: string; + capabilities: string[]; +} + +export interface BootstrapFrontendOptions { + /** 前端渲染宿主元素(会被清空)。 */ + root: HTMLElement; + /** API base URL,默认 `/api`。 */ + apiBaseUrl?: string; + /** 注册表 URL,默认 `/__floatctf/frontends/registry.json`。 */ + registryUrl?: string; + /** 前端资产前缀,默认 `/__floatctf/frontends`。 */ + frontendBaseUrl?: string; + /** 兜底 UI 宿主,默认 `document.body`。 */ + emergencyHost?: HTMLElement; + /** + * 浏览器本地紧急覆盖(`?frontend=`)。 + * 只接受注册表里已安装的安全 ID;其他值一律忽略并记入诊断。 + */ + overrideFrontendId?: string | null; + /** 回退用的前端 ID,默认 `default`。 */ + fallbackFrontendId?: string; + /** 注入依赖(测试用)。 */ + fetchImpl?: typeof fetch; + /** 动态 import 实现(测试用)。 */ + importModule?: (url: string) => Promise; + /** 样式注入实现(测试用);返回该 link 元素便于回退时移除。 */ + loadStyle?: (url: string, doc: Document) => HTMLElement; + /** 诊断回调(测试/日志用)。 */ + onDiagnostics?: (diagnostics: BootstrapDiagnostics) => void; +} + +export interface BootstrapFrontendResult { + mounted: boolean; + frontendId?: string; + frontendVersion?: string; + diagnostics: BootstrapDiagnostics; +} + +const STYLE_ATTR = "data-floatctf-frontend-style"; + +function defaultImportModule(url: string): Promise { + // 保持真正的运行时动态 import:URL 由已校验的注册表推导,绝不静态打包。 + return import(/* @vite-ignore */ url); +} + +function defaultLoadStyle(url: string, doc: Document): HTMLElement { + const link = doc.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + link.setAttribute(STYLE_ATTR, "true"); + doc.head.appendChild(link); + return link; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** 解析 `GET /api/frontend` 响应;未知/缺失字段一律回落到安全默认值。 */ +export function parseBootstrapInfo(input: unknown): FloatCTFBootstrapInfo { + const fallback: FloatCTFBootstrapInfo = { + active_frontend: DEFAULT_FRONTEND_ID, + platform_version: "", + api_contract_version: API_CONTRACT_VERSION, + frontend_runtime_version: FRONTEND_RUNTIME_VERSION, + capabilities: [], + }; + if (!isRecord(input)) return fallback; + return { + active_frontend: + typeof input.active_frontend === "string" ? input.active_frontend : DEFAULT_FRONTEND_ID, + platform_version: typeof input.platform_version === "string" ? input.platform_version : "", + api_contract_version: + typeof input.api_contract_version === "string" + ? input.api_contract_version + : API_CONTRACT_VERSION, + frontend_runtime_version: + typeof input.frontend_runtime_version === "string" + ? input.frontend_runtime_version + : FRONTEND_RUNTIME_VERSION, + capabilities: Array.isArray(input.capabilities) + ? input.capabilities.filter((c): c is string => typeof c === "string") + : [], + }; +} + +/** 组装一次挂载尝试的结果。 */ +interface AttemptOutcome { + module: FloatCTFFrontendModule; + frontend: ResolvedFrontend; + styles: HTMLElement[]; +} + +/** + * 执行完整 bootstrap 流程。**永不抛错**:任何失败都会走回退链, + * 最终失败时渲染兜底 UI 并返回 `mounted: false`。 + */ +export async function bootstrapFrontend( + options: BootstrapFrontendOptions, +): Promise { + const doc = options.root.ownerDocument; + const fetchImpl = options.fetchImpl ?? globalThis.fetch?.bind(globalThis); + const importModule = options.importModule ?? defaultImportModule; + const loadStyle = options.loadStyle ?? defaultLoadStyle; + const apiBaseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/, ""); + const registryUrl = options.registryUrl ?? DEFAULT_REGISTRY_URL; + const frontendBaseUrl = options.frontendBaseUrl ?? DEFAULT_FRONTEND_BASE_URL; + const fallbackFrontendId = options.fallbackFrontendId ?? DEFAULT_FRONTEND_ID; + + const diagnostics: BootstrapDiagnostics = { + attempted: [], + errors: [], + }; + const record = (message: string) => { + diagnostics.errors.push(message); + }; + + // ── 1. 公开 bootstrap 信息(未登录也必须可用:登录 UX 属于前端本身)── + let info: FloatCTFBootstrapInfo | null = null; + if (fetchImpl) { + try { + const response = await fetchImpl(`${apiBaseUrl}/frontend`, { + method: "GET", + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (!response.ok) { + throw new Error(`GET ${apiBaseUrl}/frontend → HTTP ${response.status}`); + } + const body: unknown = await response.json(); + // 后端统一响应包装 {code,message,data};同时容忍直接返回裸对象。 + const payload = isRecord(body) && "data" in body ? body.data : body; + info = parseBootstrapInfo(payload); + } catch (error) { + record(`bootstrap info unavailable: ${describeError(error)}`); + } + } else { + record("bootstrap info unavailable: fetch is not available"); + } + + diagnostics.platformVersion = info?.platform_version || undefined; + diagnostics.apiContractVersion = info?.api_contract_version ?? API_CONTRACT_VERSION; + diagnostics.frontendRuntimeVersion = + info?.frontend_runtime_version ?? FRONTEND_RUNTIME_VERSION; + diagnostics.activeFrontendId = info?.active_frontend ?? null; + + // ── 2. 本地注册表 ── + let registry: FloatCTFRegistry | null = null; + if (fetchImpl) { + try { + const response = await fetchImpl(registryUrl, { + method: "GET", + headers: { Accept: "application/json" }, + // 注册表/引导元数据不得被永久缓存。 + cache: "no-store", + credentials: "same-origin", + }); + if (!response.ok) { + throw new Error(`GET ${registryUrl} → HTTP ${response.status}`); + } + const parsed = parseRegistry(await response.json()); + if (!parsed.ok) { + throw new Error(parsed.errors.join("; ")); + } + registry = parsed.registry; + } catch (error) { + record(`frontend registry unavailable: ${describeError(error)}`); + } + } else { + record("frontend registry unavailable: fetch is not available"); + } + + // ── 3. 决定候选顺序 ── + const candidates: string[] = []; + const requestedOverride = options.overrideFrontendId ?? null; + if (requestedOverride !== null && requestedOverride !== undefined) { + if (!isSafeFrontendId(requestedOverride)) { + record(`ignored unsafe ?frontend value: ${JSON.stringify(requestedOverride)}`); + } else if (registry && !(requestedOverride in registry.frontends)) { + record(`ignored ?frontend=${requestedOverride}: not installed`); + } else { + diagnostics.overrideFrontendId = requestedOverride; + candidates.push(requestedOverride); + } + } + const activeId = info?.active_frontend ?? null; + if (activeId && isSafeFrontendId(activeId) && !candidates.includes(activeId)) { + candidates.push(activeId); + } else if (activeId && !isSafeFrontendId(activeId)) { + record(`active frontend id ${JSON.stringify(activeId)} is not a safe frontend id`); + } + if (!candidates.includes(fallbackFrontendId)) { + candidates.push(fallbackFrontendId); + } + + // ── 4. 逐个尝试 ── + for (const candidateId of candidates) { + const attempt = await attemptMount(candidateId); + diagnostics.attempted.push(candidateId); + if (attempt.ok) { + options.onDiagnostics?.(diagnostics); + return { + mounted: true, + frontendId: attempt.frontend.id, + frontendVersion: attempt.frontend.version, + diagnostics, + }; + } + record(`${candidateId}: ${attempt.error}`); + } + + // ── 5. 连默认前端都失败 → 兜底 UI ── + options.onDiagnostics?.(diagnostics); + if (isRecord(options.emergencyHost) || options.emergencyHost) { + renderBootstrapEmergencyUi(options.emergencyHost as HTMLElement, diagnostics); + } else if (doc.body) { + renderBootstrapEmergencyUi(doc.body, diagnostics); + } + return { mounted: false, diagnostics }; + + async function attemptMount( + frontendId: string, + ): Promise<({ ok: true } & AttemptOutcome) | { ok: false; error: string }> { + if (!registry) { + return { ok: false, error: "frontend registry is unavailable" }; + } + const resolved = resolveFrontend(registry, { + requestedId: frontendId, + frontendBaseUrl, + frontendRuntimeVersion: info?.frontend_runtime_version, + apiContractVersion: info?.api_contract_version, + }); + if (!resolved.ok) { + return { ok: false, error: resolved.errors.join("; ") }; + } + const frontend = resolved.frontend; + + const styles: HTMLElement[] = []; + try { + for (const styleUrl of frontend.styleUrls) { + styles.push(loadStyle(styleUrl, doc)); + } + const imported = await importModule(frontend.entryUrl); + const module = normalizeFrontendModule(imported as FloatCTFFrontendImport); + if (!module) { + throw new Error(`entry did not export mount(): ${frontend.entryUrl}`); + } + // 清空宿主(上一次尝试可能留下半渲染的 DOM)。 + options.root.textContent = ""; + await module.mount({ + root: options.root, + apiBaseUrl, + assetBaseUrl: frontend.assetBaseUrl, + frontendId: frontend.id, + frontendVersion: frontend.version, + platformVersion: info?.platform_version ?? "", + apiContractVersion: info?.api_contract_version ?? API_CONTRACT_VERSION, + frontendRuntimeVersion: + info?.frontend_runtime_version ?? FRONTEND_RUNTIME_VERSION, + capabilities: info?.capabilities ?? [], + }); + // 前端可以返回清理函数;bootstrap 每次页面加载只挂载一次,因此不保留它 + // (整页卸载由浏览器负责)。保留契约是为了将来支持热切换。 + return { ok: true, module, frontend, styles }; + } catch (error) { + for (const style of styles) style.remove(); + return { ok: false, error: describeError(error) }; + } + } +} + +function describeError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/packages/frontend-runtime/src/emergency.ts b/packages/frontend-runtime/src/emergency.ts new file mode 100644 index 00000000..01ae06ec --- /dev/null +++ b/packages/frontend-runtime/src/emergency.ts @@ -0,0 +1,125 @@ +/** + * 极端兜底 UI:当**连默认前端都加载失败**时,bootstrap 用纯 DOM 渲染这一段。 + * + * 约束: + * - 不依赖 React / Primer / Tailwind / 任何外部资源 + * - 只展示诊断信息(版本、ID、错误文本),**不含任何密钥、token、路径或后端内部信息** + * - 只使用 `textContent`(绝不用 `innerHTML` 拼接错误文本,避免把外部输入当 HTML 执行) + */ + +export interface BootstrapDiagnostics { + /** 平台版本(来自 `/api/frontend`,可能取不到)。 */ + platformVersion?: string; + apiContractVersion?: string; + frontendRuntimeVersion?: string; + /** 平台设置里的 FRONTEND_ACTIVE。 */ + activeFrontendId?: string | null; + /** `?frontend=` 覆盖值(若有效)。 */ + overrideFrontendId?: string | null; + /** 本次实际尝试加载过的前端 ID(按顺序)。 */ + attempted: string[]; + /** 每次尝试的失败原因(与 `attempted` 对位)。 */ + errors: string[]; +} + +/** + * 渲染兜底错误页;返回容器元素。 + * + * @param host 挂载点(通常是 `document.body`) + * @param diagnostics 诊断信息 + * @param onRetry 可选:点击"重试"时的回调(默认 `location.reload()`) + */ +export function renderBootstrapEmergencyUi( + host: HTMLElement, + diagnostics: BootstrapDiagnostics, + onRetry?: () => void, +): HTMLElement { + const doc = host.ownerDocument; + host.textContent = ""; + + const wrap = doc.createElement("div"); + wrap.setAttribute("data-floatctf-bootstrap-error", "true"); + wrap.style.cssText = [ + "max-width: 40rem", + "margin: 4rem auto", + "padding: 1.5rem", + "font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif", + "font-size: 15px", + "line-height: 1.6", + "color: #1f2328", + ].join(";"); + + const title = doc.createElement("h1"); + title.style.cssText = "font-size: 1.25rem; margin: 0 0 .75rem;"; + title.textContent = "FloatCTF 界面加载失败"; + wrap.appendChild(title); + + const intro = doc.createElement("p"); + intro.style.cssText = "margin: 0 0 1rem;"; + intro.textContent = + "当前选择的前端与内置默认前端都无法加载。请用 ?frontend=default 重试,或联系管理员检查前端安装。"; + wrap.appendChild(intro); + + const table = doc.createElement("table"); + table.style.cssText = "border-collapse: collapse; margin: 0 0 1rem; font-size: 13px;"; + const addRow = (label: string, value: string) => { + const tr = doc.createElement("tr"); + const th = doc.createElement("th"); + th.style.cssText = "text-align: left; padding: .15rem .75rem .15rem 0; vertical-align: top;"; + th.textContent = label; + const td = doc.createElement("td"); + td.style.cssText = "padding: .15rem 0; font-family: ui-monospace, SFMono-Regular, monospace;"; + td.textContent = value; + tr.append(th, td); + table.appendChild(tr); + }; + + addRow("平台版本", diagnostics.platformVersion ?? "(未知)"); + addRow("API 契约", diagnostics.apiContractVersion ?? "(未知)"); + addRow("前端运行时契约", diagnostics.frontendRuntimeVersion ?? "(未知)"); + addRow("FRONTEND_ACTIVE", diagnostics.activeFrontendId ?? "(未设置)"); + if (diagnostics.overrideFrontendId) { + addRow("?frontend=", diagnostics.overrideFrontendId); + } + addRow("已尝试", diagnostics.attempted.length > 0 ? diagnostics.attempted.join(" → ") : "(无)"); + wrap.appendChild(table); + + if (diagnostics.errors.length > 0) { + const errTitle = doc.createElement("p"); + errTitle.style.cssText = "margin: 0 0 .25rem; font-weight: 600;"; + errTitle.textContent = "失败原因"; + wrap.appendChild(errTitle); + + const list = doc.createElement("ul"); + list.style.cssText = + "margin: 0 0 1rem; padding-left: 1.25rem; font-size: 13px; font-family: ui-monospace, SFMono-Regular, monospace;"; + for (const error of diagnostics.errors) { + const li = doc.createElement("li"); + li.style.cssText = "margin-bottom: .25rem;"; + li.textContent = error; + list.appendChild(li); + } + wrap.appendChild(list); + } + + const retry = doc.createElement("button"); + retry.type = "button"; + retry.style.cssText = [ + "padding: .4rem .9rem", + "border: 1px solid #1f2328", + "border-radius: 6px", + "background: #1f2328", + "color: #fff", + "font-size: 14px", + "cursor: pointer", + ].join(";"); + retry.textContent = "重试"; + retry.addEventListener("click", () => { + if (onRetry) onRetry(); + else host.ownerDocument.defaultView?.location.reload(); + }); + wrap.appendChild(retry); + + host.appendChild(wrap); + return wrap; +} diff --git a/packages/frontend-runtime/src/index.ts b/packages/frontend-runtime/src/index.ts new file mode 100644 index 00000000..9c97d63d --- /dev/null +++ b/packages/frontend-runtime/src/index.ts @@ -0,0 +1,82 @@ +/** + * `@floatctf/frontend-runtime` —— FloatCTF 可插拔前端的**制品 / 运行时契约**。 + * + * 这个包**刻意保持很小**,并且**与框架无关**: + * - 不依赖 React / Vue / Svelte / Primer / 任何路由或状态库 + * - 不规定路由、页面、布局、登录页、管理页 + * - 只规定:制品长什么样(`frontend.json`)、注册表长什么样(`registry.json`)、 + * 以及 `mount(context)` 收到什么、bootstrap 如何加载与回退 + * + * 三份文档: + * - `docs/frontend/ARCHITECTURE.md` —— 平台架构与信任模型 + * - `docs/frontend/DEVELOPING.md` —— 外部仓库开发流程 + * - `docs/frontend/ARTIFACT.md` —— 制品 / manifest / 注册表字段与版本策略 + */ + +export { + FRONTEND_RUNTIME_VERSION, + FRONTEND_RUNTIME_FULL_VERSION, + FRONTEND_MANIFEST_SCHEMA_VERSION, + FRONTEND_REGISTRY_SCHEMA_VERSION, + API_CONTRACT_VERSION, + DEFAULT_FRONTEND_BASE_URL, + DEFAULT_REGISTRY_URL, + DEFAULT_API_BASE_URL, + DEFAULT_FRONTEND_ID, +} from "./version.js"; + +export { + FRONTEND_ID_PATTERN, + FRONTEND_ID_MAX_LENGTH, + type PathCheckResult, + checkRelativeAssetPath, + isMajorCompatible, + isSafeFrontendId, + isValidSemver, + joinUrlPath, + parseMajorConstraint, +} from "./paths.js"; + +export { + type FloatCTFFrontendCompatibility, + type FloatCTFFrontendManifest, + type ManifestParseResult, + parseFrontendManifest, + describeManifestCompatibility, +} from "./manifest.js"; + +export { + type FloatCTFRegistry, + type FloatCTFRegistryFrontend, + type FloatCTFRegistryVersion, + type RegistryParseResult, + type ResolveFrontendOptions, + type ResolveFrontendResult, + type ResolvedFrontend, + emptyRegistry, + listFrontendIds, + parseRegistry, + registerFrontendVersion, + resolveFrontend, +} from "./registry.js"; + +export { + type FloatCTFFrontendImport, + type FloatCTFFrontendModule, + type FloatCTFFrontendUnmount, + type FloatCTFMountContext, + normalizeFrontendModule, +} from "./module.js"; + +export { + type BootstrapDiagnostics, + renderBootstrapEmergencyUi, +} from "./emergency.js"; + +export { + type BootstrapFrontendOptions, + type BootstrapFrontendResult, + type FloatCTFBootstrapInfo, + bootstrapFrontend, + parseBootstrapInfo, +} from "./bootstrap.js"; diff --git a/packages/frontend-runtime/src/manifest.ts b/packages/frontend-runtime/src/manifest.ts new file mode 100644 index 00000000..2160de9e --- /dev/null +++ b/packages/frontend-runtime/src/manifest.ts @@ -0,0 +1,248 @@ +/** + * 前端制品 manifest(`frontend.json`)—— **故意保持很小**。 + * + * 它只声明"这是什么、吃什么版本、从哪加载",**不声明**路由、页面、框架、 + * 布局、登录页或任何 UI 结构。UI 结构完全属于前端实现自己。 + */ + +import { + FRONTEND_MANIFEST_SCHEMA_VERSION, + FRONTEND_RUNTIME_VERSION, + API_CONTRACT_VERSION, +} from "./version.js"; +import { + type PathCheckResult, + checkRelativeAssetPath, + isMajorCompatible, + isSafeFrontendId, + isValidSemver, +} from "./paths.js"; + +/** 兼容性声明:整数 major 约束(v1 语义,见 paths.ts 的 parseMajorConstraint)。 */ +export interface FloatCTFFrontendCompatibility { + /** 期望的前端运行时契约 major(如 `"1"`)。 */ + frontendRuntime: string; + /** 期望的对外 HTTP API 契约 major(如 `"1"`)。 */ + apiContract: string; + /** 可选:构建时使用的 `@floatctf/sdk` 版本(**信息性**,运行时不强制)。 */ + sdk?: string; +} + +/** 一个可安装的 FloatCTF 前端制品。 */ +export interface FloatCTFFrontendManifest { + schemaVersion: number; + id: string; + name: string; + version: string; + description?: string; + author?: string; + compatibility: FloatCTFFrontendCompatibility; + /** 相对本制品根目录的 ESM 入口(必须导出 `mount`)。 */ + entry: string; + /** 相对本制品根目录的样式文件(bootstrap 负责注入 ``)。 */ + styles?: string[]; +} + +export type ManifestParseResult = + | { ok: true; manifest: FloatCTFFrontendManifest; warnings: string[] } + | { ok: false; errors: string[] }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function collectPathErrors( + label: string, + value: unknown, + errors: string[], +): void { + const result: PathCheckResult = checkRelativeAssetPath(value); + if (!result.ok) { + errors.push(`${label}: ${result.reason}`); + } +} + +const MANIFEST_KEYS = new Set([ + "schemaVersion", + "id", + "name", + "version", + "description", + "author", + "compatibility", + "entry", + "styles", +]); + +/** + * 严格解析并校验 `frontend.json`。 + * + * 未知字段直接拒绝(fail-closed):制品格式是安全边界,静默忽略未知字段 + * 会让"看起来装了但实际少了一件事"的故障极难排查。 + * + * @param input 已 `JSON.parse` 的对象;也可传原始字符串。 + * @param options 可覆盖当前平台契约版本(测试注入用)。 + */ +export function parseFrontendManifest( + input: unknown, + options: { + manifestSchemaVersion?: number; + frontendRuntimeVersion?: string; + apiContractVersion?: string; + } = {}, +): ManifestParseResult { + const expectedSchema = + options.manifestSchemaVersion ?? FRONTEND_MANIFEST_SCHEMA_VERSION; + const runtimeVersion = + options.frontendRuntimeVersion ?? FRONTEND_RUNTIME_VERSION; + const apiContract = options.apiContractVersion ?? API_CONTRACT_VERSION; + + let raw: unknown = input; + if (typeof input === "string") { + try { + raw = JSON.parse(input); + } catch (error) { + return { + ok: false, + errors: [`frontend.json is not valid JSON: ${String(error)}`], + }; + } + } + + if (!isPlainObject(raw)) { + return { ok: false, errors: ["frontend.json must be a JSON object"] }; + } + + const errors: string[] = []; + const warnings: string[] = []; + + for (const key of Object.keys(raw)) { + if (!MANIFEST_KEYS.has(key)) { + errors.push(`frontend.json: unknown field \`${key}\``); + } + } + + // ── schemaVersion ── + if (raw.schemaVersion !== expectedSchema) { + errors.push( + `frontend.json: unsupported schemaVersion ${JSON.stringify(raw.schemaVersion)} (expected ${expectedSchema})`, + ); + } + + // ── id ── + if (!isSafeFrontendId(raw.id)) { + errors.push( + "frontend.json: id must match [a-z0-9][a-z0-9._-]* (max 64 chars)", + ); + } + + // ── name / version ── + if (typeof raw.name !== "string" || raw.name.trim().length === 0) { + errors.push("frontend.json: name must be a non-empty string"); + } else if (raw.name.length > 128) { + errors.push("frontend.json: name must be at most 128 characters"); + } + + if (!isValidSemver(raw.version)) { + errors.push("frontend.json: version must be a valid semver string"); + } + + if (raw.description !== undefined) { + if (typeof raw.description !== "string" || raw.description.length > 1024) { + errors.push("frontend.json: description must be a string (max 1024)"); + } + } + if (raw.author !== undefined) { + if (typeof raw.author !== "string" || raw.author.length > 256) { + errors.push("frontend.json: author must be a string (max 256)"); + } + } + + // ── compatibility ── + let compatibility: FloatCTFFrontendCompatibility | null = null; + if (!isPlainObject(raw.compatibility)) { + errors.push("frontend.json: compatibility must be an object"); + } else { + const compat = raw.compatibility; + for (const key of Object.keys(compat)) { + if (!["frontendRuntime", "apiContract", "sdk"].includes(key)) { + errors.push(`frontend.json: compatibility has unknown field \`${key}\``); + } + } + if (!isMajorCompatible(compat.frontendRuntime, runtimeVersion)) { + errors.push( + `frontend.json: compatibility.frontendRuntime ${JSON.stringify(compat.frontendRuntime)} is incompatible with runtime major ${runtimeVersion}`, + ); + } + if (!isMajorCompatible(compat.apiContract, apiContract)) { + errors.push( + `frontend.json: compatibility.apiContract ${JSON.stringify(compat.apiContract)} is incompatible with API contract major ${apiContract}`, + ); + } + if (compat.sdk !== undefined && typeof compat.sdk !== "string") { + errors.push("frontend.json: compatibility.sdk must be a string when present"); + } else if (typeof compat.sdk === "string" && compat.sdk.length > 128) { + errors.push("frontend.json: compatibility.sdk must be at most 128 characters"); + } else if (typeof compat.sdk === "string") { + warnings.push( + `compatibility.sdk=${compat.sdk} is informational only (frontends bundle their own dependencies)`, + ); + } + if ( + typeof compat.frontendRuntime === "string" && + typeof compat.apiContract === "string" + ) { + compatibility = { + frontendRuntime: compat.frontendRuntime, + apiContract: compat.apiContract, + ...(typeof compat.sdk === "string" ? { sdk: compat.sdk } : {}), + }; + } + } + + // ── entry / styles ── + collectPathErrors("frontend.json: entry", raw.entry, errors); + + if (raw.styles !== undefined) { + if (!Array.isArray(raw.styles)) { + errors.push("frontend.json: styles must be an array when present"); + } else { + if (raw.styles.length > 16) { + errors.push("frontend.json: styles must contain at most 16 entries"); + } + raw.styles.forEach((style, index) => { + collectPathErrors(`frontend.json: styles[${index}]`, style, errors); + }); + } + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { + ok: true, + warnings, + manifest: { + schemaVersion: expectedSchema, + id: raw.id as string, + name: raw.name as string, + version: raw.version as string, + ...(typeof raw.description === "string" + ? { description: raw.description } + : {}), + ...(typeof raw.author === "string" ? { author: raw.author } : {}), + compatibility: compatibility as FloatCTFFrontendCompatibility, + entry: raw.entry as string, + ...(Array.isArray(raw.styles) ? { styles: raw.styles as string[] } : {}), + }, + }; +} + +/** manifest 的兼容性摘要(用于诊断/管理端展示,不含任何敏感信息)。 */ +export function describeManifestCompatibility( + manifest: FloatCTFFrontendManifest, +): string { + const { frontendRuntime, apiContract } = manifest.compatibility; + return `runtime ${frontendRuntime} / api ${apiContract}`; +} diff --git a/packages/frontend-runtime/src/module.ts b/packages/frontend-runtime/src/module.ts new file mode 100644 index 00000000..9e2d9d4d --- /dev/null +++ b/packages/frontend-runtime/src/module.ts @@ -0,0 +1,87 @@ +/** + * FloatCTF 前端模块契约 —— 一个前端制品默认导出的东西。 + * + * ```ts + * // frontend 制品入口(任意框架) + * import type { FloatCTFFrontendModule } from "@floatctf/frontend-runtime"; + * + * export function mount(context: FloatCTFMountContext) { + * // 创建自己的框架/路由/状态/样式,渲染进 context.root + * return () => { /@ unmount @/ }; // 可选:返回清理函数 + * } + * ``` + */ + +/** `mount()` 收到的平台能力上下文。刻意保持极小。 */ +export interface FloatCTFMountContext { + /** 前端渲染的宿主元素(bootstrap 已确保为空)。 */ + root: HTMLElement; + /** API base URL,例如 `/api`(同源)。前端自行决定如何调用。 */ + apiBaseUrl: string; + /** 本前端制品根目录的同源绝对路径(用于加载自己的图片/字体等)。 */ + assetBaseUrl: string; + /** 当前前端 ID 与版本(诊断/日志用)。 */ + frontendId: string; + frontendVersion: string; + /** 平台版本(`/api/frontend` 返回,纯展示/诊断用)。 */ + platformVersion: string; + /** 对外 HTTP API 契约 major。 */ + apiContractVersion: string; + /** 前端运行时契约 major。 */ + frontendRuntimeVersion: string; + /** 真实且稳定的平台能力标记(列表内元素由平台声明,前端按需探测)。 */ + capabilities: readonly string[]; +} + +/** 前端 mount 的可选返回值:清理函数。 */ +export type FloatCTFFrontendUnmount = () => void; + +/** 前端制品入口模块的默认导出对象。 */ +export interface FloatCTFFrontendModule { + /** 可选:制品内自述的 ID/版本(仅用于诊断日志,平台以注册表为准)。 */ + manifest?: { id?: string; version?: string }; + /** 把前端挂载到 `context.root`。允许 async;抛错会触发 bootstrap 回退。 */ + mount(context: FloatCTFMountContext): void | FloatCTFFrontendUnmount | Promise; +} + +/** 动态 `import()` 到的模块形状(可能是 `default`,也可能是命名导出 `mount`)。 */ +export type FloatCTFFrontendImport = Partial & { + default?: Partial | FloatCTFFrontendModule; +}; + +/** + * 从动态 import 结果中取出可用的 module。 + * 容忍 `export default { mount }`、`export default function mount` 与 `export function mount`。 + */ +export function normalizeFrontendModule( + imported: unknown, +): FloatCTFFrontendModule | null { + if (typeof imported !== "object" || imported === null) return null; + const record = imported as Record; + + const candidates: unknown[] = []; + if (typeof record.mount === "function") { + candidates.push({ mount: record.mount }); + } + const defaultExport = record.default; + if (typeof defaultExport === "function") { + candidates.push({ mount: defaultExport }); + } else if (typeof defaultExport === "object" && defaultExport !== null) { + const defaultRecord = defaultExport as Record; + if (typeof defaultRecord.mount === "function") { + candidates.push({ + mount: defaultRecord.mount, + ...(typeof defaultRecord.manifest === "object" && defaultRecord.manifest !== null + ? { manifest: defaultRecord.manifest as FloatCTFFrontendModule["manifest"] } + : {}), + }); + } + } + + for (const candidate of candidates) { + if (typeof (candidate as FloatCTFFrontendModule).mount === "function") { + return candidate as FloatCTFFrontendModule; + } + } + return null; +} diff --git a/packages/frontend-runtime/src/paths.ts b/packages/frontend-runtime/src/paths.ts new file mode 100644 index 00000000..059bbf7e --- /dev/null +++ b/packages/frontend-runtime/src/paths.ts @@ -0,0 +1,131 @@ +/** + * 路径安全工具:前端制品里的 `entry` / `styles` 一律是**相对包路径**。 + * + * 拒绝:绝对路径、URL scheme、`..` 段、反斜杠、空段、控制字符、非 ASCII 之外的 + * 可疑字符(协议上只允许 [A-Za-z0-9._/-],且不得以 `/` 开头)。 + * + * 这些检查是安全边界(见 docs/frontend/ARCHITECTURE.md「信任模型 / 安全审查」): + * 一个被篡改的 `frontend.json` 不能把浏览器指向同源之外或注册表之外的路径。 + */ + +/** 前端 ID 允许形式:小写字母/数字开头,随后小写字母/数字/`.`/`_`/`-`。 */ +export const FRONTEND_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +/** 前端 ID 长度上限(同时用于文件系统目录名)。 */ +export const FRONTEND_ID_MAX_LENGTH = 64; + +/** 版本号(严格 semver,不含 build metadata 之外的宽松形式)。 */ +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +/** 相对包路径里单个路径段允许的字符。 */ +const SAFE_SEGMENT_PATTERN = /^[A-Za-z0-9._@+-]+$/; + +/** 允许出现在 URL 里的 scheme(用于检测 `https://…` 这类绝对地址)。 */ +const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + +export function isSafeFrontendId(id: unknown): id is string { + return ( + typeof id === "string" && + id.length > 0 && + id.length <= FRONTEND_ID_MAX_LENGTH && + FRONTEND_ID_PATTERN.test(id) + ); +} + +export function isValidSemver(version: unknown): version is string { + return typeof version === "string" && SEMVER_PATTERN.test(version); +} + +export interface PathCheckResult { + ok: boolean; + reason?: string; +} + +/** + * 校验制品内的相对路径(`entry` / `styles` / 注册表里的资产路径)。 + * + * 明确拒绝: + * - 绝对路径(`/x`、`C:\x`) + * - URL scheme(`https:`、`data:`、`file:`、`javascript:`) + * - `..` 路径穿越与 `.` 段 + * - 反斜杠与空段(`a//b`) + * - 前后空白、控制字符 + */ +export function checkRelativeAssetPath(value: unknown): PathCheckResult { + if (typeof value !== "string") { + return { ok: false, reason: "path must be a string" }; + } + if (value.length === 0) { + return { ok: false, reason: "path must not be empty" }; + } + if (value.length > 512) { + return { ok: false, reason: "path is too long" }; + } + if (value !== value.trim()) { + return { ok: false, reason: "path must not have surrounding whitespace" }; + } + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/.test(value)) { + return { ok: false, reason: "path must not contain control characters" }; + } + if (value.includes("\\")) { + return { ok: false, reason: "path must not contain backslashes" }; + } + if (URL_SCHEME_PATTERN.test(value)) { + return { ok: false, reason: "path must not contain a URL scheme" }; + } + if (value.startsWith("/") || value.startsWith("~")) { + return { ok: false, reason: "path must be relative" }; + } + const segments = value.split("/"); + for (const segment of segments) { + if (segment === "" ) { + return { ok: false, reason: "path must not contain empty segments" }; + } + if (segment === "." || segment === "..") { + return { ok: false, reason: "path must not contain '.' or '..' segments" }; + } + if (!SAFE_SEGMENT_PATTERN.test(segment)) { + return { ok: false, reason: `path segment contains unsupported characters: ${segment}` }; + } + } + return { ok: true }; +} + +/** + * 在已校验的相对前缀上拼接已校验的相对路径,返回以 `/` 分隔的 URL 路径。 + * + * 只做字符串拼接 + 归一化重复斜杠;调用方必须已经用 + * {@link checkRelativeAssetPath} / {@link isSafeFrontendId} 校验过每一段。 + */ +export function joinUrlPath(...parts: string[]): string { + const joined = parts + .filter((part) => part.length > 0) + .map((part, index) => + index === 0 ? part.replace(/\/+$/, "") : part.replace(/^\/+|\/+$/g, ""), + ) + .join("/"); + return joined.startsWith("/") ? joined : `/${joined}`; +} + +/** + * 校验 `compatibility` 里的 mostly-integer major 约束串。 + * + * v1 刻意只支持 **整数 major**(如 `"1"`),不支持 semver range 语法: + * 语义确定、易测、不会因为 range 解析器差异产生"某台机器上能装"的分歧。 + */ +export function parseMajorConstraint(value: unknown): number | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!/^(0|[1-9]\d*)$/.test(trimmed)) return null; + const major = Number.parseInt(trimmed, 10); + return Number.isSafeInteger(major) ? major : null; +} + +/** major 是否与当前契约 major 兼容(v1:必须完全相等)。 */ +export function isMajorCompatible(constraint: unknown, current: string): boolean { + const major = parseMajorConstraint(constraint); + if (major === null) return false; + return major === Number.parseInt(current, 10); +} diff --git a/packages/frontend-runtime/src/registry.ts b/packages/frontend-runtime/src/registry.ts new file mode 100644 index 00000000..2174c19f --- /dev/null +++ b/packages/frontend-runtime/src/registry.ts @@ -0,0 +1,436 @@ +/** + * 本地已安装前端注册表(`registry.json`)。 + * + * 语义边界(理解这一点比记住字段更重要): + * - **注册表描述"装了什么"**:哪些前端 ID、每个 ID 有哪些版本、每个版本的资产入口。 + * - **平台设置 `FRONTEND_ACTIVE` 描述"用哪个"**:只存前端 ID,不存版本。 + * - 每个 ID 有一个**显式** `currentVersion` 指针:安装新版本或回滚都必须显式改它, + * 绝不按文件名/版本号字典序"猜"当前版本(`9.0.0` vs `10.0.0` 这类排序会随机换前端)。 + * + * 注册表由 `scripts/frontend.sh` 原子写入(tmp + rename),浏览器只读。 + */ + +import { + FRONTEND_REGISTRY_SCHEMA_VERSION, + FRONTEND_RUNTIME_VERSION, + API_CONTRACT_VERSION, +} from "./version.js"; +import { + checkRelativeAssetPath, + isMajorCompatible, + isSafeFrontendId, + isValidSemver, + joinUrlPath, +} from "./paths.js"; +import type { FloatCTFFrontendCompatibility } from "./manifest.js"; + +/** 单个已安装版本。 */ +export interface FloatCTFRegistryVersion { + version: string; + name: string; + description?: string; + author?: string; + compatibility: FloatCTFFrontendCompatibility; + entry: string; + styles: string[]; + installedAt: string; +} + +/** 单个前端 ID。 */ +export interface FloatCTFRegistryFrontend { + id: string; + /** 显式当前版本指针;必须存在于 `versions` 中。 */ + currentVersion: string; + /** `default` 前端为 true:常规前端管理不得移除。 */ + protected: boolean; + versions: Record; +} + +export interface FloatCTFRegistry { + schemaVersion: number; + updatedAt: string; + frontends: Record; +} + +export type RegistryParseResult = + | { ok: true; registry: FloatCTFRegistry } + | { ok: false; errors: string[] }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseVersionEntry( + id: string, + version: string, + raw: unknown, + errors: string[], +): FloatCTFRegistryVersion | null { + const localErrors: string[] = []; + const push = (message: string) => { + localErrors.push(message); + errors.push(message); + }; + + if (!isPlainObject(raw)) { + push(`registry: frontends.${id}.versions.${version} must be an object`); + return null; + } + if (raw.version !== version) { + push( + `registry: frontends.${id}.versions.${version}.version=${JSON.stringify(raw.version)} does not match its key`, + ); + } + if (!isValidSemver(version)) { + push(`registry: frontends.${id}.versions key ${version} is not valid semver`); + } + if (typeof raw.name !== "string" || raw.name.trim().length === 0) { + push(`registry: frontends.${id}.versions.${version}.name must be a non-empty string`); + } + + const entryCheck = checkRelativeAssetPath(raw.entry); + if (!entryCheck.ok) { + push(`registry: frontends.${id}.versions.${version}.entry: ${entryCheck.reason}`); + } + + const styles: string[] = []; + if (raw.styles !== undefined) { + if (!Array.isArray(raw.styles)) { + push(`registry: frontends.${id}.versions.${version}.styles must be an array`); + } else { + raw.styles.forEach((style, index) => { + const styleCheck = checkRelativeAssetPath(style); + if (!styleCheck.ok) { + push( + `registry: frontends.${id}.versions.${version}.styles[${index}]: ${styleCheck.reason}`, + ); + } else { + styles.push(style as string); + } + }); + } + } + + let compatibility: FloatCTFFrontendCompatibility | null = null; + if (!isPlainObject(raw.compatibility)) { + push( + `registry: frontends.${id}.versions.${version}.compatibility must be an object`, + ); + } else { + const { frontendRuntime, apiContract, sdk } = raw.compatibility; + if (typeof frontendRuntime !== "string" || typeof apiContract !== "string") { + push( + `registry: frontends.${id}.versions.${version}.compatibility must declare frontendRuntime and apiContract`, + ); + } else { + compatibility = { + frontendRuntime, + apiContract, + ...(typeof sdk === "string" ? { sdk } : {}), + }; + } + } + + if (typeof raw.installedAt !== "string" || raw.installedAt.length === 0) { + push(`registry: frontends.${id}.versions.${version}.installedAt must be a string`); + } + + if (localErrors.length > 0 || !compatibility) { + return null; + } + + return { + version, + name: raw.name as string, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + ...(typeof raw.author === "string" ? { author: raw.author } : {}), + compatibility, + entry: raw.entry as string, + styles, + installedAt: raw.installedAt as string, + }; +} + +/** + * 严格解析并校验注册表。任何一项校验失败都返回 `ok:false` 与全部错误 + * (而不是"跳过坏条目继续"——坏注册表必须显式可见,否则会出现 + * "某个前端突然消失"这类无法定位的故障)。 + */ +export function parseRegistry( + input: unknown, + options: { registrySchemaVersion?: number } = {}, +): RegistryParseResult { + const expectedSchema = + options.registrySchemaVersion ?? FRONTEND_REGISTRY_SCHEMA_VERSION; + + let raw: unknown = input; + if (typeof input === "string") { + try { + raw = JSON.parse(input); + } catch (error) { + return { ok: false, errors: [`registry.json is not valid JSON: ${String(error)}`] }; + } + } + if (!isPlainObject(raw)) { + return { ok: false, errors: ["registry.json must be a JSON object"] }; + } + + const errors: string[] = []; + + if (raw.schemaVersion !== expectedSchema) { + errors.push( + `registry.json: unsupported schemaVersion ${JSON.stringify(raw.schemaVersion)} (expected ${expectedSchema})`, + ); + } + if (typeof raw.updatedAt !== "string" || raw.updatedAt.length === 0) { + errors.push("registry.json: updatedAt must be a string"); + } + if (!isPlainObject(raw.frontends)) { + errors.push("registry.json: frontends must be an object"); + return { ok: false, errors }; + } + + const frontends: Record = {}; + + for (const [id, rawFrontend] of Object.entries(raw.frontends)) { + if (!isSafeFrontendId(id)) { + errors.push(`registry.json: frontend id \`${id}\` is not a safe id`); + continue; + } + if (!isPlainObject(rawFrontend)) { + errors.push(`registry.json: frontends.${id} must be an object`); + continue; + } + if (rawFrontend.id !== undefined && rawFrontend.id !== id) { + errors.push( + `registry.json: frontends.${id}.id=${JSON.stringify(rawFrontend.id)} does not match its key`, + ); + } + if (!isPlainObject(rawFrontend.versions)) { + errors.push(`registry.json: frontends.${id}.versions must be an object`); + continue; + } + + const versions: Record = {}; + for (const [version, rawVersion] of Object.entries(rawFrontend.versions)) { + const parsed = parseVersionEntry(id, version, rawVersion, errors); + if (parsed) versions[version] = parsed; + } + if (Object.keys(versions).length === 0) { + errors.push(`registry.json: frontends.${id} has no valid versions`); + continue; + } + + const currentVersion = rawFrontend.currentVersion; + if (typeof currentVersion !== "string" || !(currentVersion in versions)) { + errors.push( + `registry.json: frontends.${id}.currentVersion ${JSON.stringify(currentVersion)} is not an installed version`, + ); + continue; + } + + frontends[id] = { + id, + currentVersion, + protected: rawFrontend.protected === true, + versions, + }; + } + + if (Object.keys(frontends).length === 0) { + errors.push("registry.json: no valid frontends installed"); + } + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { + ok: true, + registry: { + schemaVersion: expectedSchema, + updatedAt: raw.updatedAt as string, + frontends, + }, + }; +} + +/** 空注册表(首次安装 / 测试基线)。 */ +export function emptyRegistry(now: string): FloatCTFRegistry { + return { + schemaVersion: FRONTEND_REGISTRY_SCHEMA_VERSION, + updatedAt: now, + frontends: {}, + }; +} + +/** + * 注册某个前端的某个版本。 + * + * 与 `scripts/frontend.sh` 的同名语义保持一致: + * - 前端 ID / 版本格式必须合法 + * - **重复版本直接拒绝**(不静默覆盖已安装的不可变资产) + * - 仅当该 ID 尚无 `currentVersion`,或调用方要求 `makeCurrent` 时才移动指针 + */ +export function registerFrontendVersion( + registry: FloatCTFRegistry, + entry: { + id: string; + currentVersion?: string; + protected?: boolean; + version: FloatCTFRegistryVersion; + }, + makeCurrent = false, +): { ok: true; registry: FloatCTFRegistry } | { ok: false; error: string } { + const { id } = entry; + if (!isSafeFrontendId(id)) { + return { ok: false, error: `unsafe frontend id: ${id}` }; + } + if (!isValidSemver(entry.version.version)) { + return { ok: false, error: `invalid semver version: ${entry.version.version}` }; + } + const existing = registry.frontends[id]; + if (existing && entry.version.version in existing.versions) { + return { + ok: false, + error: `frontend ${id} version ${entry.version.version} is already installed`, + }; + } + const versions = { ...(existing?.versions ?? {}), [entry.version.version]: entry.version }; + const currentVersion = + makeCurrent || !existing?.currentVersion + ? entry.version.version + : existing.currentVersion; + return { + ok: true, + registry: { + ...registry, + frontends: { + ...registry.frontends, + [id]: { + id, + currentVersion, + protected: entry.protected ?? existing?.protected ?? false, + versions, + }, + }, + }, + }; +} + +// ── 解析(挑选要加载的前端)────────────────────────────────────────────────── + +export interface ResolvedFrontend { + id: string; + version: string; + name: string; + description?: string; + author?: string; + compatibility: FloatCTFFrontendCompatibility; + /** 入口 ESM 的**同源绝对路径**(已用注册表校验过的相对路径拼成)。 */ + entryUrl: string; + /** 样式表同源绝对路径。 */ + styleUrls: string[]; + /** 制品根目录的同源绝对路径(前端可用于加载自身资产)。 */ + assetBaseUrl: string; +} + +export type ResolveFrontendResult = + | { ok: true; frontend: ResolvedFrontend } + | { ok: false; errors: string[] }; + +export interface ResolveFrontendOptions { + /** 平台设置里的 `FRONTEND_ACTIVE`(前端 ID)。 */ + requestedId: string | null | undefined; + /** 前端资产挂载前缀,默认 `/__floatctf/frontends`。 */ + frontendBaseUrl: string; + frontendRuntimeVersion?: string; + apiContractVersion?: string; +} + +/** + * 从注册表解析要加载的前端。 + * + * 语义: + * - `requestedId` 缺失/非法/未安装 → 失败(调用方负责回退到 `default`) + * - 使用注册表**显式** `currentVersion`,绝不按版本号排序挑选 + * - 逐一校验兼容性(runtime / API contract major) + * - 已校验的相对路径才拼接为同源 URL(不允许跨源 / 穿越) + */ +export function resolveFrontend( + registry: FloatCTFRegistry, + options: ResolveFrontendOptions, +): ResolveFrontendResult { + const runtimeVersion = options.frontendRuntimeVersion ?? FRONTEND_RUNTIME_VERSION; + const apiContract = options.apiContractVersion ?? API_CONTRACT_VERSION; + const baseUrl = options.frontendBaseUrl.replace(/\/+$/, ""); + + const requestedId = options.requestedId; + if (typeof requestedId !== "string" || !isSafeFrontendId(requestedId)) { + return { + ok: false, + errors: [`active frontend id ${JSON.stringify(requestedId)} is not a safe frontend id`], + }; + } + + const frontend = registry.frontends[requestedId]; + if (!frontend) { + return { ok: false, errors: [`frontend \`${requestedId}\` is not installed`] }; + } + + const version = frontend.versions[frontend.currentVersion]; + if (!version) { + return { + ok: false, + errors: [ + `frontend \`${requestedId}\` currentVersion ${frontend.currentVersion} is not installed`, + ], + }; + } + + const errors: string[] = []; + if (!isMajorCompatible(version.compatibility.frontendRuntime, runtimeVersion)) { + errors.push( + `frontend \`${requestedId}\` requires frontend runtime ${version.compatibility.frontendRuntime} (platform runtime is ${runtimeVersion})`, + ); + } + if (!isMajorCompatible(version.compatibility.apiContract, apiContract)) { + errors.push( + `frontend \`${requestedId}\` requires API contract ${version.compatibility.apiContract} (platform contract is ${apiContract})`, + ); + } + + const entryCheck = checkRelativeAssetPath(version.entry); + if (!entryCheck.ok) { + errors.push(`frontend \`${requestedId}\` entry: ${entryCheck.reason}`); + } + for (const [index, style] of version.styles.entries()) { + const styleCheck = checkRelativeAssetPath(style); + if (!styleCheck.ok) { + errors.push(`frontend \`${requestedId}\` styles[${index}]: ${styleCheck.reason}`); + } + } + + if (errors.length > 0) return { ok: false, errors }; + + const assetBaseUrl = joinUrlPath(baseUrl, requestedId, version.version); + return { + ok: true, + frontend: { + id: requestedId, + version: version.version, + name: version.name, + ...(version.description !== undefined ? { description: version.description } : {}), + ...(version.author !== undefined ? { author: version.author } : {}), + compatibility: version.compatibility, + entryUrl: joinUrlPath(assetBaseUrl, version.entry), + styleUrls: version.styles.map((style) => joinUrlPath(assetBaseUrl, style)), + assetBaseUrl, + }, + }; +} + +/** 已安装前端 ID 列表(用于管理端选择器 / `?frontend=` 白名单)。 */ +export function listFrontendIds(registry: FloatCTFRegistry): string[] { + return Object.keys(registry.frontends).sort(); +} diff --git a/packages/frontend-runtime/src/version.ts b/packages/frontend-runtime/src/version.ts new file mode 100644 index 00000000..b566b37f --- /dev/null +++ b/packages/frontend-runtime/src/version.ts @@ -0,0 +1,38 @@ +/** + * FloatCTF 前端平台契约版本(三个互相独立的版本概念,禁止合并成一个变量): + * + * | 常量 | 含义 | 变更触发条件 | + * |------|------|--------------| + * | `FRONTEND_RUNTIME_VERSION` | 前端运行时 / 制品契约的 major | 破坏 `frontend.json` 或 `mount(context)` 契约 | + * | `API_CONTRACT_VERSION` | 对外 HTTP API 契约的 major | 破坏前端可见的 API(删字段 / 改语义) | + * | `FRONTEND_REGISTRY_SCHEMA_VERSION` | 本地注册表 `registry.json` 的 schema major | 注册表结构不兼容变更 | + * + * 平台自身包版本(`1.0.0`)与以上三者无关,不要互相推导。 + */ + +/** 前端运行时契约 major;前端 `compatibility.frontendRuntime` 必须与之相等。 */ +export const FRONTEND_RUNTIME_VERSION = "1"; + +/** 对外 HTTP API 契约 major;前端 `compatibility.apiContract` 必须与之相等。 */ +export const API_CONTRACT_VERSION = "1"; + +/** 本地前端注册表 `registry.json` 的 schema 版本。 */ +export const FRONTEND_REGISTRY_SCHEMA_VERSION = 1; + +/** 单个前端制品 `frontend.json`(manifest 文档)的 schema 版本。 */ +export const FRONTEND_MANIFEST_SCHEMA_VERSION = 1; + +/** 当前前端运行时版本(含 minor/patch,仅用于诊断展示)。 */ +export const FRONTEND_RUNTIME_FULL_VERSION = "1.0.0"; + +/** 生产环境前端资产默认挂载前缀(与 Caddy 配置一致)。 */ +export const DEFAULT_FRONTEND_BASE_URL = "/__floatctf/frontends"; + +/** 默认注册表 URL(同源静态文件,非永久缓存)。 */ +export const DEFAULT_REGISTRY_URL = `${DEFAULT_FRONTEND_BASE_URL}/registry.json`; + +/** 默认 API base URL。 */ +export const DEFAULT_API_BASE_URL = "/api"; + +/** 兜底前端 ID:始终随平台发布、不可删除。 */ +export const DEFAULT_FRONTEND_ID = "default"; diff --git a/packages/frontend-runtime/tsconfig.build.json b/packages/frontend-runtime/tsconfig.build.json new file mode 100644 index 00000000..17d8d8f4 --- /dev/null +++ b/packages/frontend-runtime/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src", + "types": [] + }, + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"] +} diff --git a/packages/frontend-runtime/tsconfig.json b/packages/frontend-runtime/tsconfig.json new file mode 100644 index 00000000..08be30b5 --- /dev/null +++ b/packages/frontend-runtime/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/frontend-runtime/vitest.config.ts b/packages/frontend-runtime/vitest.config.ts new file mode 100644 index 00000000..933ab975 --- /dev/null +++ b/packages/frontend-runtime/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + // 契约/校验测试是纯逻辑,不需要网络与真实浏览器。 + passWithNoTests: false, + }, +}); diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..19535366 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,48 @@ +{ + "name": "@floatctf/react", + "version": "1.0.0", + "description": "Optional headless React bindings for FloatCTF (query factories, SSE hooks) — no UI, no Primer, no routes", + "license": "AGPL-3.0-only", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", + "test": "vitest run", + "lint": "biome lint src", + "typecheck": "tsc -p tsconfig.build.json --noEmit" + }, + "dependencies": { + "@floatctf/sdk": "workspace:*" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.66.5", + "react": "^19.0.0" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "@tanstack/react-query": "^5.66.5", + "@testing-library/react": "^16.2.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^26.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.5" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/apps/web/src/hooks/__tests__/awdInvalidation.test.ts b/packages/react/src/__tests__/awdInvalidation.test.ts similarity index 100% rename from apps/web/src/hooks/__tests__/awdInvalidation.test.ts rename to packages/react/src/__tests__/awdInvalidation.test.ts diff --git a/packages/react/src/__tests__/query-keys.test.ts b/packages/react/src/__tests__/query-keys.test.ts new file mode 100644 index 00000000..5e90d91e --- /dev/null +++ b/packages/react/src/__tests__/query-keys.test.ts @@ -0,0 +1,51 @@ +// query options 工厂必须保持与原先 useQuery 完全相同的 query key, +// 因为既有代码按 key 做 invalidate/refetch(例如 useAwdEventStream.invalidateQueries)。 +import type { FloatCTFClient } from "@floatctf/sdk"; +import { describe, expect, it } from "vitest"; + +import { createQueryFactories } from "../queries/index"; + +/** 只用 key 的工厂不需要真的发请求:给一个最小可用的假客户端即可。 */ +function fakeClient(): FloatCTFClient { + return { + service: { + events: { get: async () => ({ code: 0, message: "ok" }) }, + challenges: { + get: async () => ({ code: 0, message: "ok" }), + getInstance: async () => ({ code: 0, message: "ok" }), + }, + }, + admin: { system: { monitor: async () => ({ code: 0, message: "ok" }) } }, + } as unknown as FloatCTFClient; +} + +const factories = createQueryFactories(fakeClient()); + +describe("query options factory keys", () => { + it("eventInfo keeps key ['eventInfo', id]", () => { + expect(factories.eventInfoQueryOptions("abc-123").queryKey).toEqual([ + "eventInfo", + "abc-123", + ]); + }); + + it("challenge keeps key ['challenge', id]", () => { + expect(factories.challengeQueryOptions("abc-123").queryKey).toEqual([ + "challenge", + "abc-123", + ]); + }); + + it("instance keeps key ['instance', id]", () => { + expect(factories.challengeInstanceQueryOptions("abc-123").queryKey).toEqual([ + "instance", + "abc-123", + ]); + }); + + it("system_information keeps key ['system_information']", () => { + expect(factories.systemInformationQueryOptions().queryKey).toEqual([ + "system_information", + ]); + }); +}); diff --git a/apps/web/src/hooks/__tests__/useAwdEventStream.test.tsx b/packages/react/src/__tests__/useAwdEventStream.test.tsx similarity index 90% rename from apps/web/src/hooks/__tests__/useAwdEventStream.test.tsx rename to packages/react/src/__tests__/useAwdEventStream.test.tsx index b77b3fc8..1e8fb458 100644 --- a/apps/web/src/hooks/__tests__/useAwdEventStream.test.tsx +++ b/packages/react/src/__tests__/useAwdEventStream.test.tsx @@ -18,30 +18,34 @@ import type { SseConnection, SseConnectionState, SseConnectionStatus, -} from "@/lib/sse"; +} from "@floatctf/sdk"; -import { useAwdEventStream } from "../useAwdEventStream"; +import { createUseAwdEventStream } from "../useAwdEventStream"; // ── mocks ──────────────────────────────────────────────────────────────────── // vi.hoisted:mock 工厂在模块导入期执行,必须先于 const 声明初始化。 const { mockConnectSse } = vi.hoisted(() => ({ mockConnectSse: vi.fn() })); -vi.mock("@/stores/AuthStore", () => ({ - useAuthStore: (selector: (s: unknown) => unknown) => - selector({ token: "test-token", adminToken: null }), -})); type OnStateChange = (status: SseConnectionStatus) => void; -vi.mock("@/lib/sse", () => ({ - connectSse: (options: import("@/lib/sse").ConnectSseOptions) => - mockConnectSse(options), -})); +vi.mock("@floatctf/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + connectSse: (options: import("@floatctf/sdk").ConnectSseOptions) => + mockConnectSse(options), + }; +}); + +// 令牌来源由前端注入 —— 这里换成测试专用的固定 hook。 +const useTestToken = () => "test-token"; +const useAwdEventStream = createUseAwdEventStream(useTestToken); // 捕获最近一次 connectSse 调用(含 onStateChange / close)。 function lastConnect(): { - options: import("@/lib/sse").ConnectSseOptions; + options: import("@floatctf/sdk").ConnectSseOptions; close: ReturnType; status: { state: SseConnectionState }; } { @@ -80,7 +84,7 @@ describe("useAwdEventStream reconnect fallback", () => { vi.useFakeTimers(); mockConnectSse.mockReset(); mockConnectSse.mockImplementation( - (options: import("@/lib/sse").ConnectSseOptions) => { + (options: import("@floatctf/sdk").ConnectSseOptions) => { const conn: SseConnection = { close: () => { // 模拟 close():同步触发 abort 语义 diff --git a/apps/web/src/hooks/awdInvalidation.ts b/packages/react/src/awdInvalidation.ts similarity index 100% rename from apps/web/src/hooks/awdInvalidation.ts rename to packages/react/src/awdInvalidation.ts diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 00000000..6d7ca84f --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,81 @@ +/** + * `@floatctf/react` —— FloatCTF 的**可选** React headless 绑定。 + * + * 边界(必须严格遵守): + * - ✅ 允许:query option 工厂、query key 常量、可复用 mutation/hook、 + * SSE → React Query 失效逻辑、数据/状态/动作 hook + * - ❌ 禁止:Header / Sidebar / Table 视觉组件、Primer、Tailwind、CSS、 + * 路由、页面、导航、对话框、任何 UX 流程 + * + * Hook 只暴露 data / actions / state,**不返回 JSX**。 + * + * 非 React 前端**完全不需要**这个包:它们直接用 `@floatctf/sdk`。 + * + * ```ts + * const { useAwdEventStream, eventInfoQueryOptions } = createFloatCTFReact({ + * client, + * useUserToken: () => useAuthStore((s) => s.token), + * useAdminToken: () => useAuthStore((s) => s.adminToken), + * }); + * ``` + */ + +import type { FloatCTFClient } from "@floatctf/sdk"; + +import { + AWD_ADMIN_QUERY_KEYS, + AWD_PLAYER_QUERY_KEYS, + invalidateAwdQueries, +} from "./awdInvalidation.js"; +import { createQueryFactories } from "./queries/index.js"; +import type { UseTokenSource } from "./tokenSource.js"; +import { createUseAdminAwdEventStream } from "./useAdminAwdEventStream.js"; +import { createUseAwdEventStream } from "./useAwdEventStream.js"; +import { createUseAwdpEventStream } from "./useAwdpEventStream.js"; +import { createUseAwdpRunStream } from "./useAwdpRunStream.js"; + +export interface CreateFloatCTFReactOptions { + /** 已绑定的 FloatCTF 客户端(来自 `@floatctf/sdk` 的 `createFloatCTFClient()`)。 */ + client: FloatCTFClient; + /** 前端自有的"读当前选手 token"hook。 */ + useUserToken: UseTokenSource; + /** 前端自有的"读当前管理员 token"hook;省略时复用 `useUserToken`。 */ + useAdminToken?: UseTokenSource; +} + +export function createFloatCTFReact(options: CreateFloatCTFReactOptions) { + const { client } = options; + const useUserToken = options.useUserToken; + const useAdminToken = options.useAdminToken ?? options.useUserToken; + + return { + client, + // ── 实时事件流(SSE + 轮询兜底 + React Query 失效)── + useAwdEventStream: createUseAwdEventStream(useUserToken), + useAdminAwdEventStream: createUseAdminAwdEventStream(useAdminToken), + useAwdpEventStream: createUseAwdpEventStream(useUserToken), + useAwdpRunStream: createUseAwdpRunStream(useUserToken), + // ── query options 工厂 ── + ...createQueryFactories(client), + // ── 失效工具(页面手动刷新与事件流共用同一份 key 常量)── + invalidateAwdQueries, + AWD_PLAYER_QUERY_KEYS, + AWD_ADMIN_QUERY_KEYS, + }; +} + +export type FloatCTFReactBindings = ReturnType; + +export { + AWD_ADMIN_QUERY_KEYS, + AWD_PLAYER_QUERY_KEYS, + invalidateAwdQueries, +} from "./awdInvalidation.js"; +export type { UseTokenSource } from "./tokenSource.js"; +export type { AwdpStreamEvent, UseAwdpEventStreamOptions } from "./useAwdpEventStream.js"; +export type { UseAwdpRunStreamOptions } from "./useAwdpRunStream.js"; +export type { + AwdStreamEvent, + UseAwdEventStreamOptions, +} from "./useAwdEventStream.js"; +export type { UseAdminAwdEventStreamOptions } from "./useAdminAwdEventStream.js"; diff --git a/apps/web/src/api/queries/service/challenges.ts b/packages/react/src/queries/challenge.ts similarity index 56% rename from apps/web/src/api/queries/service/challenges.ts rename to packages/react/src/queries/challenge.ts index bf13e916..651cf0d6 100644 --- a/apps/web/src/api/queries/service/challenges.ts +++ b/packages/react/src/queries/challenge.ts @@ -1,15 +1,16 @@ -import { serviceApi } from "@/api"; +import type { FloatCTFClient } from "@floatctf/sdk"; import { queryOptions } from "@tanstack/react-query"; /** * 题目详情 query options 工厂。 * Key: ["challenge", id] — 必须与既有 key 完全一致。 */ -export const challengeQueryOptions = (id: string) => - queryOptions({ +export function challengeQueryOptions(client: FloatCTFClient, id: string) { + return queryOptions({ queryKey: ["challenge", id], - queryFn: () => serviceApi.challenges.get(id), + queryFn: () => client.service.challenges.get(id), }); +} /** * 题目实例 query options 工厂。 @@ -18,8 +19,9 @@ export const challengeQueryOptions = (id: string) => * 注意:非动态题 instance 可能 404。loader 中用尽力预取 * (.catch()),不要硬 ensureQueryData。 */ -export const challengeInstanceQueryOptions = (id: string) => - queryOptions({ +export function challengeInstanceQueryOptions(client: FloatCTFClient, id: string) { + return queryOptions({ queryKey: ["instance", id], - queryFn: () => serviceApi.challenges.getInstance(id), + queryFn: () => client.service.challenges.getInstance(id), }); +} diff --git a/apps/web/src/api/queries/service/events.ts b/packages/react/src/queries/eventInfo.ts similarity index 75% rename from apps/web/src/api/queries/service/events.ts rename to packages/react/src/queries/eventInfo.ts index 70c9d332..5e347501 100644 --- a/apps/web/src/api/queries/service/events.ts +++ b/packages/react/src/queries/eventInfo.ts @@ -1,4 +1,4 @@ -import { serviceApi } from "@/api"; +import type { FloatCTFClient } from "@floatctf/sdk"; import { queryOptions } from "@tanstack/react-query"; /** @@ -12,8 +12,9 @@ import { queryOptions } from "@tanstack/react-query"; * 造成白屏。用户状态刷新(join/leave/建队/退队)由 mutation 的 * invalidateQueries(["eventInfo", id]) 保证(active 查询立即 refetch)。 */ -export const eventInfoQueryOptions = (id: string) => - queryOptions({ +export function eventInfoQueryOptions(client: FloatCTFClient, id: string) { + return queryOptions({ queryKey: ["eventInfo", id], - queryFn: () => serviceApi.events.get(id), + queryFn: () => client.service.events.get(id), }); +} diff --git a/packages/react/src/queries/index.ts b/packages/react/src/queries/index.ts new file mode 100644 index 00000000..4bd3ae15 --- /dev/null +++ b/packages/react/src/queries/index.ts @@ -0,0 +1,25 @@ +import type { FloatCTFClient } from "@floatctf/sdk"; + +import { challengeInstanceQueryOptions, challengeQueryOptions } from "./challenge.js"; +import { eventInfoQueryOptions } from "./eventInfo.js"; +import { systemInformationQueryOptions } from "./systemInformation.js"; + +/** + * 把 query options 工厂绑定到某个 FloatCTF 客户端。 + * + * 返回的工厂保持与原 `@/api/queries` **完全相同的调用签名与 query key**, + * 因此迁移到 headless 绑定后页面的 invalidate/refetch 语义零变化。 + */ +export function createQueryFactories(client: FloatCTFClient) { + return { + eventInfoQueryOptions: (id: string) => eventInfoQueryOptions(client, id), + challengeQueryOptions: (id: string) => challengeQueryOptions(client, id), + challengeInstanceQueryOptions: (id: string) => + challengeInstanceQueryOptions(client, id), + systemInformationQueryOptions: () => systemInformationQueryOptions(client), + }; +} + +export { challengeInstanceQueryOptions, challengeQueryOptions } from "./challenge.js"; +export { eventInfoQueryOptions } from "./eventInfo.js"; +export { systemInformationQueryOptions } from "./systemInformation.js"; diff --git a/apps/web/src/api/queries/admin/system.ts b/packages/react/src/queries/systemInformation.ts similarity index 64% rename from apps/web/src/api/queries/admin/system.ts rename to packages/react/src/queries/systemInformation.ts index e3389f98..bd0eb50f 100644 --- a/apps/web/src/api/queries/admin/system.ts +++ b/packages/react/src/queries/systemInformation.ts @@ -1,4 +1,4 @@ -import { adminApi } from "@/api"; +import type { FloatCTFClient } from "@floatctf/sdk"; import { queryOptions } from "@tanstack/react-query"; /** @@ -8,8 +8,9 @@ import { queryOptions } from "@tanstack/react-query"; * 注意:管理端仪表盘另有 refetchInterval: 60s。 * 该逻辑留在组件——loader 只预加载首屏数据。 */ -export const systemInformationQueryOptions = () => - queryOptions({ +export function systemInformationQueryOptions(client: FloatCTFClient) { + return queryOptions({ queryKey: ["system_information"], - queryFn: adminApi.system.monitor, + queryFn: () => client.admin.system.monitor(), }); +} diff --git a/packages/react/src/tokenSource.ts b/packages/react/src/tokenSource.ts new file mode 100644 index 00000000..ca836e4e --- /dev/null +++ b/packages/react/src/tokenSource.ts @@ -0,0 +1,8 @@ +/** + * React binding 的令牌来源抽象。 + * + * `@floatctf/react` **不**假设任何认证状态实现:前端把自己的 hook(Zustand selector、 + * Context selector、内存订阅……)传进来即可。这样 headless 绑定永远不会把 + * 某个状态库变成平台依赖。 + */ +export type UseTokenSource = () => string | null | undefined; diff --git a/apps/web/src/hooks/useAdminAwdEventStream.ts b/packages/react/src/useAdminAwdEventStream.ts similarity index 92% rename from apps/web/src/hooks/useAdminAwdEventStream.ts rename to packages/react/src/useAdminAwdEventStream.ts index 06c1a005..e4a9b024 100644 --- a/apps/web/src/hooks/useAdminAwdEventStream.ts +++ b/packages/react/src/useAdminAwdEventStream.ts @@ -7,25 +7,20 @@ * 与 `useAwdEventStream` 共享 `connectSse` 传输层,仅 URL 和 token 来源不同。 */ import { useQueryClient } from "@tanstack/react-query"; + +import type { UseTokenSource } from "./tokenSource.js"; import { useCallback, useEffect, useRef, useState } from "react"; -import { useAuthStore } from "@/stores/AuthStore"; import { AWD_ADMIN_QUERY_KEYS, invalidateAwdQueries, -} from "@/hooks/awdInvalidation"; +} from "./awdInvalidation.js"; +import type { AwdStreamEvent } from "./useAwdEventStream.js"; import { type SseConnection, type SseConnectionState, type SseEvent, connectSse, -} from "@/lib/sse"; - -export type AwdStreamEvent = { - type: string; - sequence?: number; - payload?: unknown; - occurred_at?: string; -}; +} from "@floatctf/sdk"; export type UseAdminAwdEventStreamOptions = { eventId: string; @@ -34,7 +29,8 @@ export type UseAdminAwdEventStreamOptions = { enabled?: boolean; }; -export function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { +export function createUseAdminAwdEventStream(useAdminToken: UseTokenSource) { + return function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { const { eventId, pollMs = 15_000, @@ -43,7 +39,7 @@ export function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { } = options; // 管理端令牌 — 独立于 users 认证域 - const adminToken = useAuthStore((s) => s.adminToken); + const adminToken = useAdminToken(); const qc = useQueryClient(); const [connectionState, setConnectionState] = @@ -195,4 +191,5 @@ export function useAdminAwdEventStream(options: UseAdminAwdEventStreamOptions) { lastError, invalidateAwd, }; -} \ No newline at end of file + }; +} diff --git a/apps/web/src/hooks/useAwdEventStream.ts b/packages/react/src/useAwdEventStream.ts similarity index 95% rename from apps/web/src/hooks/useAwdEventStream.ts rename to packages/react/src/useAwdEventStream.ts index 96ca8d0b..fcc39394 100644 --- a/apps/web/src/hooks/useAwdEventStream.ts +++ b/packages/react/src/useAwdEventStream.ts @@ -3,8 +3,7 @@ import { type SseConnectionState, type SseEvent, connectSse, -} from "@/lib/sse"; -import { useAuthStore } from "@/stores/AuthStore"; +} from "@floatctf/sdk"; /** * AWD 实时事件流 Hook。 * @@ -23,12 +22,14 @@ import { useAuthStore } from "@/stores/AuthStore"; * - 401/403 → auth_error,停止重连;令牌变更后重新尝试 */ import { useQueryClient } from "@tanstack/react-query"; + +import type { UseTokenSource } from "./tokenSource.js"; import { useCallback, useEffect, useRef, useState } from "react"; import { AWD_PLAYER_QUERY_KEYS, invalidateAwdQueries, -} from "@/hooks/awdInvalidation"; +} from "./awdInvalidation.js"; export type AwdStreamEvent = { type: string; @@ -46,7 +47,8 @@ export type UseAwdEventStreamOptions = { enabled?: boolean; }; -export function useAwdEventStream(options: UseAwdEventStreamOptions) { +export function createUseAwdEventStream(useUserToken: UseTokenSource) { + return function useAwdEventStream(options: UseAwdEventStreamOptions) { const { eventId, pollMs = 15_000, @@ -55,7 +57,7 @@ export function useAwdEventStream(options: UseAwdEventStreamOptions) { } = options; // 订阅令牌变更 — 令牌变化会触发 effect 清理 + 重建 - const token = useAuthStore((s) => s.token); + const token = useUserToken(); const qc = useQueryClient(); const [connectionState, setConnectionState] = @@ -214,4 +216,5 @@ export function useAwdEventStream(options: UseAwdEventStreamOptions) { lastError, invalidateAwd, }; -} \ No newline at end of file + }; +} diff --git a/apps/web/src/hooks/useAwdpEventStream.ts b/packages/react/src/useAwdpEventStream.ts similarity index 95% rename from apps/web/src/hooks/useAwdpEventStream.ts rename to packages/react/src/useAwdpEventStream.ts index 3af003f1..14b852b5 100644 --- a/apps/web/src/hooks/useAwdpEventStream.ts +++ b/packages/react/src/useAwdpEventStream.ts @@ -3,9 +3,10 @@ import { type SseConnectionState, type SseEvent, connectSse, -} from "@/lib/sse"; -import { useAuthStore } from "@/stores/AuthStore"; +} from "@floatctf/sdk"; import { useQueryClient } from "@tanstack/react-query"; + +import type { UseTokenSource } from "./tokenSource.js"; /** * AWDP 实时事件流 hook。 * 使用 fetch-based SSE 通过 Authorization: Bearer 头传递认证令牌。 @@ -29,13 +30,14 @@ export type UseAwdpEventStreamOptions = { const SNAPSHOT_RE = /awdp\.(score|phase|config|event|patch|manual|round|evaluation|instance)/; -export function useAwdpEventStream({ +export function createUseAwdpEventStream(useUserToken: UseTokenSource) { + return function useAwdpEventStream({ eventId, pollMs = 15000, preferStream = true, enabled = true, }: UseAwdpEventStreamOptions) { - const token = useAuthStore((s) => s.token); + const token = useUserToken(); const [connectionState, setConnectionState] = useState("idle"); @@ -190,4 +192,5 @@ export function useAwdpEventStream({ lastError, invalidateAwdp: invalidate, }; + }; } diff --git a/apps/web/src/hooks/useAwdpRunStream.ts b/packages/react/src/useAwdpRunStream.ts similarity index 93% rename from apps/web/src/hooks/useAwdpRunStream.ts rename to packages/react/src/useAwdpRunStream.ts index 990292cd..f3e69a28 100644 --- a/apps/web/src/hooks/useAwdpRunStream.ts +++ b/packages/react/src/useAwdpRunStream.ts @@ -3,9 +3,10 @@ import { type SseConnectionState, type SseEvent, connectSse, -} from "@/lib/sse"; -import { useAuthStore } from "@/stores/AuthStore"; +} from "@floatctf/sdk"; import { useQueryClient } from "@tanstack/react-query"; + +import type { UseTokenSource } from "./tokenSource.js"; /** * AWDP Practice Run 实时事件流 hook。 * @@ -14,7 +15,7 @@ import { useQueryClient } from "@tanstack/react-query"; */ import { useCallback, useEffect, useRef, useState } from "react"; -import type { AwdpStreamEvent } from "@/hooks/useAwdpEventStream"; +import type { AwdpStreamEvent } from "./useAwdpEventStream.js"; export type UseAwdpRunStreamOptions = { runId: string; @@ -26,13 +27,14 @@ export type UseAwdpRunStreamOptions = { const SNAPSHOT_RE = /awdp\.(score|phase|patch|manual|round|evaluation|instance|run)/; -export function useAwdpRunStream({ +export function createUseAwdpRunStream(useUserToken: UseTokenSource) { + return function useAwdpRunStream({ runId, pollMs = 15000, preferStream = true, enabled = true, }: UseAwdpRunStreamOptions) { - const token = useAuthStore((s) => s.token); + const token = useUserToken(); const [connectionState, setConnectionState] = useState("idle"); @@ -163,4 +165,5 @@ export function useAwdpRunStream({ lastError, invalidateRun: invalidate, }; + }; } diff --git a/packages/react/tsconfig.build.json b/packages/react/tsconfig.build.json new file mode 100644 index 00000000..949f3536 --- /dev/null +++ b/packages/react/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.test.tsx"] +} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 00000000..c6357c51 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": [], + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 00000000..0b475800 --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,11 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + passWithNoTests: false, + }, +}); diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..fd5ff811 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,78 @@ +# @floatctf/sdk + +FloatCTF platform client SDK — **framework independent**. No React, no router, no state +library, no Primer, no Tailwind, no application pages. + +``` +FloatCTF Backend ── REST / SSE / Bearer ──▶ @floatctf/sdk ──▶ @floatctf/react ──▶ Frontend +``` + +## Usage + +```ts +import { createFloatCTFClient } from "@floatctf/sdk"; + +const client = createFloatCTFClient({ + baseUrl: "/api", + // Auth storage is FRONTEND-owned; the SDK only asks for a token. + getUserToken: () => myStore.getState().token, + getAdminToken: () => myStore.getState().adminToken, + // What happens after a 401 is FRONTEND-owned: the SDK never navigates. + onUnauthorized: ({ scope }) => { + if (scope === "admin") myStore.getState().removeAdminToken(); + else myStore.getState().removeToken(); + }, +}); + +const events = await client.service.events.fetch({ limit: 20 }); +const client2 = await client.admin.settings.fetch(); +``` + +`client.service` / `client.admin` / `client.awd` / `client.awdp` are the domain façades. +Modules import the shared, DI-bound HTTP handles internally, so one page has one client. +`dispose()` unbinds it. + +## Error model + +Rejected values are always [`FloatCTFError`](./src/errors.ts), exposing: + +| field | meaning | +|---|---| +| `httpStatus` | HTTP status, when there was a response | +| `code` | platform response code (`UniResponse.code`) | +| `platformMessage` | platform message (`UniResponse.message`) | +| `kind` | `http` \| `platform` \| `network` \| `unknown` | +| `unauthorized` | true for 401 | +| `original` | the original cause (usually the Axios error) | +| `response` | Axios-compatible `{ status, data, headers }` shape | +| `displayMessage` | best message to show a user | + +`response` is kept so existing UI code reading `error.response?.data?.message` keeps +working unchanged — the typed fields are additive, not a replacement. + +## Responses + +`UniResponse` = `{ code, message, data?, meta? }` is preserved verbatim from the +backend. The SDK does not invent a second envelope. + +## Realtime (SSE) + +`connectSse` / `client.sse.connect` use `fetch` + `ReadableStream`, so the Bearer token +travels in the `Authorization` header and **never** in the URL. Reconnect uses +exponential backoff with jitter, stops on 401/403, honours `Retry-After` on 429, and +supports `Last-Event-ID`. + +## Entity types + +Generated DB-column types live behind a separate entry point so they cannot be confused +with API DTOs: + +```ts +import type { Events } from "@floatctf/sdk/entity"; +``` + +## Contract version + +`API_CONTRACT_VERSION` (see `docs/frontend/ARCHITECTURE.md`) lives with the runtime +contract in `@floatctf/frontend-runtime`. `scripts/check-architecture.sh` asserts the two +declarations never drift. diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 00000000..4105e1c2 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,53 @@ +{ + "name": "@floatctf/sdk", + "version": "1.0.0", + "description": "FloatCTF platform client SDK (framework independent): transport, error model, REST/SSE clients, DTOs", + "license": "AGPL-3.0-only", + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./entity": { + "types": "./dist/entity/index.d.ts", + "import": "./dist/entity/index.js" + }, + "./entity/*": { + "types": "./dist/entity/*.d.ts", + "import": "./dist/entity/*.js" + }, + "./sse": { + "types": "./dist/sse/index.d.ts", + "import": "./dist/sse/index.js" + }, + "./api/*": { + "types": "./dist/api/*.d.ts", + "import": "./dist/api/*.js" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput", + "test": "vitest run", + "lint": "biome lint src", + "typecheck": "tsc -p tsconfig.build.json --noEmit" + }, + "dependencies": { + "axios": "^1.11.0" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "jsdom": "^26.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.5" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/sdk/src/__tests__/errors.test.ts b/packages/sdk/src/__tests__/errors.test.ts new file mode 100644 index 00000000..6360e3c7 --- /dev/null +++ b/packages/sdk/src/__tests__/errors.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import type { UniResponse } from "../protocol"; +import { + FloatCTFError, + floatCTFErrorFromEnvelope, + toFloatCTFError, +} from "../errors"; + +describe("FloatCTFError", () => { + it("exposes typed fields and an axios-compatible response shape", () => { + const error = new FloatCTFError({ + message: "Request failed with status code 403", + kind: "http", + httpStatus: 403, + code: 4030, + platformMessage: "没有权限执行该操作", + response: { status: 403, data: { code: 4030, message: "没有权限执行该操作" } }, + }); + expect(error.name).toBe("FloatCTFError"); + expect(error.kind).toBe("http"); + expect(error.httpStatus).toBe(403); + expect(error.code).toBe(4030); + expect(error.platformMessage).toBe("没有权限执行该操作"); + expect(error.displayMessage).toBe("没有权限执行该操作"); + expect(error.unauthorized).toBe(false); + expect(error.response?.status).toBe(403); + }); + + it("marks 401 as unauthorized", () => { + const error = new FloatCTFError({ message: "401", kind: "http", httpStatus: 401 }); + expect(error.unauthorized).toBe(true); + }); + + it("falls back to message for displayMessage", () => { + const error = new FloatCTFError({ message: "Network Error", kind: "network" }); + expect(error.displayMessage).toBe("Network Error"); + }); +}); + +describe("toFloatCTFError", () => { + it("is idempotent", () => { + const error = new FloatCTFError({ message: "x", kind: "unknown" }); + expect(toFloatCTFError(error)).toBe(error); + }); + + it("normalizes an axios-like HTTP error", () => { + const axiosError = Object.assign(new Error("Request failed with status code 404"), { + isAxiosError: true, + response: { status: 404, statusText: "Not Found", data: { code: 404, message: "不存在" }, headers: {} }, + }); + const normalized = toFloatCTFError(axiosError); + expect(normalized.httpStatus).toBe(404); + expect(normalized.platformMessage).toBe("不存在"); + expect(normalized.kind).toBe("http"); + expect(normalized.original).toBe(axiosError); + }); + + it("normalizes a network error without a response", () => { + const axiosError = Object.assign(new Error("Network Error"), { + isAxiosError: true, + code: "ERR_NETWORK", + }); + const normalized = toFloatCTFError(axiosError); + expect(normalized.kind).toBe("network"); + expect(normalized.httpStatus).toBeUndefined(); + expect(normalized.response).toBeUndefined(); + }); + + it("handles a plain Error and a non-Error value", () => { + expect(toFloatCTFError(new Error("plain")).kind).toBe("unknown"); + expect(toFloatCTFError("string failure").message).toBe("string failure"); + }); +}); + +describe("floatCTFErrorFromEnvelope", () => { + it("returns null for a successful envelope", () => { + const ok: UniResponse<{ id: string }> = { code: 0, message: "ok", data: { id: "x" } }; + expect(floatCTFErrorFromEnvelope(ok)).toBeNull(); + }); + + it("returns a platform error for a non-zero code", () => { + const error = floatCTFErrorFromEnvelope({ code: 7, message: "业务失败" }); + expect(error?.kind).toBe("platform"); + expect(error?.code).toBe(7); + expect(error?.displayMessage).toBe("业务失败"); + }); + + it("ignores non-envelope payloads", () => { + expect(floatCTFErrorFromEnvelope(null)).toBeNull(); + expect(floatCTFErrorFromEnvelope("text")).toBeNull(); + }); +}); diff --git a/packages/sdk/src/__tests__/transport.test.ts b/packages/sdk/src/__tests__/transport.test.ts new file mode 100644 index 00000000..d9599462 --- /dev/null +++ b/packages/sdk/src/__tests__/transport.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { FloatCTFError } from "../errors"; +import { + bindHttpClients, + resetHttpClientsForTests, + service_api, + admin_api, +} from "../transport"; + +/** + * 传输层契约测试(取代旧 `apps/web/src/api/__tests__/axiosInterceptors.test.ts`)。 + * + * 关键变化:SDK **不再**自己导航或清登录态,而是把 401 交给调用方注入的 + * `onUnauthorized`。这里验证"机制在 SDK、UI 反应在前端"这条边界。 + */ + +interface RecordedRequest { + url?: string; + headers: Record; +} + +function installAdapter( + instance: { defaults: { adapter?: unknown } }, + record: RecordedRequest[], + respond: (config: { url?: string }) => { status: number; data?: unknown }, +): void { + instance.defaults.adapter = async (config: Record) => { + record.push({ + url: config.url as string | undefined, + headers: (config.headers ?? {}) as Record, + }); + const { status, data } = respond(config); + if (status >= 400) { + const error = Object.assign(new Error(`Request failed with status code ${status}`), { + isAxiosError: true, + response: { status, data, statusText: "", headers: {} }, + config, + }); + throw error; + } + return { status, data, statusText: "", headers: {}, config }; + }; +} + +describe("createFloatCTFClient transport", () => { + afterEach(() => { + resetHttpClientsForTests(); + vi.restoreAllMocks(); + }); + + it("sends the player bearer token on the player transport only", async () => { + const seen: RecordedRequest[] = []; + const bound = bindHttpClients({ + baseUrl: "/api", + getUserToken: () => "user-token", + getAdminToken: () => "admin-token", + }); + installAdapter(bound.service, seen, () => ({ status: 200, data: { code: 0 } })); + installAdapter(bound.admin, seen, () => ({ status: 200, data: { code: 0 } })); + + await service_api.get("/events"); + await admin_api.get("/settings"); + + expect(seen[0]?.headers.Authorization).toBe("Bearer user-token"); + expect(seen[1]?.headers.Authorization).toBe("Bearer admin-token"); + }); + + it("omits the Authorization header when there is no token", async () => { + const seen: RecordedRequest[] = []; + const bound = bindHttpClients({ getUserToken: () => null }); + installAdapter(bound.service, seen, () => ({ status: 200, data: {} })); + await service_api.get("/events"); + expect(seen[0]?.headers.Authorization).toBeUndefined(); + }); + + it("invokes onUnauthorized for a 401 instead of navigating anywhere", async () => { + const onUnauthorized = vi.fn(); + const bound = bindHttpClients({ + getUserToken: () => "user-token", + getAdminToken: () => "admin-token", + onUnauthorized, + }); + installAdapter(bound.service, [], () => ({ + status: 401, + data: { code: 401, message: "unauthorized" }, + })); + + await expect(service_api.get("/events")).rejects.toBeInstanceOf(FloatCTFError); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(onUnauthorized.mock.calls[0][0]).toMatchObject({ scope: "user", status: 401 }); + }); + + it("reports the admin scope separately", async () => { + const onUnauthorized = vi.fn(); + const bound = bindHttpClients({ onUnauthorized }); + installAdapter(bound.admin, [], () => ({ status: 401, data: {} })); + + await expect(admin_api.get("/settings")).rejects.toBeInstanceOf(FloatCTFError); + expect(onUnauthorized.mock.calls[0][0]).toMatchObject({ scope: "admin", status: 401 }); + }); + + it("does not call onUnauthorized for 500 or for network failures", async () => { + const onUnauthorized = vi.fn(); + const bound = bindHttpClients({ onUnauthorized }); + installAdapter(bound.admin, [], () => ({ status: 500, data: { message: "boom" } })); + + await expect(admin_api.get("/settings")).rejects.toMatchObject({ httpStatus: 500 }); + expect(onUnauthorized).not.toHaveBeenCalled(); + }); + + it("classifies a response-less network error as kind=network without crashing", async () => { + const onError = vi.fn(); + const bound = bindHttpClients({ onError }); + bound.service.defaults.adapter = async () => { + throw Object.assign(new Error("Network Error"), { + isAxiosError: true, + code: "ERR_NETWORK", + }); + }; + + const rejection = await service_api.get("/events").catch((error) => error); + expect(rejection).toBeInstanceOf(FloatCTFError); + expect((rejection as FloatCTFError).kind).toBe("network"); + expect((rejection as FloatCTFError).httpStatus).toBeUndefined(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it("preserves platform code and message, and keeps axios-shaped response for existing UI", async () => { + const bound = bindHttpClients({}); + installAdapter(bound.service, [], () => ({ + status: 400, + data: { code: 1001, message: "赛事已结束,无法提交 flag" }, + })); + + const rejection = (await service_api + .get("/submit/flag") + .catch((error) => error)) as FloatCTFError; + expect(rejection.httpStatus).toBe(400); + expect(rejection.code).toBe(1001); + expect(rejection.platformMessage).toBe("赛事已结束,无法提交 flag"); + // 既有页面读取 error.response?.data?.message —— 必须继续可用 + expect( + (rejection.response?.data as { message: string } | undefined)?.message, + ).toBe("赛事已结束,无法提交 flag"); + expect(rejection.displayMessage).toBe("赛事已结束,无法提交 flag"); + }); + + it("throws a readable error when used before binding", async () => { + resetHttpClientsForTests(); + await expect(service_api.get("/events")).rejects.toThrow(/not configured/); + }); +}); diff --git a/apps/web/src/api/admin/announcements.ts b/packages/sdk/src/api/admin/announcements.ts similarity index 86% rename from apps/web/src/api/admin/announcements.ts rename to packages/sdk/src/api/admin/announcements.ts index 0ec3be5d..c87f1768 100644 --- a/apps/web/src/api/admin/announcements.ts +++ b/packages/sdk/src/api/admin/announcements.ts @@ -1,5 +1,5 @@ -import type { Announcements } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { Announcements } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const announcementAdminApi = { fetch: async ( diff --git a/apps/web/src/api/admin/auth.ts b/packages/sdk/src/api/admin/auth.ts similarity index 79% rename from apps/web/src/api/admin/auth.ts rename to packages/sdk/src/api/admin/auth.ts index 0b468772..a33706fa 100644 --- a/apps/web/src/api/admin/auth.ts +++ b/packages/sdk/src/api/admin/auth.ts @@ -1,4 +1,4 @@ -import { type UniResponse, admin_api } from "@/api/axios"; +import { type UniResponse, admin_api } from "../../transport.js"; export const adminLoginFn = async ({ username, diff --git a/apps/web/src/api/admin/challenges.ts b/packages/sdk/src/api/admin/challenges.ts similarity index 93% rename from apps/web/src/api/admin/challenges.ts rename to packages/sdk/src/api/admin/challenges.ts index 47b49570..36690c8d 100644 --- a/apps/web/src/api/admin/challenges.ts +++ b/packages/sdk/src/api/admin/challenges.ts @@ -1,12 +1,12 @@ -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; -import type { ChallengeSets } from "@/entity"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; +import type { ChallengeSets } from "../../entity/index.js"; import type { BuildChallengeResult, ChallengeCheckResult, ChallengeScanItem, ImportChallengeResponse, -} from "@/routes/admin/challenges"; -import type { ChallengesListItem } from "@/types/challengeDto"; +} from "../../types/adminChallenge.js"; +import type { ChallengesListItem } from "../../types/challengeDto.js"; export const challengeAdminApi = { fetch: async ( diff --git a/apps/web/src/api/admin/dashboard.ts b/packages/sdk/src/api/admin/dashboard.ts similarity index 95% rename from apps/web/src/api/admin/dashboard.ts rename to packages/sdk/src/api/admin/dashboard.ts index 6a94ecc6..e779717c 100644 --- a/apps/web/src/api/admin/dashboard.ts +++ b/packages/sdk/src/api/admin/dashboard.ts @@ -1,4 +1,4 @@ -import { type UniResponse, admin_api } from "@/api/axios"; +import { type UniResponse, admin_api } from "../../transport.js"; export type DashboardSummary = { stats: { diff --git a/apps/web/src/api/admin/database.ts b/packages/sdk/src/api/admin/database.ts similarity index 65% rename from apps/web/src/api/admin/database.ts rename to packages/sdk/src/api/admin/database.ts index 2c2e5934..ade5a2cb 100644 --- a/apps/web/src/api/admin/database.ts +++ b/packages/sdk/src/api/admin/database.ts @@ -1,5 +1,5 @@ -import type { SqlResult, SqlStatement } from "@/routes/admin/database"; -import { type UniResponse, admin_api } from "@/api/axios"; +import type { SqlResult, SqlStatement } from "../../types/database.js"; +import { type UniResponse, admin_api } from "../../transport.js"; export const databaseAdminApi = { exec_sql: async ({ diff --git a/apps/web/src/api/admin/discussions.ts b/packages/sdk/src/api/admin/discussions.ts similarity index 87% rename from apps/web/src/api/admin/discussions.ts rename to packages/sdk/src/api/admin/discussions.ts index 3ea78e9b..d095de7a 100644 --- a/apps/web/src/api/admin/discussions.ts +++ b/packages/sdk/src/api/admin/discussions.ts @@ -1,5 +1,5 @@ -import type { DiscussionComments, Discussions } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { DiscussionComments, Discussions } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const discussionAdminApi = { fetch: async ( diff --git a/apps/web/src/api/admin/docker.ts b/packages/sdk/src/api/admin/docker.ts similarity index 96% rename from apps/web/src/api/admin/docker.ts rename to packages/sdk/src/api/admin/docker.ts index ea789d6b..04bcca8f 100644 --- a/apps/web/src/api/admin/docker.ts +++ b/packages/sdk/src/api/admin/docker.ts @@ -1,4 +1,4 @@ -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export interface FloatDockerContainer { id: string; diff --git a/apps/web/src/api/admin/download.ts b/packages/sdk/src/api/admin/download.ts similarity index 95% rename from apps/web/src/api/admin/download.ts rename to packages/sdk/src/api/admin/download.ts index eb67600d..24f6bf2e 100644 --- a/apps/web/src/api/admin/download.ts +++ b/packages/sdk/src/api/admin/download.ts @@ -1,4 +1,4 @@ -import { admin_api } from "@/api/axios"; +import { admin_api } from "../../transport.js"; export const downloadAdminApi = { download: async (key: string): Promise => { diff --git a/apps/web/src/api/admin/event_announcements.ts b/packages/sdk/src/api/admin/event_announcements.ts similarity index 90% rename from apps/web/src/api/admin/event_announcements.ts rename to packages/sdk/src/api/admin/event_announcements.ts index c5e4ea7c..cdba8197 100644 --- a/apps/web/src/api/admin/event_announcements.ts +++ b/packages/sdk/src/api/admin/event_announcements.ts @@ -1,5 +1,5 @@ -import type { EventAnnouncements } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { EventAnnouncements } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const eventAnnouncementAdminApi = { fetch: (event_id: string) => { diff --git a/apps/web/src/api/admin/event_challenges.ts b/packages/sdk/src/api/admin/event_challenges.ts similarity index 93% rename from apps/web/src/api/admin/event_challenges.ts rename to packages/sdk/src/api/admin/event_challenges.ts index d9febf41..f221c18a 100644 --- a/apps/web/src/api/admin/event_challenges.ts +++ b/packages/sdk/src/api/admin/event_challenges.ts @@ -1,6 +1,6 @@ -import type { JeopardyEventChallenges as EventChallenges } from "@/entity"; -import type { EventChallengeResult } from "@/routes/admin/events/jeopardy.$id"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { JeopardyEventChallenges as EventChallenges } from "../../entity/index.js"; +import type { EventChallengeResult } from "../../types/adminEventChallenge.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const eventChallengeAdminApi = { fetch: (event_id: string) => { diff --git a/apps/web/src/api/admin/event_logs.ts b/packages/sdk/src/api/admin/event_logs.ts similarity index 71% rename from apps/web/src/api/admin/event_logs.ts rename to packages/sdk/src/api/admin/event_logs.ts index ac8b9cff..fdd6deb2 100644 --- a/apps/web/src/api/admin/event_logs.ts +++ b/packages/sdk/src/api/admin/event_logs.ts @@ -1,5 +1,5 @@ -import type { EventLogs } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { EventLogs } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const eventLogAdminApi = { fetch: (event_id: string) => { diff --git a/apps/web/src/api/admin/event_teams.ts b/packages/sdk/src/api/admin/event_teams.ts similarity index 88% rename from apps/web/src/api/admin/event_teams.ts rename to packages/sdk/src/api/admin/event_teams.ts index f522062f..df03fb0e 100644 --- a/apps/web/src/api/admin/event_teams.ts +++ b/packages/sdk/src/api/admin/event_teams.ts @@ -1,5 +1,5 @@ -import type { TeamResult } from "@/routes/admin/events/jeopardy.$id/teams"; -import { type UniResponse, admin_api } from "@/api/axios"; +import type { TeamResult } from "../../types/adminEventTeam.js"; +import { type UniResponse, admin_api } from "../../transport.js"; export const eventTeamAdminApi = { getTeams: (id: string) => { diff --git a/apps/web/src/api/admin/event_users.ts b/packages/sdk/src/api/admin/event_users.ts similarity index 90% rename from apps/web/src/api/admin/event_users.ts rename to packages/sdk/src/api/admin/event_users.ts index b9d4a0af..87170cbd 100644 --- a/apps/web/src/api/admin/event_users.ts +++ b/packages/sdk/src/api/admin/event_users.ts @@ -1,5 +1,5 @@ -import type { EventUserResult } from "@/routes/admin/events/jeopardy.$id/users"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { EventUserResult } from "../../types/adminEventUser.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const eventUserAdminApi = { fetch: (event_id: string) => { diff --git a/apps/web/src/api/admin/event_writeups.ts b/packages/sdk/src/api/admin/event_writeups.ts similarity index 71% rename from apps/web/src/api/admin/event_writeups.ts rename to packages/sdk/src/api/admin/event_writeups.ts index e6437464..e3edea91 100644 --- a/apps/web/src/api/admin/event_writeups.ts +++ b/packages/sdk/src/api/admin/event_writeups.ts @@ -1,5 +1,5 @@ -import type { EventWriteup } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { EventWriteup } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const eventWriteupAdminApi = { fetch: (event_id: string) => { diff --git a/apps/web/src/api/admin/events.ts b/packages/sdk/src/api/admin/events.ts similarity index 90% rename from apps/web/src/api/admin/events.ts rename to packages/sdk/src/api/admin/events.ts index 7b976ea3..984cbb6c 100644 --- a/apps/web/src/api/admin/events.ts +++ b/packages/sdk/src/api/admin/events.ts @@ -1,6 +1,6 @@ -import type { Events } from "@/entity"; -import type { DataPresent } from "@/routes/admin/events/jeopardy.$id/data_present"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { Events } from "../../entity/index.js"; +import type { DataPresent } from "../../types/dataPresent.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const eventAdminApi = { fetch: async (params: QueryParams = {}): Promise> => { diff --git a/packages/sdk/src/api/admin/index.ts b/packages/sdk/src/api/admin/index.ts new file mode 100644 index 00000000..1324c62d --- /dev/null +++ b/packages/sdk/src/api/admin/index.ts @@ -0,0 +1,30 @@ +export { adminLoginFn } from "./auth.js"; +export { systemAdminApi } from "./system.js"; +export { settingAdminApi } from "./settings.js"; +export { announcementAdminApi } from "./announcements.js"; +export { challengeAdminApi } from "./challenges.js"; +export { userAdminApi } from "./users.js"; +export { eventAdminApi } from "./events.js"; +export { instanceAdminApi } from "./instances.js"; +export { eventChallengeAdminApi } from "./event_challenges.js"; +export { eventUserAdminApi } from "./event_users.js"; +export { eventAnnouncementAdminApi } from "./event_announcements.js"; +export { eventWriteupAdminApi } from "./event_writeups.js"; +export { eventTeamAdminApi } from "./event_teams.js"; +export { eventLogAdminApi } from "./event_logs.js"; +export { databaseAdminApi } from "./database.js"; +export { scheduledTaskAdminApi } from "./scheduled_tasks.js"; +export { weaponsAdminApi } from "./weapons.js"; +export { logsAdminApi } from "./logs.js"; +export { downloadAdminApi } from "./download.js"; +export { discussionAdminApi } from "./discussions.js"; +export { dashboardAdminApi } from "./dashboard.js"; +export { superAdminApi } from "./super_admin.js"; +export { + dockerAdminApi, + type FloatDockerContainer, + type ContainerInfo, + type PortInfo, + type ImageInfo, + type NetworkInfo, +} from "./docker.js"; diff --git a/apps/web/src/api/admin/instances.ts b/packages/sdk/src/api/admin/instances.ts similarity index 88% rename from apps/web/src/api/admin/instances.ts rename to packages/sdk/src/api/admin/instances.ts index 5cab1a33..a0761114 100644 --- a/apps/web/src/api/admin/instances.ts +++ b/packages/sdk/src/api/admin/instances.ts @@ -1,5 +1,5 @@ -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; -import type { InstancesDto } from "@/api/service/instances"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; +import type { InstancesDto } from "../service/instances.js"; /** * 管理端统一实例条目(归一化视图)。 diff --git a/apps/web/src/api/admin/logs.ts b/packages/sdk/src/api/admin/logs.ts similarity index 60% rename from apps/web/src/api/admin/logs.ts rename to packages/sdk/src/api/admin/logs.ts index 1db1fda4..a3905f9c 100644 --- a/apps/web/src/api/admin/logs.ts +++ b/packages/sdk/src/api/admin/logs.ts @@ -1,5 +1,5 @@ -import type { Logs } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { Logs } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const logsAdminApi = { fetch: async (params: QueryParams = {}): Promise> => { diff --git a/apps/web/src/api/admin/scheduled_tasks.ts b/packages/sdk/src/api/admin/scheduled_tasks.ts similarity index 88% rename from apps/web/src/api/admin/scheduled_tasks.ts rename to packages/sdk/src/api/admin/scheduled_tasks.ts index 047b8b16..1997fa28 100644 --- a/apps/web/src/api/admin/scheduled_tasks.ts +++ b/packages/sdk/src/api/admin/scheduled_tasks.ts @@ -1,5 +1,5 @@ -import type { ScheduledTasks } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { ScheduledTasks } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const scheduledTaskAdminApi = { fetch: async ( diff --git a/apps/web/src/api/admin/settings.ts b/packages/sdk/src/api/admin/settings.ts similarity index 87% rename from apps/web/src/api/admin/settings.ts rename to packages/sdk/src/api/admin/settings.ts index e9da8d05..5cb0b959 100644 --- a/apps/web/src/api/admin/settings.ts +++ b/packages/sdk/src/api/admin/settings.ts @@ -1,5 +1,5 @@ -import type { Settings } from "@/entity"; -import { type UniResponse, admin_api } from "@/api/axios"; +import type { Settings } from "../../entity/index.js"; +import { type UniResponse, admin_api } from "../../transport.js"; /** * 管理端 settings 接口的 API DTO。 diff --git a/apps/web/src/api/admin/super_admin.ts b/packages/sdk/src/api/admin/super_admin.ts similarity index 85% rename from apps/web/src/api/admin/super_admin.ts rename to packages/sdk/src/api/admin/super_admin.ts index 8aa99e1d..2cfc3520 100644 --- a/apps/web/src/api/admin/super_admin.ts +++ b/packages/sdk/src/api/admin/super_admin.ts @@ -1,5 +1,5 @@ -import type { SuperAdmin } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { SuperAdmin } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const superAdminApi = { fetch: async ( diff --git a/apps/web/src/api/admin/system.ts b/packages/sdk/src/api/admin/system.ts similarity index 70% rename from apps/web/src/api/admin/system.ts rename to packages/sdk/src/api/admin/system.ts index 6841fc9c..60a8cb38 100644 --- a/apps/web/src/api/admin/system.ts +++ b/packages/sdk/src/api/admin/system.ts @@ -1,5 +1,5 @@ -import { type UniResponse, admin_api } from "@/api/axios"; -import type { SystemInformation } from "@/routes/admin/dashboard"; +import { type UniResponse, admin_api } from "../../transport.js"; +import type { SystemInformation } from "../../types/systemInformation.js"; export const systemAdminApi = { monitor: async (): Promise> => { diff --git a/apps/web/src/api/admin/users.ts b/packages/sdk/src/api/admin/users.ts similarity index 84% rename from apps/web/src/api/admin/users.ts rename to packages/sdk/src/api/admin/users.ts index 7120b870..b42df999 100644 --- a/apps/web/src/api/admin/users.ts +++ b/packages/sdk/src/api/admin/users.ts @@ -1,5 +1,5 @@ -import type { Users } from "@/entity"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { Users } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const userAdminApi = { fetch: async (params: QueryParams = {}): Promise> => { diff --git a/apps/web/src/api/admin/weapons.ts b/packages/sdk/src/api/admin/weapons.ts similarity index 89% rename from apps/web/src/api/admin/weapons.ts rename to packages/sdk/src/api/admin/weapons.ts index 8f6d8162..f88faeb7 100644 --- a/apps/web/src/api/admin/weapons.ts +++ b/packages/sdk/src/api/admin/weapons.ts @@ -1,5 +1,5 @@ -import type { Weapons } from "@/entity/weapons"; -import { type QueryParams, type UniResponse, admin_api } from "@/api/axios"; +import type { Weapons } from "../../entity/weapons.js"; +import { type QueryParams, type UniResponse, admin_api } from "../../transport.js"; export const weaponsAdminApi = { fetch: async ( diff --git a/apps/web/src/api/awd.ts b/packages/sdk/src/api/awd.ts similarity index 99% rename from apps/web/src/api/awd.ts rename to packages/sdk/src/api/awd.ts index 873f7803..87bc2801 100644 --- a/apps/web/src/api/awd.ts +++ b/packages/sdk/src/api/awd.ts @@ -8,7 +8,7 @@ import { type UniResponse, admin_api, service_api, -} from "@/api/axios"; +} from "../transport.js"; export type AwdEventStatus = { event_id: string; diff --git a/apps/web/src/api/awdp.ts b/packages/sdk/src/api/awdp.ts similarity index 99% rename from apps/web/src/api/awdp.ts rename to packages/sdk/src/api/awdp.ts index 40a9c964..4261cfc3 100644 --- a/apps/web/src/api/awdp.ts +++ b/packages/sdk/src/api/awdp.ts @@ -8,7 +8,7 @@ import { type UniResponse, admin_api, service_api, -} from "@/api/axios"; +} from "../transport.js"; // ──────────────────────────────────────────────────────────────────────────── // Types @@ -245,7 +245,7 @@ export type AwdpDataActivity = { /** 管理端 AWDP Data Present 聚合响应。 */ export type AwdpDataPresent = { - event: import("@/entity").Events; + event: import("../entity/index.js").Events; user_count: number; team_count: number; gameboxes: AwdpDataGameBox[]; diff --git a/apps/web/src/api/awdpRuns.ts b/packages/sdk/src/api/awdpRuns.ts similarity index 99% rename from apps/web/src/api/awdpRuns.ts rename to packages/sdk/src/api/awdpRuns.ts index fd08cc2a..0bdf0a17 100644 --- a/apps/web/src/api/awdpRuns.ts +++ b/packages/sdk/src/api/awdpRuns.ts @@ -7,7 +7,7 @@ import { type BreakSubmitResponse, type ManualCheckDto, type PatchSubmitResponse, -} from "@/api/awdp"; +} from "./awdp.js"; /** * AWDP Practice Run(Training Ground)API 客户端。 * @@ -33,7 +33,7 @@ import { * 形状完全一致的 `AwdpRoundDto` / `BreakSubmitResponse` / `PatchSubmitResponse` / * `ManualCheckDto` / `AwdpPhase` / `AwdpEndpoint`。 */ -import { type QueryParams, type UniResponse, service_api } from "@/api/axios"; +import { type QueryParams, type UniResponse, service_api } from "../transport.js"; // ──────────────────────────────────────────────────────────────────────────── // Types diff --git a/packages/sdk/src/api/index.ts b/packages/sdk/src/api/index.ts new file mode 100644 index 00000000..509bf5ff --- /dev/null +++ b/packages/sdk/src/api/index.ts @@ -0,0 +1,137 @@ +/** + * 领域 API 门面:把各模块客户端按使用方(选手端 / 管理端)聚合成对象。 + * + * 这些门面**不是工厂**:具体模块通过 `../transport` 里被绑定的 HTTP handle 发请求, + * 因此 `createFloatCTFClient()` 绑定传输后,这里导出的门面即可直接使用。 + */ + +export { adminLoginFn } from "./admin/auth.js"; +export { systemAdminApi } from "./admin/system.js"; +export { settingAdminApi, type SettingsDto } from "./admin/settings.js"; +export { announcementAdminApi } from "./admin/announcements.js"; +export { challengeAdminApi } from "./admin/challenges.js"; +export { userAdminApi } from "./admin/users.js"; +export { eventAdminApi } from "./admin/events.js"; +export { instanceAdminApi, type AdminInstanceRow } from "./admin/instances.js"; +export { eventChallengeAdminApi } from "./admin/event_challenges.js"; +export { eventUserAdminApi } from "./admin/event_users.js"; +export { eventAnnouncementAdminApi } from "./admin/event_announcements.js"; +export { eventWriteupAdminApi } from "./admin/event_writeups.js"; +export { eventTeamAdminApi } from "./admin/event_teams.js"; +export { eventLogAdminApi } from "./admin/event_logs.js"; +export { databaseAdminApi } from "./admin/database.js"; +export { scheduledTaskAdminApi } from "./admin/scheduled_tasks.js"; +export { weaponsAdminApi } from "./admin/weapons.js"; +export { logsAdminApi } from "./admin/logs.js"; +export { downloadAdminApi } from "./admin/download.js"; +export { discussionAdminApi } from "./admin/discussions.js"; +export { dashboardAdminApi, type DashboardSummary } from "./admin/dashboard.js"; +export { superAdminApi } from "./admin/super_admin.js"; +export { + dockerAdminApi, + type FloatDockerContainer, + type ContainerInfo, + type PortInfo, + type ImageInfo, + type NetworkInfo, +} from "./admin/docker.js"; + +export { userServiceApi } from "./service/users.js"; +export { eventServiceApi } from "./service/events.js"; +export { + challengeServiceApi, + type UnifiedWriteupDetail, + type UnifiedWriteupResult, +} from "./service/challenges.js"; +export { instanceServiceApi } from "./service/instances.js"; +export { submitServiceApi } from "./service/submit.js"; +export { solveServiceApi, type SolveResult } from "./service/solves.js"; +export { weaponsServiceApi } from "./service/weapons.js"; +export { announcementServiceApi } from "./service/announcements.js"; +export { uploadsServiceApi } from "./service/uploads.js"; +export { + discussionServiceApi, + type DiscussionWithAuthor, +} from "./service/discussions.js"; + +import { + adminLoginFn, + announcementAdminApi, + challengeAdminApi, + dashboardAdminApi, + databaseAdminApi, + discussionAdminApi, + dockerAdminApi, + downloadAdminApi, + eventAdminApi, + eventAnnouncementAdminApi, + eventChallengeAdminApi, + eventLogAdminApi, + eventTeamAdminApi, + eventUserAdminApi, + eventWriteupAdminApi, + instanceAdminApi, + logsAdminApi, + scheduledTaskAdminApi, + settingAdminApi, + superAdminApi, + systemAdminApi, + userAdminApi, + weaponsAdminApi, +} from "./admin/index.js"; +import { + announcementServiceApi, + challengeServiceApi, + discussionServiceApi, + eventServiceApi, + instanceServiceApi, + solveServiceApi, + submitServiceApi, + uploadsServiceApi, + userServiceApi, + weaponsServiceApi, +} from "./service/index.js"; +import { awdAdminApi, awdPlayerApi } from "./awd.js"; + +/** 管理端 API 门面(与原 `@/api` 的 `adminApi` 形状一致)。 */ +export const adminApi = { + login: adminLoginFn, + system: systemAdminApi, + settings: settingAdminApi, + announcements: announcementAdminApi, + challenges: challengeAdminApi, + discussions: discussionAdminApi, + users: userAdminApi, + events: eventAdminApi, + instances: instanceAdminApi, + event_challenges: eventChallengeAdminApi, + event_users: eventUserAdminApi, + event_announcements: eventAnnouncementAdminApi, + event_logs: eventLogAdminApi, + event_writeups: eventWriteupAdminApi, + event_teams: eventTeamAdminApi, + database: databaseAdminApi, + scheduled_tasks: scheduledTaskAdminApi, + weapons: weaponsAdminApi, + logs: logsAdminApi, + download: downloadAdminApi, + docker: dockerAdminApi, + dashboard: dashboardAdminApi, + super_admin: superAdminApi, + awd: awdAdminApi, +}; + +/** 选手端 API 门面(与原 `@/api` 的 `serviceApi` 形状一致)。 */ +export const serviceApi = { + users: userServiceApi, + events: eventServiceApi, + challenges: challengeServiceApi, + instances: instanceServiceApi, + submit: submitServiceApi, + solves: solveServiceApi, + weapons: weaponsServiceApi, + announcements: announcementServiceApi, + discussions: discussionServiceApi, + uploads: uploadsServiceApi, + awd: awdPlayerApi, +}; diff --git a/apps/web/src/api/service/announcements.ts b/packages/sdk/src/api/service/announcements.ts similarity index 79% rename from apps/web/src/api/service/announcements.ts rename to packages/sdk/src/api/service/announcements.ts index f0da67cd..9368b091 100644 --- a/apps/web/src/api/service/announcements.ts +++ b/packages/sdk/src/api/service/announcements.ts @@ -1,5 +1,5 @@ -import type { Announcements } from "@/entity"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; +import type { Announcements } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, service_api } from "../../transport.js"; export const announcementServiceApi = { fetch: async ( diff --git a/apps/web/src/api/service/challenges.ts b/packages/sdk/src/api/service/challenges.ts similarity index 91% rename from apps/web/src/api/service/challenges.ts rename to packages/sdk/src/api/service/challenges.ts index 9a6b7d29..7ced4689 100644 --- a/apps/web/src/api/service/challenges.ts +++ b/packages/sdk/src/api/service/challenges.ts @@ -1,12 +1,12 @@ -import type { InstancesDto as Instances } from "./instances"; +import type { InstancesDto as Instances } from "./instances.js"; import type { ChallengeSets, ChallengeWriteup, Challenges, -} from "@/entity"; -import type { ChallengeWriteupResult } from "@/routes/service/challenges/$id/writeup"; -import type { ChallengesListItem } from "@/types/challengeDto"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; +} from "../../entity/index.js"; +import type { ChallengeWriteupResult } from "../../types/challengeWriteup.js"; +import type { ChallengesListItem } from "../../types/challengeDto.js"; +import { type QueryParams, type UniResponse, service_api } from "../../transport.js"; /** 全局 Writeup 列表统一条目(challenge + gamebox 合并;writeup_type 区分类型)。 */ export type UnifiedWriteupResult = { diff --git a/apps/web/src/api/service/discussions.ts b/packages/sdk/src/api/service/discussions.ts similarity index 96% rename from apps/web/src/api/service/discussions.ts rename to packages/sdk/src/api/service/discussions.ts index 66af1eb5..cb9f4ca8 100644 --- a/apps/web/src/api/service/discussions.ts +++ b/packages/sdk/src/api/service/discussions.ts @@ -1,5 +1,5 @@ -import type { DiscussionComments, Discussions } from "@/entity"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; +import type { DiscussionComments, Discussions } from "../../entity/index.js"; +import { type QueryParams, type UniResponse, service_api } from "../../transport.js"; /** * 后端 GET /discussions 与 GET /discussions/{id} 返回的 DTO: diff --git a/apps/web/src/api/service/events.ts b/packages/sdk/src/api/service/events.ts similarity index 87% rename from apps/web/src/api/service/events.ts rename to packages/sdk/src/api/service/events.ts index d0974e0e..175f7815 100644 --- a/apps/web/src/api/service/events.ts +++ b/packages/sdk/src/api/service/events.ts @@ -1,15 +1,15 @@ -import type { InstancesDto as Instances } from "./instances"; +import type { InstancesDto as Instances } from "./instances.js"; import type { EventAnnouncements, EventTeams, EventUsers, -} from "@/entity"; -import type { EventInfo } from "@/routes/service/events"; -import type { EventChallengeResult } from "@/routes/service/events/jeopardy.$id/challenges"; -import type { EventInstanceResult } from "@/routes/service/events/jeopardy.$id/instances"; -import type { ScoreboardItem } from "@/routes/service/events/jeopardy.$id/scoreboard"; -import type { TrendItem } from "@/routes/service/events/jeopardy.$id/trend"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; +} from "../../entity/index.js"; +import type { EventInfo } from "../../types/eventInfo.js"; +import type { EventChallengeResult } from "../../types/eventChallenge.js"; +import type { EventInstanceResult } from "../../types/eventInstance.js"; +import type { ScoreboardItem } from "../../types/scoreboard.js"; +import type { TrendItem } from "../../types/trend.js"; +import { type QueryParams, type UniResponse, service_api } from "../../transport.js"; export const eventServiceApi = { fetch: async ( diff --git a/packages/sdk/src/api/service/index.ts b/packages/sdk/src/api/service/index.ts new file mode 100644 index 00000000..9b78e130 --- /dev/null +++ b/packages/sdk/src/api/service/index.ts @@ -0,0 +1,10 @@ +export { userServiceApi } from "./users.js"; +export { eventServiceApi } from "./events.js"; +export { challengeServiceApi } from "./challenges.js"; +export { instanceServiceApi } from "./instances.js"; +export { submitServiceApi } from "./submit.js"; +export { solveServiceApi } from "./solves.js"; +export { weaponsServiceApi } from "./weapons.js"; +export { announcementServiceApi } from "./announcements.js"; +export { uploadsServiceApi } from "./uploads.js"; +export { discussionServiceApi } from "./discussions.js"; diff --git a/apps/web/src/api/service/instances.ts b/packages/sdk/src/api/service/instances.ts similarity index 58% rename from apps/web/src/api/service/instances.ts rename to packages/sdk/src/api/service/instances.ts index fadc0032..245f7ddc 100644 --- a/apps/web/src/api/service/instances.ts +++ b/packages/sdk/src/api/service/instances.ts @@ -1,31 +1,7 @@ -import { type QueryParams, type UniResponse, service_api } from "../axios"; +import { type QueryParams, type UniResponse, service_api } from "../../transport.js"; +import type { Instances, InstancesDto } from "../../types/instanceDto.js"; -/** 选手侧挑战实例 DTO(GET /api/instances,与后端 InstancesDto 对齐)。 - * 归一化后为合并列表:challenge(挑战练习)与 gamebox(AWDP 练习)两类。 */ -export type InstancesDto = { - id: string; - status: string; - flag: string; - content: string | null; - /** 关联挑战;AWDP 练习实例为 null(用 run_id/gamebox_id 标识)。 */ - challenge_id: string | null; - event_id: string; - team_id?: string | null; - user_id: string; - identifier: string; - created_at: string; - updated_at: string; - destroy_at: string | null; - challenge_title?: string | null; - event_title?: string | null; - user_name?: string | null; - run_id?: string | null; - gamebox_id?: string | null; - gamebox_title?: string | null; -}; - -/** 兼容旧引用(实体型已拆分为 event_challenge_instance,DTO 字段才是接口返回)。 */ -export type Instances = InstancesDto; +export type { Instances, InstancesDto }; export const instanceServiceApi = { launch: async (id: string): Promise> => { diff --git a/apps/web/src/api/service/solves.ts b/packages/sdk/src/api/service/solves.ts similarity index 88% rename from apps/web/src/api/service/solves.ts rename to packages/sdk/src/api/service/solves.ts index ba136ddf..0d3afe21 100644 --- a/apps/web/src/api/service/solves.ts +++ b/packages/sdk/src/api/service/solves.ts @@ -1,6 +1,6 @@ -import type { JeopardyChallengeSolves as ChallengeSolves } from "@/entity"; -import type { TopUser } from "@/routes/service/top"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; +import type { JeopardyChallengeSolves as ChallengeSolves } from "../../entity/index.js"; +import type { TopUser } from "../../types/top.js"; +import { type QueryParams, type UniResponse, service_api } from "../../transport.js"; /** * 后端 GET /solves 返回的 DTO:challenge_solves 表字段(serde flatten)+ 解题者信息。 diff --git a/apps/web/src/api/service/submit.ts b/packages/sdk/src/api/service/submit.ts similarity index 94% rename from apps/web/src/api/service/submit.ts rename to packages/sdk/src/api/service/submit.ts index 6886cd54..629a09fe 100644 --- a/apps/web/src/api/service/submit.ts +++ b/packages/sdk/src/api/service/submit.ts @@ -1,5 +1,5 @@ -import type { JeopardyChallengeSolves as ChallengeSolves } from "@/entity"; -import { type UniResponse, service_api } from "../axios"; +import type { JeopardyChallengeSolves as ChallengeSolves } from "../../entity/index.js"; +import { type UniResponse, service_api } from "../../transport.js"; export const submitServiceApi = { submit: async ({ diff --git a/apps/web/src/api/service/uploads.ts b/packages/sdk/src/api/service/uploads.ts similarity index 92% rename from apps/web/src/api/service/uploads.ts rename to packages/sdk/src/api/service/uploads.ts index 55dc230b..12936542 100644 --- a/apps/web/src/api/service/uploads.ts +++ b/packages/sdk/src/api/service/uploads.ts @@ -1,4 +1,4 @@ -import { type UniResponse, service_api } from "../axios"; +import { type UniResponse, service_api } from "../../transport.js"; export const uploadsServiceApi = { upload_image: async (image_file: File): Promise> => { diff --git a/apps/web/src/api/service/users.ts b/packages/sdk/src/api/service/users.ts similarity index 93% rename from apps/web/src/api/service/users.ts rename to packages/sdk/src/api/service/users.ts index d8a8f351..23424ec9 100644 --- a/apps/web/src/api/service/users.ts +++ b/packages/sdk/src/api/service/users.ts @@ -1,5 +1,5 @@ -import type { Users } from "@/entity"; -import { type UniResponse, service_api } from "../axios"; +import type { Users } from "../../entity/index.js"; +import { type UniResponse, service_api } from "../../transport.js"; export const userServiceApi = { getMe: async (): Promise> => { diff --git a/apps/web/src/api/service/weapons.ts b/packages/sdk/src/api/service/weapons.ts similarity index 79% rename from apps/web/src/api/service/weapons.ts rename to packages/sdk/src/api/service/weapons.ts index d40cb0b0..6846172c 100644 --- a/apps/web/src/api/service/weapons.ts +++ b/packages/sdk/src/api/service/weapons.ts @@ -1,5 +1,5 @@ -import type { Weapons } from "@/entity/weapons"; -import { type QueryParams, type UniResponse, service_api } from "../axios"; +import type { Weapons } from "../../entity/weapons.js"; +import { type QueryParams, type UniResponse, service_api } from "../../transport.js"; export const weaponsServiceApi = { fetch: async ( diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 00000000..a8ad7c35 --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,115 @@ +/** + * `createFloatCTFClient()` —— SDK 的唯一入口工厂。 + * + * 它做三件事: + * 1. 建立并**绑定**共享 HTTP 传输(baseURL + Bearer 注入 + 错误归一化 + 401 回调) + * 2. 返回按领域分组的 API 门面(`service` / `admin` / `awd` / `awdp` / `awdpRuns`) + * 3. 暴露实时(SSE)工具与可以 `dispose()` 的生命周期 + * + * 前端负责注入"token 从哪来"和"401 干什么";SDK 负责其余全部传输机制。 + * + * ```ts + * const client = createFloatCTFClient({ + * baseUrl: "/api", + * getUserToken: () => useAuthStore.getState().token, + * getAdminToken: () => useAuthStore.getState().adminToken, + * onUnauthorized: ({ scope }) => { + * if (scope === "admin") { useAuthStore.getState().removeAdminToken(); router.navigate({ to: "/admin" }); } + * else { useAuthStore.getState().removeToken(); router.navigate({ to: "/" }); } + * }, + * }); + * ``` + */ + +import type { AxiosInstance } from "axios"; + +import { + type FloatCTFClientOptions, + admin_api, + service_api, + bindHttpClients, +} from "./transport.js"; + +import { adminApi, serviceApi } from "./api/index.js"; +import { awdAdminApi, awdPlayerApi } from "./api/awd.js"; +import { awdpAdminApi, awdpPlayerApi } from "./api/awdp.js"; +import { awdpRunApi } from "./api/awdpRuns.js"; +import { connectSse, type ConnectSseOptions, type SseConnection } from "./sse/connectSse.js"; +import { + createSseParser, + type SseEvent, + type SseParser, +} from "./sse/parser.js"; + +export type { FloatCTFClientOptions } from "./transport.js"; + +export interface FloatCTFClient { + /** 选手端 API base URL。 */ + readonly baseUrl: string; + /** 管理端 API base URL。 */ + readonly adminBaseUrl: string; + /** 底层 axios 实例(逃生舱:自定义拦截器/上传进度/流式响应)。 */ + readonly transport: { + readonly service: AxiosInstance; + readonly admin: AxiosInstance; + }; + /** 领域门面。 */ + readonly service: typeof serviceApi; + readonly admin: typeof adminApi; + readonly awd: { + readonly player: typeof awdPlayerApi; + readonly admin: typeof awdAdminApi; + }; + readonly awdp: { + readonly player: typeof awdpPlayerApi; + readonly admin: typeof awdpAdminApi; + readonly runs: typeof awdpRunApi; + }; + /** 实时传输(fetch-based SSE,Bearer 走 Authorization 头)。 */ + readonly sse: { + connect(options: Omit & { getToken?: () => string | null }): SseConnection; + createParser(): SseParser; + }; + /** 解绑传输(登出 / 卸载 / 测试)。 */ + dispose(): void; +} + +/** + * 创建并绑定 FloatCTF 客户端。 + * + * ⚠️ 同一个页面同时只支持**一个**绑定的客户端(bootstrap 每次加载只挂载一个前端)。 + * 再次创建会替换绑定并使先前客户端的传输失效——这是刻意的、有文档的约束, + * 换来的是 API 模块不需要到处传递实例。 + */ +export function createFloatCTFClient( + options: FloatCTFClientOptions = {}, +): FloatCTFClient { + const { service, admin, dispose } = bindHttpClients(options); + const baseUrl = (options.baseUrl ?? "/api").replace(/\/+$/, ""); + const adminBaseUrl = (options.adminBaseUrl ?? `${baseUrl}/admin`).replace(/\/+$/, ""); + + return { + baseUrl, + adminBaseUrl, + transport: { service, admin }, + service: serviceApi, + admin: adminApi, + awd: { player: awdPlayerApi, admin: awdAdminApi }, + awdp: { player: awdpPlayerApi, admin: awdpAdminApi, runs: awdpRunApi }, + sse: { + connect: (sseOptions) => + connectSse({ + ...sseOptions, + // 默认复用客户端注入的 token 来源,除非调用方显式覆盖。 + getToken: sseOptions.getToken ?? (() => options.getUserToken?.() ?? null), + }), + createParser: createSseParser, + }, + dispose, + }; +} + +/** 当前绑定的 handle(供需要直接使用 handle 的 SDK 内部/高级场景)。 */ +export const httpClients = { service: service_api, admin: admin_api } as const; + +export type { SseConnection, SseEvent, SseParser, ConnectSseOptions }; diff --git a/apps/web/src/entity/announcements.ts b/packages/sdk/src/entity/announcements.ts similarity index 100% rename from apps/web/src/entity/announcements.ts rename to packages/sdk/src/entity/announcements.ts diff --git a/apps/web/src/entity/awd_event_gameboxes.ts b/packages/sdk/src/entity/awd_event_gameboxes.ts similarity index 100% rename from apps/web/src/entity/awd_event_gameboxes.ts rename to packages/sdk/src/entity/awd_event_gameboxes.ts diff --git a/apps/web/src/entity/awd_event_networks.ts b/packages/sdk/src/entity/awd_event_networks.ts similarity index 97% rename from apps/web/src/entity/awd_event_networks.ts rename to packages/sdk/src/entity/awd_event_networks.ts index f34f144a..d950905e 100644 --- a/apps/web/src/entity/awd_event_networks.ts +++ b/packages/sdk/src/entity/awd_event_networks.ts @@ -1,4 +1,4 @@ -import type { AwdNetworkAllocationMode } from './sea_orm_active_enums'; +import type { AwdNetworkAllocationMode } from './sea_orm_active_enums.js'; export type AwdEventNetworks = { id: string; diff --git a/apps/web/src/entity/awd_events.ts b/packages/sdk/src/entity/awd_events.ts similarity index 99% rename from apps/web/src/entity/awd_events.ts rename to packages/sdk/src/entity/awd_events.ts index 76c20e0e..80815394 100644 --- a/apps/web/src/entity/awd_events.ts +++ b/packages/sdk/src/entity/awd_events.ts @@ -1,4 +1,4 @@ -import type { AwdEventStatus, AwdPhase } from './sea_orm_active_enums'; +import type { AwdEventStatus, AwdPhase } from './sea_orm_active_enums.js'; export type AwdEvents = { id: string; diff --git a/apps/web/src/entity/awd_flag_issues.ts b/packages/sdk/src/entity/awd_flag_issues.ts similarity index 100% rename from apps/web/src/entity/awd_flag_issues.ts rename to packages/sdk/src/entity/awd_flag_issues.ts diff --git a/apps/web/src/entity/awd_flag_submissions.ts b/packages/sdk/src/entity/awd_flag_submissions.ts similarity index 100% rename from apps/web/src/entity/awd_flag_submissions.ts rename to packages/sdk/src/entity/awd_flag_submissions.ts diff --git a/apps/web/src/entity/awd_internal_token_rotations.ts b/packages/sdk/src/entity/awd_internal_token_rotations.ts similarity index 100% rename from apps/web/src/entity/awd_internal_token_rotations.ts rename to packages/sdk/src/entity/awd_internal_token_rotations.ts diff --git a/apps/web/src/entity/awd_judge_batches.ts b/packages/sdk/src/entity/awd_judge_batches.ts similarity index 100% rename from apps/web/src/entity/awd_judge_batches.ts rename to packages/sdk/src/entity/awd_judge_batches.ts diff --git a/apps/web/src/entity/awd_judge_tasks.ts b/packages/sdk/src/entity/awd_judge_tasks.ts similarity index 90% rename from apps/web/src/entity/awd_judge_tasks.ts rename to packages/sdk/src/entity/awd_judge_tasks.ts index 9c473f56..d59221b9 100644 --- a/apps/web/src/entity/awd_judge_tasks.ts +++ b/packages/sdk/src/entity/awd_judge_tasks.ts @@ -1,4 +1,4 @@ -import type { JudgeTaskStatus } from './sea_orm_active_enums'; +import type { JudgeTaskStatus } from './sea_orm_active_enums.js'; export type AwdJudgeTasks = { id: string; diff --git a/apps/web/src/entity/awd_network_allocations.ts b/packages/sdk/src/entity/awd_network_allocations.ts similarity index 95% rename from apps/web/src/entity/awd_network_allocations.ts rename to packages/sdk/src/entity/awd_network_allocations.ts index 69411f5a..7b6d396f 100644 --- a/apps/web/src/entity/awd_network_allocations.ts +++ b/packages/sdk/src/entity/awd_network_allocations.ts @@ -1,4 +1,4 @@ -import type { AwdNetworkAllocationKind } from './sea_orm_active_enums'; +import type { AwdNetworkAllocationKind } from './sea_orm_active_enums.js'; export type AwdNetworkAllocations = { id: string; diff --git a/apps/web/src/entity/awd_network_settings.ts b/packages/sdk/src/entity/awd_network_settings.ts similarity index 100% rename from apps/web/src/entity/awd_network_settings.ts rename to packages/sdk/src/entity/awd_network_settings.ts diff --git a/apps/web/src/entity/awd_orphan_resources.ts b/packages/sdk/src/entity/awd_orphan_resources.ts similarity index 100% rename from apps/web/src/entity/awd_orphan_resources.ts rename to packages/sdk/src/entity/awd_orphan_resources.ts diff --git a/apps/web/src/entity/awd_precheck_runs.ts b/packages/sdk/src/entity/awd_precheck_runs.ts similarity index 84% rename from apps/web/src/entity/awd_precheck_runs.ts rename to packages/sdk/src/entity/awd_precheck_runs.ts index 28f82c22..58bc51dd 100644 --- a/apps/web/src/entity/awd_precheck_runs.ts +++ b/packages/sdk/src/entity/awd_precheck_runs.ts @@ -1,4 +1,4 @@ -import type { PrecheckStatus } from './sea_orm_active_enums'; +import type { PrecheckStatus } from './sea_orm_active_enums.js'; export type AwdPrecheckRuns = { id: string; diff --git a/apps/web/src/entity/awd_reset_records.ts b/packages/sdk/src/entity/awd_reset_records.ts similarity index 100% rename from apps/web/src/entity/awd_reset_records.ts rename to packages/sdk/src/entity/awd_reset_records.ts diff --git a/apps/web/src/entity/awd_rounds.ts b/packages/sdk/src/entity/awd_rounds.ts similarity index 97% rename from apps/web/src/entity/awd_rounds.ts rename to packages/sdk/src/entity/awd_rounds.ts index b875dd75..49362e19 100644 --- a/apps/web/src/entity/awd_rounds.ts +++ b/packages/sdk/src/entity/awd_rounds.ts @@ -1,4 +1,4 @@ -import type { AwdPhase, RoundStatus } from './sea_orm_active_enums'; +import type { AwdPhase, RoundStatus } from './sea_orm_active_enums.js'; export type AwdRounds = { id: string; diff --git a/apps/web/src/entity/awd_runtime_resources.ts b/packages/sdk/src/entity/awd_runtime_resources.ts similarity index 100% rename from apps/web/src/entity/awd_runtime_resources.ts rename to packages/sdk/src/entity/awd_runtime_resources.ts diff --git a/apps/web/src/entity/awd_score_events.ts b/packages/sdk/src/entity/awd_score_events.ts similarity index 85% rename from apps/web/src/entity/awd_score_events.ts rename to packages/sdk/src/entity/awd_score_events.ts index 074bf190..1cc18827 100644 --- a/apps/web/src/entity/awd_score_events.ts +++ b/packages/sdk/src/entity/awd_score_events.ts @@ -1,4 +1,4 @@ -import type { ScoreEventType } from './sea_orm_active_enums'; +import type { ScoreEventType } from './sea_orm_active_enums.js'; export type AwdScoreEvents = { id: string; diff --git a/apps/web/src/entity/awd_team_bans.ts b/packages/sdk/src/entity/awd_team_bans.ts similarity index 84% rename from apps/web/src/entity/awd_team_bans.ts rename to packages/sdk/src/entity/awd_team_bans.ts index 6b02f108..5be35055 100644 --- a/apps/web/src/entity/awd_team_bans.ts +++ b/packages/sdk/src/entity/awd_team_bans.ts @@ -1,4 +1,4 @@ -import type { BanStatus } from './sea_orm_active_enums'; +import type { BanStatus } from './sea_orm_active_enums.js'; export type AwdTeamBans = { id: string; diff --git a/apps/web/src/entity/awd_team_networks.ts b/packages/sdk/src/entity/awd_team_networks.ts similarity index 100% rename from apps/web/src/entity/awd_team_networks.ts rename to packages/sdk/src/entity/awd_team_networks.ts diff --git a/apps/web/src/entity/awd_wireguard_peers.ts b/packages/sdk/src/entity/awd_wireguard_peers.ts similarity index 85% rename from apps/web/src/entity/awd_wireguard_peers.ts rename to packages/sdk/src/entity/awd_wireguard_peers.ts index bb3108ce..b28c2325 100644 --- a/apps/web/src/entity/awd_wireguard_peers.ts +++ b/packages/sdk/src/entity/awd_wireguard_peers.ts @@ -1,4 +1,4 @@ -import type { WgPeerStatus } from './sea_orm_active_enums'; +import type { WgPeerStatus } from './sea_orm_active_enums.js'; export type AwdWireguardPeers = { id: string; diff --git a/apps/web/src/entity/awdp_breaks.ts b/packages/sdk/src/entity/awdp_breaks.ts similarity index 100% rename from apps/web/src/entity/awdp_breaks.ts rename to packages/sdk/src/entity/awdp_breaks.ts diff --git a/apps/web/src/entity/awdp_evaluation_proofs.ts b/packages/sdk/src/entity/awdp_evaluation_proofs.ts similarity index 100% rename from apps/web/src/entity/awdp_evaluation_proofs.ts rename to packages/sdk/src/entity/awdp_evaluation_proofs.ts diff --git a/apps/web/src/entity/awdp_evaluations.ts b/packages/sdk/src/entity/awdp_evaluations.ts similarity index 95% rename from apps/web/src/entity/awdp_evaluations.ts rename to packages/sdk/src/entity/awdp_evaluations.ts index 736b062c..5de29792 100644 --- a/apps/web/src/entity/awdp_evaluations.ts +++ b/packages/sdk/src/entity/awdp_evaluations.ts @@ -1,4 +1,4 @@ -import type { AwdpEvaluationKind, AwdpEvaluationStatus } from './sea_orm_active_enums'; +import type { AwdpEvaluationKind, AwdpEvaluationStatus } from './sea_orm_active_enums.js'; export type AwdpEvaluations = { id: string; diff --git a/apps/web/src/entity/awdp_event_gameboxes.ts b/packages/sdk/src/entity/awdp_event_gameboxes.ts similarity index 100% rename from apps/web/src/entity/awdp_event_gameboxes.ts rename to packages/sdk/src/entity/awdp_event_gameboxes.ts diff --git a/apps/web/src/entity/awdp_event_networks.ts b/packages/sdk/src/entity/awdp_event_networks.ts similarity index 100% rename from apps/web/src/entity/awdp_event_networks.ts rename to packages/sdk/src/entity/awdp_event_networks.ts diff --git a/apps/web/src/entity/awdp_events.ts b/packages/sdk/src/entity/awdp_events.ts similarity index 100% rename from apps/web/src/entity/awdp_events.ts rename to packages/sdk/src/entity/awdp_events.ts diff --git a/apps/web/src/entity/awdp_fix_rounds.ts b/packages/sdk/src/entity/awdp_fix_rounds.ts similarity index 100% rename from apps/web/src/entity/awdp_fix_rounds.ts rename to packages/sdk/src/entity/awdp_fix_rounds.ts diff --git a/apps/web/src/entity/awdp_instances.ts b/packages/sdk/src/entity/awdp_instances.ts similarity index 100% rename from apps/web/src/entity/awdp_instances.ts rename to packages/sdk/src/entity/awdp_instances.ts diff --git a/apps/web/src/entity/awdp_patch_submissions.ts b/packages/sdk/src/entity/awdp_patch_submissions.ts similarity index 100% rename from apps/web/src/entity/awdp_patch_submissions.ts rename to packages/sdk/src/entity/awdp_patch_submissions.ts diff --git a/apps/web/src/entity/awdp_run_writeups.ts b/packages/sdk/src/entity/awdp_run_writeups.ts similarity index 100% rename from apps/web/src/entity/awdp_run_writeups.ts rename to packages/sdk/src/entity/awdp_run_writeups.ts diff --git a/apps/web/src/entity/awdp_runs.ts b/packages/sdk/src/entity/awdp_runs.ts similarity index 90% rename from apps/web/src/entity/awdp_runs.ts rename to packages/sdk/src/entity/awdp_runs.ts index 38813a17..d33c4889 100644 --- a/apps/web/src/entity/awdp_runs.ts +++ b/packages/sdk/src/entity/awdp_runs.ts @@ -1,4 +1,4 @@ -import type { AwdpPhase } from './sea_orm_active_enums'; +import type { AwdpPhase } from './sea_orm_active_enums.js'; export type AwdpRuns = { id: string; diff --git a/apps/web/src/entity/awdp_score_events.ts b/packages/sdk/src/entity/awdp_score_events.ts similarity index 100% rename from apps/web/src/entity/awdp_score_events.ts rename to packages/sdk/src/entity/awdp_score_events.ts diff --git a/apps/web/src/entity/challenge_set_items.ts b/packages/sdk/src/entity/challenge_set_items.ts similarity index 100% rename from apps/web/src/entity/challenge_set_items.ts rename to packages/sdk/src/entity/challenge_set_items.ts diff --git a/apps/web/src/entity/challenge_sets.ts b/packages/sdk/src/entity/challenge_sets.ts similarity index 100% rename from apps/web/src/entity/challenge_sets.ts rename to packages/sdk/src/entity/challenge_sets.ts diff --git a/apps/web/src/entity/challenge_writeup.ts b/packages/sdk/src/entity/challenge_writeup.ts similarity index 100% rename from apps/web/src/entity/challenge_writeup.ts rename to packages/sdk/src/entity/challenge_writeup.ts diff --git a/apps/web/src/entity/challenges.ts b/packages/sdk/src/entity/challenges.ts similarity index 100% rename from apps/web/src/entity/challenges.ts rename to packages/sdk/src/entity/challenges.ts diff --git a/apps/web/src/entity/discussion_comments.ts b/packages/sdk/src/entity/discussion_comments.ts similarity index 100% rename from apps/web/src/entity/discussion_comments.ts rename to packages/sdk/src/entity/discussion_comments.ts diff --git a/apps/web/src/entity/discussion_likes.ts b/packages/sdk/src/entity/discussion_likes.ts similarity index 100% rename from apps/web/src/entity/discussion_likes.ts rename to packages/sdk/src/entity/discussion_likes.ts diff --git a/apps/web/src/entity/discussions.ts b/packages/sdk/src/entity/discussions.ts similarity index 100% rename from apps/web/src/entity/discussions.ts rename to packages/sdk/src/entity/discussions.ts diff --git a/apps/web/src/entity/event_announcements.ts b/packages/sdk/src/entity/event_announcements.ts similarity index 100% rename from apps/web/src/entity/event_announcements.ts rename to packages/sdk/src/entity/event_announcements.ts diff --git a/apps/web/src/entity/event_challenge_instance.ts b/packages/sdk/src/entity/event_challenge_instance.ts similarity index 100% rename from apps/web/src/entity/event_challenge_instance.ts rename to packages/sdk/src/entity/event_challenge_instance.ts diff --git a/apps/web/src/entity/event_gamebox_instances.ts b/packages/sdk/src/entity/event_gamebox_instances.ts similarity index 83% rename from apps/web/src/entity/event_gamebox_instances.ts rename to packages/sdk/src/entity/event_gamebox_instances.ts index 08188b60..3d399f84 100644 --- a/apps/web/src/entity/event_gamebox_instances.ts +++ b/packages/sdk/src/entity/event_gamebox_instances.ts @@ -1,4 +1,4 @@ -import type { GameboxStatus } from './sea_orm_active_enums'; +import type { GameboxStatus } from './sea_orm_active_enums.js'; export type EventGameboxInstances = { id: string; diff --git a/apps/web/src/entity/event_instances.ts b/packages/sdk/src/entity/event_instances.ts similarity index 100% rename from apps/web/src/entity/event_instances.ts rename to packages/sdk/src/entity/event_instances.ts diff --git a/apps/web/src/entity/event_logs.ts b/packages/sdk/src/entity/event_logs.ts similarity index 92% rename from apps/web/src/entity/event_logs.ts rename to packages/sdk/src/entity/event_logs.ts index 0fc21a32..32cde7d6 100644 --- a/apps/web/src/entity/event_logs.ts +++ b/packages/sdk/src/entity/event_logs.ts @@ -1,4 +1,4 @@ -import type { EventFamily, EventPurpose, ParticipantMode } from './sea_orm_active_enums'; +import type { EventFamily, EventPurpose, ParticipantMode } from './sea_orm_active_enums.js'; export type EventLogs = { id: string; diff --git a/apps/web/src/entity/event_team_members.ts b/packages/sdk/src/entity/event_team_members.ts similarity index 97% rename from apps/web/src/entity/event_team_members.ts rename to packages/sdk/src/entity/event_team_members.ts index c794b1bf..4f7135df 100644 --- a/apps/web/src/entity/event_team_members.ts +++ b/packages/sdk/src/entity/event_team_members.ts @@ -1,4 +1,4 @@ -import type { EventTeamMemberRole } from './sea_orm_active_enums'; +import type { EventTeamMemberRole } from './sea_orm_active_enums.js'; export type EventTeamMembers = { event_id: string; diff --git a/apps/web/src/entity/event_teams.ts b/packages/sdk/src/entity/event_teams.ts similarity index 100% rename from apps/web/src/entity/event_teams.ts rename to packages/sdk/src/entity/event_teams.ts diff --git a/apps/web/src/entity/event_users.ts b/packages/sdk/src/entity/event_users.ts similarity index 100% rename from apps/web/src/entity/event_users.ts rename to packages/sdk/src/entity/event_users.ts diff --git a/apps/web/src/entity/event_writeup.ts b/packages/sdk/src/entity/event_writeup.ts similarity index 100% rename from apps/web/src/entity/event_writeup.ts rename to packages/sdk/src/entity/event_writeup.ts diff --git a/apps/web/src/entity/events.ts b/packages/sdk/src/entity/events.ts similarity index 93% rename from apps/web/src/entity/events.ts rename to packages/sdk/src/entity/events.ts index d5bef812..ca2b9dd7 100644 --- a/apps/web/src/entity/events.ts +++ b/packages/sdk/src/entity/events.ts @@ -1,4 +1,4 @@ -import type { EventFamily, EventPurpose, ParticipantMode } from './sea_orm_active_enums'; +import type { EventFamily, EventPurpose, ParticipantMode } from './sea_orm_active_enums.js'; export type Events = { id: string; diff --git a/apps/web/src/entity/gameboxes.ts b/packages/sdk/src/entity/gameboxes.ts similarity index 100% rename from apps/web/src/entity/gameboxes.ts rename to packages/sdk/src/entity/gameboxes.ts diff --git a/packages/sdk/src/entity/index.ts b/packages/sdk/src/entity/index.ts new file mode 100644 index 00000000..161f7a91 --- /dev/null +++ b/packages/sdk/src/entity/index.ts @@ -0,0 +1,60 @@ +export * from "./sea_orm_active_enums.js"; +export * from "./announcements.js"; +export * from "./awd_event_gameboxes.js"; +export * from "./awd_event_networks.js"; +export * from "./awd_events.js"; +export * from "./awd_flag_issues.js"; +export * from "./awd_flag_submissions.js"; +export * from "./awd_internal_token_rotations.js"; +export * from "./awd_judge_batches.js"; +export * from "./awd_judge_tasks.js"; +export * from "./awd_network_allocations.js"; +export * from "./awd_network_settings.js"; +export * from "./awd_orphan_resources.js"; +export * from "./awd_precheck_runs.js"; +export * from "./awd_reset_records.js"; +export * from "./awd_rounds.js"; +export * from "./awd_runtime_resources.js"; +export * from "./awd_score_events.js"; +export * from "./awd_team_bans.js"; +export * from "./awd_team_networks.js"; +export * from "./awd_wireguard_peers.js"; +export * from "./awdp_breaks.js"; +export * from "./awdp_evaluation_proofs.js"; +export * from "./awdp_evaluations.js"; +export * from "./awdp_event_gameboxes.js"; +export * from "./awdp_event_networks.js"; +export * from "./awdp_events.js"; +export * from "./awdp_fix_rounds.js"; +export * from "./awdp_instances.js"; +export * from "./awdp_patch_submissions.js"; +export * from "./awdp_run_writeups.js"; +export * from "./awdp_runs.js"; +export * from "./awdp_score_events.js"; +export * from "./challenge_set_items.js"; +export * from "./challenge_sets.js"; +export * from "./challenge_writeup.js"; +export * from "./challenges.js"; +export * from "./discussion_comments.js"; +export * from "./discussion_likes.js"; +export * from "./discussions.js"; +export * from "./event_announcements.js"; +export * from "./event_challenge_instance.js"; +export * from "./event_gamebox_instances.js"; +export * from "./event_instances.js"; +export * from "./event_logs.js"; +export * from "./event_team_members.js"; +export * from "./event_teams.js"; +export * from "./event_users.js"; +export * from "./event_writeup.js"; +export * from "./events.js"; +export * from "./gameboxes.js"; +export * from "./instance_endpoints.js"; +export * from "./jeopardy_challenge_solves.js"; +export * from "./jeopardy_event_challenges.js"; +export * from "./logs.js"; +export * from "./scheduled_tasks.js"; +export * from "./settings.js"; +export * from "./super_admin.js"; +export * from "./users.js"; +export * from "./weapons.js"; diff --git a/apps/web/src/entity/instance_endpoints.ts b/packages/sdk/src/entity/instance_endpoints.ts similarity index 100% rename from apps/web/src/entity/instance_endpoints.ts rename to packages/sdk/src/entity/instance_endpoints.ts diff --git a/apps/web/src/entity/jeopardy_challenge_solves.ts b/packages/sdk/src/entity/jeopardy_challenge_solves.ts similarity index 100% rename from apps/web/src/entity/jeopardy_challenge_solves.ts rename to packages/sdk/src/entity/jeopardy_challenge_solves.ts diff --git a/apps/web/src/entity/jeopardy_event_challenges.ts b/packages/sdk/src/entity/jeopardy_event_challenges.ts similarity index 100% rename from apps/web/src/entity/jeopardy_event_challenges.ts rename to packages/sdk/src/entity/jeopardy_event_challenges.ts diff --git a/apps/web/src/entity/logs.ts b/packages/sdk/src/entity/logs.ts similarity index 100% rename from apps/web/src/entity/logs.ts rename to packages/sdk/src/entity/logs.ts diff --git a/apps/web/src/entity/scheduled_tasks.ts b/packages/sdk/src/entity/scheduled_tasks.ts similarity index 100% rename from apps/web/src/entity/scheduled_tasks.ts rename to packages/sdk/src/entity/scheduled_tasks.ts diff --git a/apps/web/src/entity/sea_orm_active_enums.ts b/packages/sdk/src/entity/sea_orm_active_enums.ts similarity index 100% rename from apps/web/src/entity/sea_orm_active_enums.ts rename to packages/sdk/src/entity/sea_orm_active_enums.ts diff --git a/apps/web/src/entity/settings.ts b/packages/sdk/src/entity/settings.ts similarity index 71% rename from apps/web/src/entity/settings.ts rename to packages/sdk/src/entity/settings.ts index 5e385e74..a7ceab61 100644 --- a/apps/web/src/entity/settings.ts +++ b/packages/sdk/src/entity/settings.ts @@ -1,4 +1,4 @@ -import type { SettingValueType } from './sea_orm_active_enums'; +import type { SettingValueType } from './sea_orm_active_enums.js'; export type Settings = { id: string; diff --git a/apps/web/src/entity/super_admin.ts b/packages/sdk/src/entity/super_admin.ts similarity index 100% rename from apps/web/src/entity/super_admin.ts rename to packages/sdk/src/entity/super_admin.ts diff --git a/apps/web/src/entity/users.ts b/packages/sdk/src/entity/users.ts similarity index 100% rename from apps/web/src/entity/users.ts rename to packages/sdk/src/entity/users.ts diff --git a/apps/web/src/entity/weapons.ts b/packages/sdk/src/entity/weapons.ts similarity index 100% rename from apps/web/src/entity/weapons.ts rename to packages/sdk/src/entity/weapons.ts diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 00000000..5cc7f549 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,156 @@ +/** + * FloatCTF 错误模型。 + * + * 目标(docs/frontend/ARCHITECTURE.md §错误契约): + * - 每个前端作者**不必重复解析 Axios 错误**就能拿到:HTTP 状态、平台业务码、 + * 平台文案、原始 cause。 + * - 同时**保持与既有 UI 完全一致的读取方式**(`error.response?.data?.message` / + * `error.message`):迁移期间不要求任何页面改写错误处理,UI 行为零变化。 + * + * 因此 `FloatCTFError` 既提供类型化字段(`httpStatus` / `code` / `platformMessage` / + * `original`),也保留 axios 兼容的 `response` 形态。 + */ + +import { isAxiosError } from "axios"; +import { UNI_SUCCESS_CODE } from "./protocol.js"; + +export type FloatCTFErrorKind = + /** 服务端返回了 HTTP 响应,但状态码非 2xx。 */ + | "http" + /** 平台返回了非成功业务码(HTTP 200 但 `code !== 0`)。 */ + | "platform" + /** 请求未拿到响应:超时 / 连接被拒 / DNS 失败 / 被取消。 */ + | "network" + /** 无法归类的异常。 */ + | "unknown"; + +/** 与 axios `response` 兼容的最小形态(既有 UI 读取 `response.data.message`)。 */ +export interface FloatCTFErrorResponse { + status?: number; + statusText?: string; + data?: unknown; + headers?: unknown; +} + +export class FloatCTFError extends Error { + override readonly name = "FloatCTFError"; + readonly kind: FloatCTFErrorKind; + /** HTTP 状态码(无响应时为 undefined)。 */ + readonly httpStatus?: number; + /** 平台业务码(若响应体是 `UniResponse`)。 */ + readonly code?: number; + /** 平台文案(`UniResponse.message`)。 */ + readonly platformMessage?: string; + /** 原始错误(通常是 axios 错误),用于深度诊断。 */ + readonly original?: unknown; + /** 是否因认证失败(401)触发;前端据此决定是否跳登录。 */ + readonly unauthorized: boolean; + /** 兼容既有 UI 的 axios 形态读取。 */ + readonly response?: FloatCTFErrorResponse; + + constructor(init: { + message: string; + kind: FloatCTFErrorKind; + httpStatus?: number; + code?: number; + platformMessage?: string; + original?: unknown; + unauthorized?: boolean; + response?: FloatCTFErrorResponse; + }) { + super(init.message); + this.kind = init.kind; + this.httpStatus = init.httpStatus; + this.code = init.code; + this.platformMessage = init.platformMessage; + this.original = init.original; + this.unauthorized = init.unauthorized ?? init.httpStatus === 401; + this.response = init.response; + } + + /** 最适合展示给用户的文案:平台文案优先,其次 Error.message。 */ + get displayMessage(): string { + return this.platformMessage?.trim() || this.message; + } + + toJSON(): Record { + return { + name: this.name, + kind: this.kind, + httpStatus: this.httpStatus, + code: this.code, + platformMessage: this.platformMessage, + message: this.message, + unauthorized: this.unauthorized, + }; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * 把任意异常归类为 {@link FloatCTFError}。 + * + * 已归一化的错误原样返回(幂等),避免拦截器叠加多层包装。 + */ +export function toFloatCTFError(error: unknown): FloatCTFError { + if (error instanceof FloatCTFError) return error; + + if (isAxiosError(error)) { + const status = error.response?.status; + const data = error.response?.data; + const platformMessage = + isRecord(data) && typeof data.message === "string" ? data.message : undefined; + const code = + isRecord(data) && typeof data.code === "number" ? data.code : undefined; + const kind: FloatCTFErrorKind = status === undefined ? "network" : "http"; + return new FloatCTFError({ + message: error.message, + kind, + ...(status !== undefined ? { httpStatus: status } : {}), + ...(code !== undefined ? { code } : {}), + ...(platformMessage !== undefined ? { platformMessage } : {}), + original: error, + unauthorized: status === 401, + ...(error.response + ? { + response: { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data, + headers: error.response.headers, + }, + } + : {}), + }); + } + + if (error instanceof Error) { + return new FloatCTFError({ + message: error.message, + kind: "unknown", + original: error, + }); + } + + return new FloatCTFError({ message: String(error), kind: "unknown", original: error }); +} + +/** + * 把非 2xx 之外的"平台业务失败"(HTTP 2xx 但 `code !== 0`)归一化为错误。 + * 返回 `null` 表示这是一个成功的 envelope。 + */ +export function floatCTFErrorFromEnvelope(envelope: unknown): FloatCTFError | null { + if (!isRecord(envelope)) return null; + const code = envelope.code; + if (typeof code !== "number" || code === UNI_SUCCESS_CODE) return null; + const message = typeof envelope.message === "string" ? envelope.message : "platform error"; + return new FloatCTFError({ + message, + kind: "platform", + code, + platformMessage: message, + }); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 00000000..d1af4777 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,69 @@ +/** + * `@floatctf/sdk` —— FloatCTF 平台客户端 SDK(**框架无关**)。 + * + * 依赖方向(唯一允许的方向): + * + * ``` + * FloatCTF Backend ── REST / SSE / Bearer ──▶ @floatctf/sdk ──▶ @floatctf/react ──▶ Frontend + * ``` + * + * 本包**绝不**依赖:React、路由、Zustand、Primer、Tailwind、任何应用页面。 + * 认证 token 的来源与 401 之后的 UI 反应**全部由调用方注入** + * (见 `createFloatCTFClient`)。 + * + * 内容: + * - `createFloatCTFClient()` / `FloatCTFError` / `UniResponse`(传输与错误契约) + * - 领域 API 客户端(选手端 / 管理端 / AWD / AWDP) + * - fetch-based SSE(Bearer 走 Authorization 头) + * - 前端可见的 DTO 类型 + * + * 生成的数据库实体是独立入口:`@floatctf/sdk/entity`(不要把 DB 列类型混进 API 面)。 + */ + +// ── 协议与错误 ────────────────────────────────────────────────────────────── +export { UNI_SUCCESS_CODE, type QueryParams, type UniResponse } from "./protocol.js"; +export { + FloatCTFError, + floatCTFErrorFromEnvelope, + toFloatCTFError, + type FloatCTFErrorKind, + type FloatCTFErrorResponse, +} from "./errors.js"; + +// ── 传输 ──────────────────────────────────────────────────────────────────── +export { + admin_api, + bindHttpClients, + resetHttpClientsForTests, + service_api, + type FloatCTFAuthScope, + type FloatCTFClientOptions, + type FloatCTFHttpClient, + type UnauthorizedContext, +} from "./transport.js"; + +// ── 客户端工厂 ────────────────────────────────────────────────────────────── +export { + createFloatCTFClient, + httpClients, + type FloatCTFClient, +} from "./client.js"; + +// ── 视图无关 DTO 类型 ─────────────────────────────────────────────────────── +export * from "./types/index.js"; + +// ── SSE ───────────────────────────────────────────────────────────────────── +export { + connectSse, + type ConnectSseOptions, + type SseConnection, + type SseConnectionState, + type SseConnectionStatus, +} from "./sse/connectSse.js"; +export { createSseParser, type SseEvent, type SseParser } from "./sse/parser.js"; + +// ── 领域 API(门面 + 各模块具名导出)──────────────────────────────────────── +export * from "./api/index.js"; +export * from "./api/awd.js"; +export * from "./api/awdp.js"; +export * from "./api/awdpRuns.js"; diff --git a/packages/sdk/src/protocol.ts b/packages/sdk/src/protocol.ts new file mode 100644 index 00000000..65a31133 --- /dev/null +++ b/packages/sdk/src/protocol.ts @@ -0,0 +1,32 @@ +/** + * FloatCTF 平台响应封装(`UniResponse`)——**以后端真实行为为准**。 + * + * 后端所有 handler 都返回 `UniResponse`(Rust `UniResult`), + * 序列化为 `{ code, message, data, meta }`: + * - `code`:平台业务码(0 = 成功;错误响应用 `AppError` 映射) + * - `message`:平台文案(可直接展示,也可能是英文技术文案) + * - `data`:业务数据;无数据时为 `null` 或缺失 + * - `meta`:列表接口的分页元信息 + * + * 这里**不做包装/解包转换**:SDK 原样透传后端 envelope,避免发明第二套协议。 + */ + +/** 列表/分页查询参数。 */ +export type QueryParams = { + offset?: number; + limit?: number; + page?: number; + total?: number; + filter?: string; +}; + +/** 平台统一响应封装。 */ +export type UniResponse = { + code: number; + message: string; + data?: T; + meta?: QueryParams; +}; + +/** 平台成功业务码。 */ +export const UNI_SUCCESS_CODE = 0; diff --git a/apps/web/src/lib/sse/__tests__/connectSse.test.ts b/packages/sdk/src/sse/__tests__/connectSse.test.ts similarity index 100% rename from apps/web/src/lib/sse/__tests__/connectSse.test.ts rename to packages/sdk/src/sse/__tests__/connectSse.test.ts diff --git a/apps/web/src/lib/sse/__tests__/parser.test.ts b/packages/sdk/src/sse/__tests__/parser.test.ts similarity index 100% rename from apps/web/src/lib/sse/__tests__/parser.test.ts rename to packages/sdk/src/sse/__tests__/parser.test.ts diff --git a/apps/web/src/lib/sse/connectSse.ts b/packages/sdk/src/sse/connectSse.ts similarity index 99% rename from apps/web/src/lib/sse/connectSse.ts rename to packages/sdk/src/sse/connectSse.ts index d64765ec..1d423146 100644 --- a/apps/web/src/lib/sse/connectSse.ts +++ b/packages/sdk/src/sse/connectSse.ts @@ -13,7 +13,7 @@ * - Last-Event-ID 支持(若后端提供) */ -import { type SseEvent, type SseParser, createSseParser } from "./parser"; +import { type SseEvent, type SseParser, createSseParser } from "./parser.js"; // ── 类型 ──────────────────────────────────────────────────────────────────── diff --git a/packages/sdk/src/sse/index.ts b/packages/sdk/src/sse/index.ts new file mode 100644 index 00000000..e683c056 --- /dev/null +++ b/packages/sdk/src/sse/index.ts @@ -0,0 +1,9 @@ +export { createSseParser } from "./parser.js"; +export type { SseEvent, SseParser } from "./parser.js"; +export { connectSse } from "./connectSse.js"; +export type { + ConnectSseOptions, + SseConnection, + SseConnectionState, + SseConnectionStatus, +} from "./connectSse.js"; diff --git a/apps/web/src/lib/sse/parser.ts b/packages/sdk/src/sse/parser.ts similarity index 100% rename from apps/web/src/lib/sse/parser.ts rename to packages/sdk/src/sse/parser.ts diff --git a/packages/sdk/src/transport.ts b/packages/sdk/src/transport.ts new file mode 100644 index 00000000..fb0118f4 --- /dev/null +++ b/packages/sdk/src/transport.ts @@ -0,0 +1,233 @@ +/** + * HTTP 传输层:共享的 Axios 实例 + Bearer 认证注入 + 错误归一化 + 401 回调。 + * + * **这个模块是 SDK 与 UI 的分界线**(替换旧的 `apps/web/src/api/axios.ts`): + * + * | 机制 | 归属 | + * |------|------| + * | baseURL、Authorization 头注入、错误解析、401 判定 | SDK(本模块) | + * | token 从哪来(Zustand / localStorage / 内存) | 前端(`getUserToken` / `getAdminToken` 回调) | + * | 401 之后做什么(跳哪个路由 / 清哪个 store / 弹什么提示) | 前端(`onUnauthorized` 回调) | + * + * SDK **绝不** import router / Zustand / localStorage / 任何登录页 URL。 + * + * ## 关于模块级 handle + * + * `service_api` / `admin_api` 是"待绑定的 HTTP handle":API 模块直接 import 它们, + * 由 `createFloatCTFClient()` 在启动时绑定真实 axios 实例。这样一个页面只会有一个 + * 客户端(bootstrap 每次加载只挂载一个前端),而 **认证来源与 UI 反应仍然由调用方注入**。 + * 未绑定就调用会抛出可读错误,而不是静默发到错误地址。 + */ + +import axios, { + type AxiosInstance, + type AxiosRequestConfig, + type AxiosResponse, +} from "axios"; + +import { FloatCTFError, toFloatCTFError } from "./errors.js"; + +export type { QueryParams, UniResponse } from "./protocol.js"; + +/** 认证作用域:选手端 / 管理端各自独立携带 token(互不影响)。 */ +export type FloatCTFAuthScope = "user" | "admin"; + +export interface UnauthorizedContext { + scope: FloatCTFAuthScope; + status: number; + error: FloatCTFError; +} + +export interface FloatCTFClientOptions { + /** 选手端 API base URL,默认 `/api`。 */ + baseUrl?: string; + /** 管理端 API base URL,默认 `${baseUrl}/admin`。 */ + adminBaseUrl?: string; + /** 取当前选手 token(同步、纯读)。默认恒为 `null`。 */ + getUserToken?: () => string | null | undefined; + /** 取当前管理员 token(同步、纯读)。默认恒为 `null`。 */ + getAdminToken?: () => string | null | undefined; + /** + * 收到 401 时调用。前端在此决定清理 token / 跳转登录页 / 提示用户。 + * SDK 自身不跳转、不写 localStorage。 + */ + onUnauthorized?: (context: UnauthorizedContext) => void; + /** 每次请求失败时调用(已归一化的错误)。 */ + onError?: (error: FloatCTFError, scope: FloatCTFAuthScope) => void; + /** 附加到两个 axios 实例的默认配置(如 `withCredentials`、`timeout`)。 */ + requestConfig?: AxiosRequestConfig; +} + +/** + * 绑定后暴露给 API 模块的 HTTP handle。 + * + * 泛型签名**逐字对齐 axios**(含 `R = AxiosResponse`):这样 `return http.get(...)` + * 这类写法能继续按上下文返回类型推断 `R`,迁移前后的类型行为完全一致。 + */ +// biome-ignore lint/suspicious/noExplicitAny: 与 axios 的签名保持一致 +export interface FloatCTFHttpClient { + get, D = any>( + url: string, + config?: AxiosRequestConfig, + ): Promise; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + delete, D = any>( + url: string, + config?: AxiosRequestConfig, + ): Promise; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + post, D = any>( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + put, D = any>( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise; + // biome-ignore lint/suspicious/noExplicitAny: 同上 + patch, D = any>( + url: string, + data?: D, + config?: AxiosRequestConfig, + ): Promise; + /** 底层 axios 实例(需要拦截器/流式响应时的逃生舱)。 */ + readonly instance: AxiosInstance; +} + +interface Binding { + service: AxiosInstance | null; + admin: AxiosInstance | null; +} + +const binding: Binding = { service: null, admin: null }; + +function unboundError(scope: FloatCTFAuthScope): FloatCTFError { + return new FloatCTFError({ + message: + `FloatCTF ${scope} HTTP client is not configured. ` + + "Call createFloatCTFClient() before issuing API requests.", + kind: "unknown", + }); +} + +function createHandle(scope: FloatCTFAuthScope): FloatCTFHttpClient { + const current = (): AxiosInstance | null => + scope === "user" ? binding.service : binding.admin; + const require = (): AxiosInstance => { + const instance = current(); + if (!instance) throw unboundError(scope); + return instance; + }; + /** + * 未绑定时返回**被拒绝的 Promise** 而不是同步抛错: + * 所有 API 方法都必须保持 axios 的"总是返回 Promise"语义,否则调用方的 + * `.catch()` 会漏掉这个错误,变成未捕获异常。 + */ + const run = (fn: (instance: AxiosInstance) => Promise): Promise => { + const instance = current(); + if (!instance) return Promise.reject(unboundError(scope)); + return fn(instance); + }; + const handle: FloatCTFHttpClient = { + get(url, config) { + return run((instance) => instance.get(url, config)); + }, + delete(url, config) { + return run((instance) => instance.delete(url, config)); + }, + post(url, data, config) { + return run((instance) => instance.post(url, data, config)); + }, + put(url, data, config) { + return run((instance) => instance.put(url, data, config)); + }, + patch(url, data, config) { + return run((instance) => instance.patch(url, data, config)); + }, + get instance() { + return require(); + }, + }; + return handle; +} + +/** 选手端 HTTP handle(由 `createFloatCTFClient()` 绑定)。 */ +export const service_api: FloatCTFHttpClient = createHandle("user"); + +/** 管理端 HTTP handle(由 `createFloatCTFClient()` 绑定)。 */ +export const admin_api: FloatCTFHttpClient = createHandle("admin"); + +function attachInterceptors( + instance: AxiosInstance, + scope: FloatCTFAuthScope, + options: FloatCTFClientOptions, +): void { + const tokenGetter = + scope === "user" ? options.getUserToken : options.getAdminToken; + + instance.interceptors.request.use((config) => { + const token = tokenGetter?.(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); + + instance.interceptors.response.use( + (response) => response, + (error: unknown) => { + // 网络层失败(超时 / 连接中断)没有 response:归一化必须容错, + // 否则真正的错误会被 TypeError 覆盖成 "reading 'status'"。 + const normalized = toFloatCTFError(error); + if (normalized.httpStatus === 401) { + options.onUnauthorized?.({ + scope, + status: 401, + error: normalized, + }); + } + options.onError?.(normalized, scope); + return Promise.reject(normalized); + }, + ); +} + +/** + * 构建并绑定两个 axios 实例。 + * + * @internal 由 `createFloatCTFClient()` 调用;单独使用请优先用后者。 + */ +export function bindHttpClients(options: FloatCTFClientOptions = {}): { + service: AxiosInstance; + admin: AxiosInstance; + dispose: () => void; +} { + const baseUrl = (options.baseUrl ?? "/api").replace(/\/+$/, ""); + const adminBaseUrl = (options.adminBaseUrl ?? `${baseUrl}/admin`).replace(/\/+$/, ""); + + const service = axios.create({ baseURL: baseUrl, ...options.requestConfig }); + const admin = axios.create({ baseURL: adminBaseUrl, ...options.requestConfig }); + attachInterceptors(service, "user", options); + attachInterceptors(admin, "admin", options); + + binding.service = service; + binding.admin = admin; + + return { + service, + admin, + dispose: () => { + if (binding.service === service) binding.service = null; + if (binding.admin === admin) binding.admin = null; + }, + }; +} + +/** 测试用:解除绑定,避免用例之间互相污染。 */ +export function resetHttpClientsForTests(): void { + binding.service = null; + binding.admin = null; +} diff --git a/packages/sdk/src/types/adminChallenge.ts b/packages/sdk/src/types/adminChallenge.ts new file mode 100644 index 00000000..6d22bc39 --- /dev/null +++ b/packages/sdk/src/types/adminChallenge.ts @@ -0,0 +1,33 @@ +import type { ChallengesListItem } from "./challengeDto.js"; + +/** 题目完整性检查结果。 */ +export type ChallengeCheckResult = { + id: string; + challenge_name: string; + is_ok: boolean; + docker_image: boolean; + attachment: boolean; + /** static / attachment-only 题目(无 src/Dockerfile):没有镜像,无需 Build。 */ + static_content: boolean; +}; + +/** 题目构建结果。 */ +export type BuildChallengeResult = { + challenge_name: string; + is_ok: boolean; + message: string; +}; + +/** 题目目录扫描结果条目。 */ +export type ChallengeScanItem = { + safe_name: string; + name: string | null; + version: string | null; + status: "added" | "skipped" | "error"; + message: string; +}; + +/** 题目导入响应。 */ +export type ImportChallengeResponse = { + challenge: ChallengesListItem; +}; diff --git a/packages/sdk/src/types/adminEventChallenge.ts b/packages/sdk/src/types/adminEventChallenge.ts new file mode 100644 index 00000000..899c63bf --- /dev/null +++ b/packages/sdk/src/types/adminEventChallenge.ts @@ -0,0 +1,15 @@ +import type { Challenges } from "../entity/index.js"; + +/** 管理端赛事挂题行(jeopardy_event_challenges)。 */ +export type EventChallenge = { + event_id: string; + challenge_id: string; + hidden: boolean; + points: number; +}; + +/** 管理端赛事题目列表条目(挂题行 + 题目实体)。 */ +export type EventChallengeResult = { + event_challenge: EventChallenge; + challenge: Challenges; +}; diff --git a/packages/sdk/src/types/adminEventTeam.ts b/packages/sdk/src/types/adminEventTeam.ts new file mode 100644 index 00000000..1db2b9a4 --- /dev/null +++ b/packages/sdk/src/types/adminEventTeam.ts @@ -0,0 +1,17 @@ +import type { EventTeamMemberRole, EventTeams } from "../entity/index.js"; + +/** 管理端战队成员条目。 */ +export type TeamMemberResult = { + username: string; + nickname: string; + role: EventTeamMemberRole; + points: number; +}; + +/** 管理端战队列表条目。 */ +export type TeamResult = { + id: string; + team: EventTeams; + captain: string; + members: TeamMemberResult[]; +}; diff --git a/packages/sdk/src/types/adminEventUser.ts b/packages/sdk/src/types/adminEventUser.ts new file mode 100644 index 00000000..b441e930 --- /dev/null +++ b/packages/sdk/src/types/adminEventUser.ts @@ -0,0 +1,8 @@ +import type { EventUsers, Users } from "../entity/index.js"; + +/** 管理端赛事用户列表条目。 */ +export type EventUserResult = { + id: string; + user: Users; + event_user: EventUsers; +}; diff --git a/apps/web/src/types/challengeDto.ts b/packages/sdk/src/types/challengeDto.ts similarity index 95% rename from apps/web/src/types/challengeDto.ts rename to packages/sdk/src/types/challengeDto.ts index ecc41050..e5eee6bd 100644 --- a/apps/web/src/types/challengeDto.ts +++ b/packages/sdk/src/types/challengeDto.ts @@ -1,4 +1,4 @@ -import type { Challenges } from "@/entity"; +import type { Challenges } from "../entity/index.js"; /** API 返回的附件元数据(不包含任何 flag 明文)。 */ export type ChallengeAttachmentDto = { diff --git a/packages/sdk/src/types/challengeWriteup.ts b/packages/sdk/src/types/challengeWriteup.ts new file mode 100644 index 00000000..bf6905ee --- /dev/null +++ b/packages/sdk/src/types/challengeWriteup.ts @@ -0,0 +1,11 @@ +import type { ChallengeWriteup, Challenges } from "../entity/index.js"; + +/** 题目 Writeup 详情(含作者与所属题目)。 */ +export type ChallengeWriteupResult = { + id: string; + nickname: string; + avatar?: string; + email: string; + challenge: Challenges; + writeup: ChallengeWriteup; +}; diff --git a/packages/sdk/src/types/dataPresent.ts b/packages/sdk/src/types/dataPresent.ts new file mode 100644 index 00000000..a895c87e --- /dev/null +++ b/packages/sdk/src/types/dataPresent.ts @@ -0,0 +1,32 @@ +import type { Events } from "../entity/index.js"; +import type { ScoreboardItem } from "./scoreboard.js"; +import type { TrendItem } from "./trend.js"; + +/** 数据大屏:单题聚合。 */ +export type DataEventChallenge = { + name: string; + category: string; + points: number; + solved_count: number; + solved_percent: number; +}; + +/** 数据大屏:最近解出流水。 */ +export type DataEventChallengeSolve = { + user_nickname: string; + challenge_name: string; + challenge_category: string; + created_at: string; // NaiveDateTime → string + bonus_points: number; +}; + +/** 数据大屏聚合响应。 */ +export type DataPresent = { + event: Events; // 对应 events::Model + user_count: number; + team_count: number; + solved_recent_15: DataEventChallengeSolve[]; + event_challenges: DataEventChallenge[]; + scoreboard_top10: ScoreboardItem[]; + trend: TrendItem[]; +}; diff --git a/packages/sdk/src/types/database.ts b/packages/sdk/src/types/database.ts new file mode 100644 index 00000000..7306e78e --- /dev/null +++ b/packages/sdk/src/types/database.ts @@ -0,0 +1,14 @@ +/** 管理端 SQL 控制台:单条语句请求。 */ +export type SqlStatement = { + sql: string; +}; + +/** 管理端 SQL 控制台:执行结果。 */ +export type SqlResult = { + sql_type: string; + // biome-ignore lint/suspicious/noExplicitAny: 任意列的查询结果 + rows: Record[]; + count: number; + rows_affected: number; + elapsed_ms: number; +}; diff --git a/packages/sdk/src/types/eventChallenge.ts b/packages/sdk/src/types/eventChallenge.ts new file mode 100644 index 00000000..ec4d298f --- /dev/null +++ b/packages/sdk/src/types/eventChallenge.ts @@ -0,0 +1,12 @@ +import type { ChallengesListItem } from "./challengeDto.js"; + +/** 选手端赛事题目列表条目。 */ +export type EventChallengeResult = { + /** 行唯一标识(后端不返回,前端由 challenge.id 合成,供 DataTable 行 key)。 */ + id: string; + challenge: ChallengesListItem; + current_points: number; + solved_count: number; + solved: boolean; + solved_no: number; +}; diff --git a/packages/sdk/src/types/eventInfo.ts b/packages/sdk/src/types/eventInfo.ts new file mode 100644 index 00000000..b3f50732 --- /dev/null +++ b/packages/sdk/src/types/eventInfo.ts @@ -0,0 +1,21 @@ +import type { EventTeamMembers, EventTeams, Events } from "../entity/index.js"; + +/** 赛事详情接口返回的成员条目。 */ +export type EventTeamMemberResult = { + member_name: string; + member: EventTeamMembers; +}; + +/** 赛事详情接口返回的战队(含成员)。 */ +export type EventTeamResult = { + team: EventTeams; + members: EventTeamMemberResult[]; +}; + +/** 选手端赛事列表/详情条目。 */ +export type EventInfo = { + id: string; + event: Events; + team_result?: EventTeamResult; + joined: boolean; +}; diff --git a/packages/sdk/src/types/eventInstance.ts b/packages/sdk/src/types/eventInstance.ts new file mode 100644 index 00000000..f346f935 --- /dev/null +++ b/packages/sdk/src/types/eventInstance.ts @@ -0,0 +1,9 @@ +import type { Instances } from "./instanceDto.js"; + +/** 赛事实例列表条目(含归属信息)。 */ +export type EventInstanceResult = { + id: string; + instance: Instances; + challenge_name: string; + user_nickname: string; +}; diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts new file mode 100644 index 00000000..77372c6c --- /dev/null +++ b/packages/sdk/src/types/index.ts @@ -0,0 +1,42 @@ +/** + * 前端可见的 API DTO / 视图无关类型。 + * + * 这些类型**曾经散落在路由页面文件里**,被 `api/*` 反向 import(架构倒挂)。 + * 现在集中在这里:页面 → sdk 是唯一允许的方向。 + */ + +export type { ChallengeAttachmentDto, ChallengesListItem } from "./challengeDto.js"; +export type { TopUser } from "./top.js"; +export type { TrendItem, TrendPoint } from "./trend.js"; +export type { ChallengeScoreboard, ScoreboardItem } from "./scoreboard.js"; +export type { EventInstanceResult } from "./eventInstance.js"; +export type { EventChallengeResult } from "./eventChallenge.js"; +export type { Instances, InstancesDto } from "./instanceDto.js"; +export type { ChallengeWriteupResult } from "./challengeWriteup.js"; +export type { + EventInfo, + EventTeamMemberResult, + EventTeamResult, +} from "./eventInfo.js"; +export type { EventUserResult } from "./adminEventUser.js"; +export type { TeamMemberResult, TeamResult } from "./adminEventTeam.js"; +export type { + DataEventChallenge, + DataEventChallengeSolve, + DataPresent, +} from "./dataPresent.js"; +export type { EventChallenge, EventChallengeResult as AdminEventChallengeResult } from "./adminEventChallenge.js"; +export type { SqlResult, SqlStatement } from "./database.js"; +export type { + DiskInformation, + DockerImageInfo, + DockerInformation, + NetworkInterfaceInfo, + SystemInformation, +} from "./systemInformation.js"; +export type { + BuildChallengeResult, + ChallengeCheckResult, + ChallengeScanItem, + ImportChallengeResponse, +} from "./adminChallenge.js"; diff --git a/packages/sdk/src/types/instanceDto.ts b/packages/sdk/src/types/instanceDto.ts new file mode 100644 index 00000000..c72f78a9 --- /dev/null +++ b/packages/sdk/src/types/instanceDto.ts @@ -0,0 +1,30 @@ +/** + * 选手侧挑战/靶机实例 DTO(`GET /api/instances`,与后端 `InstancesDto` 对齐)。 + * + * 归一化后为合并列表:challenge(挑战练习)与 gamebox(AWDP 练习)两类。 + * **注意**:这不是生成的实体 `entity/event_challenge_instance`,字段以接口返回为准。 + */ +export type InstancesDto = { + id: string; + status: string; + flag: string; + content: string | null; + /** 关联挑战;AWDP 练习实例为 null(用 run_id/gamebox_id 标识)。 */ + challenge_id: string | null; + event_id: string; + team_id?: string | null; + user_id: string; + identifier: string; + created_at: string; + updated_at: string; + destroy_at: string | null; + challenge_title?: string | null; + event_title?: string | null; + user_name?: string | null; + run_id?: string | null; + gamebox_id?: string | null; + gamebox_title?: string | null; +}; + +/** 兼容旧引用(实体型已拆分为 entity/event_challenge_instance,DTO 字段才是接口返回)。 */ +export type Instances = InstancesDto; diff --git a/packages/sdk/src/types/scoreboard.ts b/packages/sdk/src/types/scoreboard.ts new file mode 100644 index 00000000..c1dba706 --- /dev/null +++ b/packages/sdk/src/types/scoreboard.ts @@ -0,0 +1,17 @@ +/** 计分榜格子:某主体在某题上的解出状态(后端 `jeopardy::domain::scoreboard::ChallengeScoreboard`)。 */ +export type ChallengeScoreboard = { + name: string; + solved: boolean; + solved_no: number; +}; + +/** 计分榜一行(个人或战队)。 */ +export type ScoreboardItem = { + id: string; + no: number; + name: string; + avatar?: string; + score: number; + solved_count: number; + challenges: ChallengeScoreboard[]; +}; diff --git a/packages/sdk/src/types/systemInformation.ts b/packages/sdk/src/types/systemInformation.ts new file mode 100644 index 00000000..2ceef0c9 --- /dev/null +++ b/packages/sdk/src/types/systemInformation.ts @@ -0,0 +1,54 @@ +/** 磁盘信息。 */ +export type DiskInformation = { + name: string; + mount_point: string; + file_system: string; + total_space: number; + available_space: number; + used_space: number; + usage_percent: number; +}; + +/** 网卡信息。 */ +export type NetworkInterfaceInfo = { + name: string; + ip_addresses: string[]; + received: number; + transmitted: number; + recv_rate: number; + transmit_rate: number; +}; + +/** Docker 镜像摘要。 */ +export type DockerImageInfo = { + id: string; + repo_tags: string[]; + size: number; +}; + +/** Docker 概况。 */ +export type DockerInformation = { + image_count: number; + images: DockerImageInfo[]; + running_container_count: number; + total_disk: number; +}; + +/** 管理端仪表盘系统信息。 */ +export type SystemInformation = { + name?: string; + kernel_version?: string; + os_version?: string; + host_name?: string; + uptime: number; + total_memory: number; + used_memory: number; + total_swap: number; + used_swap: number; + avg_temp: number; + max_temp: number; + nb_cpu: number; + disks_info: DiskInformation[]; + network_interfaces: NetworkInterfaceInfo[]; + docker_info: DockerInformation; +}; diff --git a/packages/sdk/src/types/top.ts b/packages/sdk/src/types/top.ts new file mode 100644 index 00000000..c2ba3ad8 --- /dev/null +++ b/packages/sdk/src/types/top.ts @@ -0,0 +1,8 @@ +/** `GET /api/solves/top` 排行榜条目(选手端 Top 15)。 */ +export type TopUser = { + no: number; + nickname: string; + avatar?: string; + solved_count: number; + solved_last_at: string; +}; diff --git a/packages/sdk/src/types/trend.ts b/packages/sdk/src/types/trend.ts new file mode 100644 index 00000000..ffce555f --- /dev/null +++ b/packages/sdk/src/types/trend.ts @@ -0,0 +1,12 @@ +/** 赛事走势图数据点(时间 + 当时总分)。 */ +export type TrendPoint = { + name: string; + score: number; // total score + time: string; // NaiveDateTime → ISO 字符串 +}; + +/** 走势图单个主体(个人或战队)的折线。 */ +export type TrendItem = { + name: string; + points: TrendPoint[]; +}; diff --git a/packages/sdk/tsconfig.build.json b/packages/sdk/tsconfig.build.json new file mode 100644 index 00000000..990a0848 --- /dev/null +++ b/packages/sdk/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "exclude": ["src/**/__tests__/**", "src/**/*.test.ts"] +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 00000000..b347dd51 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "skipLibCheck": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts new file mode 100644 index 00000000..9e12c292 --- /dev/null +++ b/packages/sdk/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // 默认 Node 环境:SDK 是框架/浏览器无关的传输与协议代码。 + // 需要 DOM 的用例在文件首行加 `// @vitest-environment jsdom`。 + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + passWithNoTests: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8795a1b6..d71a9c9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,28 @@ importers: .: {} apps/web: + dependencies: + '@floatctf/frontend-runtime': + specifier: workspace:* + version: link:../../packages/frontend-runtime + devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vite: + specifier: ^6.3.5 + version: 6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0) + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) + + frontends/default: dependencies: '@emotion/is-prop-valid': specifier: 1.2.2 @@ -16,6 +38,15 @@ importers: '@faker-js/faker': specifier: ^9.6.0 version: 9.9.0 + '@floatctf/frontend-runtime': + specifier: workspace:* + version: link:../../packages/frontend-runtime + '@floatctf/react': + specifier: workspace:* + version: link:../../packages/react + '@floatctf/sdk': + specifier: workspace:* + version: link:../../packages/sdk '@primer/css': specifier: ^22.0.2 version: 22.1.1(@primer/primitives@11.8.1) @@ -93,7 +124,7 @@ importers: version: 11.0.10(react@19.2.6) recharts: specifier: ^3.2.0 - version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1) + version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.8)(react@19.2.6)(redux@5.0.1) rehype-rewrite: specifier: ^4.0.2 version: 4.0.4 @@ -153,11 +184,81 @@ importers: specifier: ^4.2.4 version: 4.2.4 -packages: + packages/frontend-runtime: + devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} + packages/react: + dependencies: + '@floatctf/sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 + '@tanstack/react-query': + specifier: ^5.66.5 + version: 5.100.10(react@19.2.6) + '@testing-library/react': + specifier: ^16.2.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.1.7(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0)) + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + react: + specifier: ^19.0.0 + version: 19.2.6 + react-dom: + specifier: ^19.0.0 + version: 19.2.6(react@19.2.6) + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) + + packages/sdk: + dependencies: + axios: + specifier: ^1.11.0 + version: 1.16.0(debug@4.4.1) + devDependencies: + '@biomejs/biome': + specifier: 1.9.4 + version: 1.9.4 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.13)(@types/node@24.12.4)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0) + +packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -168,42 +269,22 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.0': - resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} - engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.3': resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.3': - resolution: {integrity: sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==} - engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.3': - resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -212,20 +293,10 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} @@ -240,10 +311,6 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.27.1': - resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} @@ -252,19 +319,10 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.3': - resolution: {integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==} - engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.3': - resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} @@ -294,34 +352,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.28.3': - resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} - engines: {node: '>=6.9.0'} - '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.3': - resolution: {integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.2': - resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -1244,9 +1286,6 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - '@types/hast@2.3.10': - resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} - '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1262,9 +1301,6 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} - '@types/prismjs@1.26.5': - resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} - '@types/prismjs@1.26.6': resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==} @@ -1727,9 +1763,6 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - decode-named-character-reference@1.2.0: - resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} - decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1807,9 +1840,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.39.10: - resolution: {integrity: sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==} - es-toolkit@1.46.1: resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} @@ -1950,9 +1980,6 @@ packages: hast-util-is-element@3.0.0: resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - hast-util-parse-selector@3.1.1: - resolution: {integrity: sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==} - hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} @@ -1977,9 +2004,6 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} - hastscript@7.2.0: - resolution: {integrity: sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==} - hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} @@ -2577,9 +2601,6 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} - refractor@4.9.0: - resolution: {integrity: sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==} - refractor@5.0.0: resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} @@ -2597,9 +2618,6 @@ packages: rehype-parse@9.0.1: resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} - rehype-prism-plus@2.0.1: - resolution: {integrity: sha512-Wglct0OW12tksTUseAPyWPo3srjBOY7xKlql/DPKi7HbsdZTyaLCAoO58QBKSczFQxElTsQlOY3JDOFzB/K++Q==} - rehype-prism-plus@2.0.2: resolution: {integrity: sha512-jTHb8ZtQHd2VWAAKeCINgv/8zNEF0+LesmwJak69GemoPVN9/8fGEARTvqOpKqmN57HwaM9z8UKBVNVJe8zggw==} @@ -2878,11 +2896,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - use-sync-external-store@1.5.0: - resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -3063,11 +3076,6 @@ packages: snapshots: - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 - '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.3.0 @@ -3083,42 +3091,14 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.27.1 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.0': {} - '@babel/compat-data@7.29.3': {} - '@babel/core@7.28.3': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.3) - '@babel/helpers': 7.28.3 - '@babel/parser': 7.28.3 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.3 - '@babel/types': 7.28.2 - convert-source-map: 2.0.0 - debug: 4.4.1 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3139,14 +3119,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.3': - dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.30 - jsesc: 3.1.0 - '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.3 @@ -3155,14 +3127,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.30 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.3 @@ -3173,13 +3137,6 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -3187,15 +3144,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.3)': - dependencies: - '@babel/core': 7.28.3 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -3209,26 +3157,15 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.27.1': {} - '@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.3': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.29.0 - '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.28.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -3243,44 +3180,24 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.3)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.3 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.3)': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.28.3 + '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.27.1 - '@babel/runtime@7.28.3': {} - '@babel/runtime@7.29.2': {} - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.3 - '@babel/types': 7.29.0 - '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 '@babel/parser': 7.29.3 '@babel/types': 7.29.0 - '@babel/traverse@7.28.3': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.3 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.3 - '@babel/template': 7.27.2 - '@babel/types': 7.29.0 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -3293,11 +3210,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.28.2': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 - '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -3903,7 +3815,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) - '@babel/template': 7.27.2 + '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 '@tanstack/router-core': 1.169.2 @@ -3941,8 +3853,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.27.1 - '@babel/runtime': 7.28.3 + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -3952,7 +3864,7 @@ snapshots: '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.1.7(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.29.2 '@testing-library/dom': 10.4.1 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -3964,8 +3876,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -3976,7 +3888,7 @@ snapshots: '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.3 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@types/babel__traverse@7.28.0': @@ -4120,10 +4032,6 @@ snapshots: '@types/geojson@7946.0.16': {} - '@types/hast@2.3.10': - dependencies: - '@types/unist': 2.0.11 - '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -4140,8 +4048,6 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/prismjs@1.26.5': {} - '@types/prismjs@1.26.6': {} '@types/react-dom@19.1.7(@types/react@19.2.14)': @@ -4154,11 +4060,11 @@ snapshots: '@types/styled-system@5.1.23': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/styled-system__css@5.0.21': dependencies: - csstype: 3.1.3 + csstype: 3.2.3 '@types/styled-system__theme-get@5.0.4': {} @@ -4198,12 +4104,12 @@ snapshots: '@uiw/react-md-editor@4.1.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.29.2 '@uiw/react-markdown-preview': 5.2.0(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 react-dom: 19.2.6(react@19.2.6) rehype: 13.0.2 - rehype-prism-plus: 2.0.1 + rehype-prism-plus: 2.0.2 transitivePeerDependencies: - '@types/react' - supports-color @@ -4217,9 +4123,9 @@ snapshots: '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@24.12.4)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: - '@babel/core': 7.28.3 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.3) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.3) + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 @@ -4281,7 +4187,7 @@ snapshots: ahooks@3.9.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.29.2 '@types/js-cookie': 3.0.6 dayjs: 1.11.20 intersection-observer: 0.12.2 @@ -4654,10 +4560,6 @@ snapshots: decimal.js@10.6.0: {} - decode-named-character-reference@1.2.0: - dependencies: - character-entities: 2.0.2 - decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -4722,8 +4624,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 - es-toolkit@1.39.10: {} - es-toolkit@1.46.1: {} esbuild@0.25.9: @@ -4886,10 +4786,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hast-util-parse-selector@3.1.1: - dependencies: - '@types/hast': 2.3.10 - hast-util-parse-selector@4.0.0: dependencies: '@types/hast': 3.0.4 @@ -4925,7 +4821,7 @@ snapshots: nth-check: 2.1.1 property-information: 7.1.0 space-separated-tokens: 2.0.2 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 zwitch: 2.0.4 hast-util-to-html@9.0.5: @@ -4980,14 +4876,6 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hastscript@7.2.0: - dependencies: - '@types/hast': 2.3.10 - comma-separated-tokens: 2.0.3 - hast-util-parse-selector: 3.1.1 - property-information: 6.5.0 - space-separated-tokens: 2.0.2 - hastscript@9.0.1: dependencies: '@types/hast': 3.0.4 @@ -4998,7 +4886,7 @@ snapshots: history@5.3.0: dependencies: - '@babel/runtime': 7.28.3 + '@babel/runtime': 7.29.2 hsluv@1.0.1: {} @@ -5613,7 +5501,7 @@ snapshots: '@types/unist': 2.0.11 character-entities-legacy: 3.0.0 character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.2.0 + decode-named-character-reference: 1.3.0 is-alphanumerical: 2.0.1 is-decimal: 2.0.1 is-hexadecimal: 2.0.1 @@ -5738,7 +5626,7 @@ snapshots: dependencies: '@types/use-sync-external-store': 0.0.6 react: 19.2.6 - use-sync-external-store: 1.5.0(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) optionalDependencies: '@types/react': 19.2.14 redux: 5.0.1 @@ -5751,21 +5639,21 @@ snapshots: dependencies: picomatch: 2.3.1 - recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@17.0.2)(react@19.2.6)(redux@5.0.1): + recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.8)(react@19.2.6)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.9.0(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6) clsx: 2.1.1 decimal.js-light: 2.5.1 - es-toolkit: 1.39.10 + es-toolkit: 1.46.1 eventemitter3: 5.0.1 immer: 10.1.3 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - react-is: 17.0.2 + react-is: 19.2.8 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1) reselect: 5.1.1 tiny-invariant: 1.3.3 - use-sync-external-store: 1.5.0(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) victory-vendor: 37.3.6 transitivePeerDependencies: - '@types/react' @@ -5777,13 +5665,6 @@ snapshots: redux@5.0.1: {} - refractor@4.9.0: - dependencies: - '@types/hast': 2.3.10 - '@types/prismjs': 1.26.5 - hastscript: 7.2.0 - parse-entities: 4.0.2 - refractor@5.0.0: dependencies: '@types/hast': 3.0.4 @@ -5817,15 +5698,6 @@ snapshots: hast-util-from-html: 2.0.3 unified: 11.0.5 - rehype-prism-plus@2.0.1: - dependencies: - hast-util-to-string: 3.0.1 - parse-numeric-range: 1.3.0 - refractor: 4.9.0 - rehype-parse: 9.0.1 - unist-util-filter: 5.0.1 - unist-util-visit: 5.0.0 - rehype-prism-plus@2.0.2: dependencies: hast-util-to-string: 3.0.1 @@ -5845,7 +5717,7 @@ snapshots: dependencies: hast-util-select: 6.0.4 unified: 11.0.5 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 rehype-slug@6.0.0: dependencies: @@ -6171,10 +6043,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-sync-external-store@1.5.0(react@19.2.6): - dependencies: - react: 19.2.6 - use-sync-external-store@1.6.0(react@19.2.6): dependencies: react: 19.2.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 764b1c08..c0632ac9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "apps/web" - "packages/*" + - "frontends/*" allowBuilds: "@biomejs/biome": true diff --git a/scripts/check-architecture.sh b/scripts/check-architecture.sh new file mode 100755 index 00000000..4782484f --- /dev/null +++ b/scripts/check-architecture.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# +# FloatCTF 前端平台架构边界守卫(docs/frontend/ARCHITECTURE.md)。 +# +# 这些边界的意义:一旦破防,可插拔前端就退化成"同一个仓库里的一堆页面"—— +# 外部仓库无法独立构建、非 React 前端被迫下载 Primer、bootstrap 悄悄把默认前端 +# 打进产物。文字约定靠不住,所以这里做成**失败即红**的门禁。 +# +# 规则(全部为"存在即违规",无例外): +# 1. @floatctf/sdk 不得依赖 React / 路由 / 状态库 / Primer / 应用层 +# 2. @floatctf/react 不得包含 UI(Primer / Tailwind / CSS / 路由 / 页面) +# 3. @floatctf/frontend-runtime 不得依赖任何 UI 框架 +# 4. frontends/default 不得 import apps/web 或逃出自身包的仓库路径 +# 5. apps/web (bootstrap) 不得 import frontends/default(生产产物必须纯净) +# 6. packages/* 不得 import 任何 app/frontend +# 7. 后端 API_CONTRACT_VERSION 必须与前端运行时的 API 契约一致 +# +# 用法:scripts/check-architecture.sh +set -Eeuo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +FAIL=0 + +info() { printf '[check-architecture] %s\n' "$*"; } +fail() { printf '[check-architecture] FAIL: %s\n' "$*" >&2; FAIL=1; } + +# 依赖存在性断言:路径漂移必须报错,绝不静默放行。 +require_dir() { + [ -d "$1" ] || fail "expected directory missing: $1(守卫会静默失效,必须修)" +} +require_file() { + [ -f "$1" ] || fail "expected file missing: $1" +} + +require_dir packages/sdk/src +require_dir packages/react/src +require_dir packages/frontend-runtime/src +require_dir apps/web/src +require_dir frontends/default/src +require_file apps/api/src/core/contract.rs + +# 只检查非注释行:注释里提到某个包名不算违规。 +code_only() { grep -vE '^[[:space:]]*(//|/\*|\*|\*/)'; } + +# matches_imports +matches_imports() { + local dir="$1" pattern="$2" + grep -rnE --include='*.ts' --include='*.tsx' --include='*.css' \ + "from \"${pattern}\"|import\\(\"${pattern}\"|@import[[:space:]]+\"${pattern}" \ + "$dir" 2>/dev/null | grep -v '/dist/' | grep -v '/node_modules/' | code_only || true +} + +check_forbidden() { #