From a8cdc2671ea2581d7591db6c04d9370c54443808 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Fri, 4 Sep 2026 21:43:26 +0100 Subject: [PATCH] Fixed the Manager deployment database dependency. Root cause src/proxy.ts statically imported installation-state.ts, which statically imported Prisma. Additionally, the root layout statically imported business branding, which also loads Prisma. The pathname check was also incorrect for Manager deployments served at /. --- src/__tests__/proxy-manager-mode.test.ts | 59 ++++++++++++++++++++++++ src/app/layout.tsx | 19 +++++++- src/proxy.ts | 24 ++++++---- 3 files changed, 92 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/proxy-manager-mode.test.ts diff --git a/src/__tests__/proxy-manager-mode.test.ts b/src/__tests__/proxy-manager-mode.test.ts new file mode 100644 index 0000000..dbe8e5e --- /dev/null +++ b/src/__tests__/proxy-manager-mode.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const isInstalledForRequest = vi.fn(); + +// If proxy.ts or its Manager request path statically imports Prisma, this +// mock throws while the test module is loading. Manager mode must not reach it. +vi.mock("@/lib/db/prisma", () => { + throw new Error("Prisma must not be imported by the Manager proxy path."); +}); + +vi.mock("@/lib/installation/installation-state", () => ({ isInstalledForRequest })); + +import { handleProxyRequest } from "@/proxy"; + +function request(pathname: string) { + return new NextRequest(`https://manager.example.com${pathname}`); +} + +describe("proxy deployment separation", () => { + const originalManagerMode = process.env.MANAGER_MODE; + + afterEach(() => { + if (originalManagerMode === undefined) delete process.env.MANAGER_MODE; + else process.env.MANAGER_MODE = originalManagerMode; + isInstalledForRequest.mockReset(); + }); + + it("serves the Manager root without loading or checking business installation state", async () => { + process.env.MANAGER_MODE = "true"; + + const response = await handleProxyRequest(request("/")); + + expect(response.status).toBe(200); + expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); + expect(isInstalledForRequest).not.toHaveBeenCalled(); + }); + + it("checks installation state for a normal business request", async () => { + process.env.MANAGER_MODE = "false"; + isInstalledForRequest.mockResolvedValue(false); + + const response = await handleProxyRequest(request("/")); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://manager.example.com/install"); + expect(isInstalledForRequest).toHaveBeenCalledOnce(); + }); + + it("preserves protected business route redirects after installation", async () => { + process.env.MANAGER_MODE = "false"; + isInstalledForRequest.mockResolvedValue(true); + + const response = await handleProxyRequest(request("/owner")); + + expect(response.status).toBe(307); + expect(response.headers.get("location")).toContain("/sign-in?callbackUrl=%2Fowner"); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 54b2541..c3a3219 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; import { Providers } from "@/components/providers"; -import { getBranding } from "@/lib/business/branding"; import "./globals.css"; const inter = Inter({ @@ -18,6 +17,13 @@ const inter = Inter({ * than "NextChair" (see that file's own fallback-order comment). */ export async function generateMetadata(): Promise { + if (process.env.MANAGER_MODE === "true") { + return { + title: { default: "NextChair Manager", template: "%s | NextChair Manager" }, + description: "Manage NextChair business deployments.", + }; + } + const { getBranding } = await import("@/lib/business/branding"); const branding = await getBranding(); return { @@ -41,6 +47,17 @@ export async function generateMetadata(): Promise { } export async function generateViewport(): Promise { + if (process.env.MANAGER_MODE === "true") { + return { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + viewportFit: "cover", + themeColor: "#111b15", + }; + } + const { getBranding } = await import("@/lib/business/branding"); const branding = await getBranding(); return { width: "device-width", diff --git a/src/proxy.ts b/src/proxy.ts index b47e45b..3e2f0d8 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,7 +1,6 @@ import NextAuth from "next-auth"; -import { NextResponse } from "next/server"; +import { NextResponse, type NextRequest } from "next/server"; import { authConfig } from "@/auth.config"; -import { isInstalledForRequest } from "@/lib/installation/installation-state"; // /home, /appointments and /settings are intentionally NOT hard-gated here // as of Phase 4 — Home must work for guests (public business browsing + @@ -21,7 +20,6 @@ const PROTECTED_PREFIXES = ["/staff", "/owner"]; // business data guard themselves (see /api/health and // /api/cron/process-notifications). const INSTALL_EXEMPT_PREFIXES = [ - "/manager", "/install", "/offline", "/manifest.webmanifest", @@ -84,17 +82,23 @@ function buildCsp(): string { ].join("; "); } -export default auth(async (req) => { +export async function handleProxyRequest(req: NextRequest) { const { pathname } = req.nextUrl; const csp = buildCsp(); + // Manager is a separate deployment and hostname, so its root pathname is + // normally `/`, not `/manager`. Branch before loading any business module. + if (process.env.MANAGER_MODE === "true") { + const response = NextResponse.next(); + response.headers.set("Content-Security-Policy", csp); + return response; + } + const isInstallExempt = INSTALL_EXEMPT_PREFIXES.some( (prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`), ); - // The platform Manager is a separate deployment and deliberately has no - // business database. Its own session guard runs inside /manager pages/API. - const isManagerPath = pathname === "/manager" || pathname.startsWith("/manager/"); - const installed = isManagerPath ? true : await isInstalledForRequest(); + const { isInstalledForRequest } = await import("@/lib/installation/installation-state"); + const installed = await isInstalledForRequest(); if (!installed && !isInstallExempt) { const response = NextResponse.redirect(new URL("/install", req.nextUrl.origin)); @@ -126,7 +130,9 @@ export default auth(async (req) => { const response = NextResponse.next(); response.headers.set("Content-Security-Policy", csp); return response; -}); +} + +export default auth(handleProxyRequest); export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],