/** * `/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. * * The login page and dashboard are now served by the `database-admin` SvelteKit * SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the * concrete JSON/redirect routes the SPA depends on: * * /database/config — unauthenticated bootstrap: silo org slug + dev toggle * /database/dev-login — DEV ONLY bypass, registered only when the flag is on * * The dev bypass (/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. * * NOTE: concrete routes here MUST be registered before the SPA fallback * (registerDatabaseSpa serves /database and /database/*), or the wildcard would * shadow them. */ import type { FastifyInstance } from "fastify"; import type { PrismaClient } from "@prisma/client"; import { SESSION_COOKIE_NAME, signSession } 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 — the SPA builds the org-scoped Feishu login link from it. */ 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 { // Unauthenticated bootstrap for the static SPA login page. Exposes only what // the page needs to build the Feishu login link and toggle the dev button — // no secrets, no user data. app.get("/database/config", async () => { return { siloOrganizationSlug: config.siloOrganizationSlug, devLoginEnabled: config.allowDevLoginBypass, }; }); // 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/* JSON 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. Register concrete // routes before registerDatabaseSpa's /database/* fallback (done in ./plugin.ts). }