From df66691d241ea2e9a2b7f274d57f6fac638c95f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <3401797899@qq.com> Date: Mon, 20 Jul 2026 21:02:04 +0800 Subject: [PATCH] feat(hub): add /database admin surface with Feishu login Adds a self-contained `/database/*` HTTP surface under hub/src/database: - /database/admin: Feishu-only login page (Tailwind, light theme) - /database/dashboard: session-gated sidebar + content shell - /database/dev-login: DEV ONLY session bypass, double-gated by NODE_ENV != production AND HUB_DEV_LOGIN_BYPASS; never active in prod hub.ts mounts the plugin after the admin plugin so the cookie parser and /auth/feishu/* routes are available. The dev bypass logic is fully contained in the database module; admin auth routes are untouched. Co-Authored-By: Claude Opus 4.8 --- hub/.gitignore | 3 + hub/src/database/README.md | 40 +++ hub/src/database/plugin.ts | 45 ++++ hub/src/database/routes/databaseRoutes.ts | 307 ++++++++++++++++++++++ hub/src/hub.ts | 9 + 5 files changed, 404 insertions(+) create mode 100644 hub/src/database/README.md create mode 100644 hub/src/database/plugin.ts create mode 100644 hub/src/database/routes/databaseRoutes.ts diff --git a/hub/.gitignore b/hub/.gitignore index f02a5e5..4e09b8a 100644 --- a/hub/.gitignore +++ b/hub/.gitignore @@ -5,6 +5,9 @@ dist/ .env.* !.env.example .secrets/ +.dev-keyring.json +.dev-workspaces/ +.dev-skills/ admin-web/node_modules/ admin-web/build/ admin-web/.svelte-kit/ diff --git a/hub/src/database/README.md b/hub/src/database/README.md new file mode 100644 index 0000000..06daf26 --- /dev/null +++ b/hub/src/database/README.md @@ -0,0 +1,40 @@ +# src/database/ + +`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它, +所以服务器启动时能正确识别这些路由。 + +页面: + +- `/database/admin` —— 飞书登录页(唯一登录方式) +- `/database/dashboard` —— 左菜单 + 右内容的后台,未登录会跳回 `/database/admin` + +登录走平台既有的飞书 OAuth:登录页的按钮指向 `/auth/feishu/`, +回调由 `src/admin/routes/authRoutes.ts` 处理并种下 session cookie。 + +## 文件 + +| 文件 | 职责 | +|------|------| +| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` | +| `routes/databaseRoutes.ts` | 路由 + 页面渲染,**你主要在这里加内容** | + +新增一类端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/...")`, +要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts` 里 `registerXxxRoutes(app, {...})` +注册一次。 + +## 约定(与 admin 面一致) + +1. 路由用**绝对路径** `"/database/..."`,不用 Fastify prefix —— 每条路由 grep 得到。 +2. **guard 前置、fail closed**:凡碰数据的端点第一行先跑 + `requireSession` / `requireOrgRole` / `requireProjectPermission` + (都在 `../admin/auth/guards.js`)。 +3. **租户隔离**(ADR-0020):每个 Prisma 查询都 scope 到 `auth.organization.id`, + 不得跨 org。禁止无鉴权的数据路由。 +4. 数据库通过传入的 `config.prisma` 访问(全进程单例,见 `../db.ts`); + 不要在这里 `new PrismaClient()`。 + +## 为什么代码在 `src/` 下 + +`tsconfig.json` 固定 `rootDir: "src"` 且 `include: ["src/**/*.ts"]`。只有 +`src/` 下的 `.ts` 会被 `tsc` 编译、被 `tsx watch`(`npm run dev`)加载。放在 +`src/` 之外的目录不会被构建,外部识别不到。 diff --git a/hub/src/database/plugin.ts b/hub/src/database/plugin.ts new file mode 100644 index 0000000..4d17c16 --- /dev/null +++ b/hub/src/database/plugin.ts @@ -0,0 +1,45 @@ +/** + * Registers the `/database/*` HTTP surface onto the Fastify app. + * + * Single public entry point (mirrors src/admin/plugin.ts). hub.ts calls + * registerDatabasePlugin() so the routes are recognized at startup. + * + * Register AFTER registerAdminPlugin: it relies on the @fastify/cookie parser + * and the /auth/feishu/* login routes that the admin plugin adds. + * + * The dev login bypass is self-contained in THIS module: the flag is read here + * (not threaded from hub.ts) and only ever enables the `/database/dev-login` + * route below. Double-gated: NODE_ENV must not be production AND + * HUB_DEV_LOGIN_BYPASS must be truthy. Production always requires real Feishu + * OAuth. + */ +import type { FastifyInstance } from "fastify"; +import type { PrismaClient } from "@prisma/client"; +import { registerDatabaseRoutes } from "./routes/databaseRoutes.js"; + +export interface DatabasePluginConfig { + readonly prisma: PrismaClient; + readonly sessionSecret: string; + readonly siloOrganizationSlug: string; +} + +const FALSY = new Set(["", "0", "false", "no", "off"]); + +export async function registerDatabasePlugin( + app: FastifyInstance, + config: DatabasePluginConfig, +): Promise { + const allowDevLoginBypass = + process.env["NODE_ENV"] !== "production" && + !FALSY.has((process.env["HUB_DEV_LOGIN_BYPASS"] ?? "").trim().toLowerCase()); + if (allowDevLoginBypass) { + app.log.warn("DEV login bypass enabled: /database/dev-login mints a session without Feishu OAuth"); + } + + await registerDatabaseRoutes(app, { + prisma: config.prisma, + sessionSecret: config.sessionSecret, + siloOrganizationSlug: config.siloOrganizationSlug, + allowDevLoginBypass, + }); +} diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts new file mode 100644 index 0000000..5f1ef6f --- /dev/null +++ b/hub/src/database/routes/databaseRoutes.ts @@ -0,0 +1,307 @@ +/** + * `/database/*` route aggregator. + * + * Owns the `/database` HTTP surface (single place new sub-routes get wired in). + * Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the + * literal string it serves. + * + * /database/admin — Feishu-only login page (+ dev one-click button) + * /database/dashboard — sidebar + content admin shell, session-gated + * /database/dev-login — DEV ONLY bypass, registered only when the flag is on + * + * The dev bypass (button + /database/dev-login) is self-contained here and + * gated by allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS + * + NODE_ENV). Production requires real Feishu OAuth. + */ +import type { FastifyInstance } from "fastify"; +import type { PrismaClient } from "@prisma/client"; +import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js"; + +export interface DatabaseRouteConfig { + readonly prisma: PrismaClient; + /** HMAC secret for the signed session cookie — reused from the admin plane. */ + readonly sessionSecret: string; + /** Silo Organization slug — builds the org-scoped Feishu login link. */ + readonly siloOrganizationSlug: string; + /** DEV ONLY. Enables the one-click button and the /database/dev-login route. */ + readonly allowDevLoginBypass: boolean; +} + +export async function registerDatabaseRoutes( + app: FastifyInstance, + config: DatabaseRouteConfig, +): Promise { + app.get("/database/admin", async (request, reply) => { + // Already signed in → straight to the dashboard. + if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) { + return reply.redirect("/database/dashboard"); + } + return reply.type("text/html").send(renderLoginPage(config)); + }); + + app.get("/database/dashboard", async (request, reply) => { + const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config); + if (user === null) return reply.redirect("/database/admin"); + return reply.type("text/html").send(renderDashboard(user.displayName)); + }); + + // DEV ONLY bypass — self-contained here, registered only when the flag is on + // (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the + // scoped SessionIdentity shape the Feishu OAuth callback produces so the + // session guard behaves identically. Never registered in production. + if (config.allowDevLoginBypass) { + app.get("/database/dev-login", async (_request, reply) => { + const membership = await config.prisma.organizationMembership.findFirst({ + where: { + revokedAt: null, + role: { in: ["OWNER", "ADMIN"] }, + organization: { status: "ACTIVE" }, + }, + select: { userId: true, organizationId: true }, + orderBy: { createdAt: "asc" }, + }); + if (membership === null) { + return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } }); + } + const identity = await config.prisma.feishuUserIdentity.findFirst({ + where: { + userId: membership.userId, + connection: { organizationId: membership.organizationId, status: "ACTIVE" }, + }, + select: { id: true, connectionId: true, connection: { select: { organizationId: true } } }, + }); + if (identity === null) { + return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } }); + } + const token = signSession( + { + userId: membership.userId, + feishuIdentityId: identity.id, + feishuConnectionId: identity.connectionId, + feishuOrganizationId: identity.connection.organizationId, + }, + config.sessionSecret, + ); + // Local dev is http://127.0.0.1, so secure:false. This route only ever + // runs outside production (double-gated in ./plugin.ts). + reply.setCookie(SESSION_COOKIE_NAME, token, { + path: "/", + httpOnly: true, + sameSite: "lax", + secure: false, + maxAge: 7 * 24 * 60 * 60, + }); + reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used"); + return reply.redirect("/database/dashboard"); + }); + } + + // Add more /database/* routes here. Guard data routes with requireSession / + // requireOrgRole (../../admin/auth/guards.js) and scope every query to the + // caller's org (ADR-0020). Access the DB via config.prisma. +} + +/** Verify the session cookie and load the user, or null if not signed in. */ +async function resolveUser( + rawCookie: string | undefined, + config: DatabaseRouteConfig, +): Promise<{ displayName: string } | null> { + if (rawCookie === undefined || rawCookie === "") return null; + const session = verifySession(rawCookie, config.sessionSecret); + if (session === null) return null; + const user = await config.prisma.user.findUnique({ + where: { id: session.userId }, + select: { displayName: true }, + }); + return user; +} + +/** + * Shared document head: Tailwind CDN + a small design system (fonts, keyframes + * for the aurora background, fade-in, shimmer). Both pages import it so the + * look stays consistent. + */ +function pageHead(title: string): string { + return ` + + + ${title} + + + + +`; +} + +/** The animated aurora background blobs, shared by both pages (soft pastels on light). */ +const AURORA = ` +
+
+
+
+
`; + +function renderLoginPage(config: DatabaseRouteConfig): string { + const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`; + const devButton = config.allowDevLoginBypass + ? ` +
+
+
开发模式
+
+ + 一键登录管理员 + +

仅开发环境可见 · 跳过飞书 OAuth

` + : ""; + + return ` + +${pageHead("Database Admin · 登录")} + + ${AURORA} +
+
+
D
+

Database Admin

+

使用飞书登录以管理数据库

+
+ + + + 使用飞书登录 + + ${devButton} +
+ +`; +} + +/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */ +const NAV_ITEMS: ReadonlyArray<{ label: string; icon: string; href: string; active: boolean }> = [ + { label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z", href: "/database/dashboard", active: true }, + { label: "数据表", icon: "M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z", href: "#", active: false }, + { label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", href: "#", active: false }, + { label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2-1l1-2h4l1 2a7 7 0 0 1 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z", href: "#", active: false }, +]; + +function renderDashboard(displayName: string): string { + const nav = NAV_ITEMS.map((item) => { + const cls = item.active + ? "group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50" + : "group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900"; + return ` + + ${item.label} + `; + }).join("\n "); + + const stats = [ + { label: "数据表", value: "—", accent: "from-violet-200/60 to-transparent" }, + { label: "记录数", value: "—", accent: "from-cyan-200/60 to-transparent" }, + { label: "最近查询", value: "—", accent: "from-indigo-200/60 to-transparent" }, + ].map((s, i) => ` +
+
+

${s.label}

+

${s.value}

+
`).join(""); + + const initial = escapeHtml(displayName.slice(0, 1) || "U"); + + return ` + +${pageHead("Database Admin · 概览")} + + ${AURORA} +
+ + + + +
+
+
+

概览

+

欢迎回来,这里是数据库管理台

+
+ +
+ +
+
+ ${stats} +
+ +
+ 内容区占位 · 在 src/database/routes/databaseRoutes.ts 里继续搭建 +
+
+
+
+ + + +`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} diff --git a/hub/src/hub.ts b/hub/src/hub.ts index c221ce4..2093f0f 100644 --- a/hub/src/hub.ts +++ b/hub/src/hub.ts @@ -1,5 +1,6 @@ import Fastify from "fastify"; import { registerAdminPlugin } from "./admin/plugin.js"; +import { registerDatabasePlugin } from "./database/plugin.js"; import { prisma } from "./db.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js"; import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js"; @@ -143,6 +144,14 @@ export async function startHub(): Promise { secretEnvelope, }); + // `/database/*` surface. Registered after the admin plugin so the + // @fastify/cookie parser and /auth/* login routes are already available. + await registerDatabasePlugin(app, { + prisma, + sessionSecret, + siloOrganizationSlug: siloOrganization.slug, + }); + const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); if (feishuListenerEnabled) { const feishuConfig = {