/** * `/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('"', """); }