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 /.
This commit is contained in:
3 files changed
+92
-10
No files matched your search
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+18
-1
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
import { getBranding } from "@/lib/business/branding";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -18,6 +17,13 @@ const inter = Inter({
|
|||||||
* than "NextChair" (see that file's own fallback-order comment).
|
* than "NextChair" (see that file's own fallback-order comment).
|
||||||
*/
|
*/
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
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();
|
const branding = await getBranding();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -41,6 +47,17 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateViewport(): Promise<Viewport> {
|
export async function generateViewport(): Promise<Viewport> {
|
||||||
|
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();
|
const branding = await getBranding();
|
||||||
return {
|
return {
|
||||||
width: "device-width",
|
width: "device-width",
|
||||||
|
|||||||
+15
-9
@@ -1,7 +1,6 @@
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { authConfig } from "@/auth.config";
|
import { authConfig } from "@/auth.config";
|
||||||
import { isInstalledForRequest } from "@/lib/installation/installation-state";
|
|
||||||
|
|
||||||
// /home, /appointments and /settings are intentionally NOT hard-gated here
|
// /home, /appointments and /settings are intentionally NOT hard-gated here
|
||||||
// as of Phase 4 — Home must work for guests (public business browsing +
|
// 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
|
// business data guard themselves (see /api/health and
|
||||||
// /api/cron/process-notifications).
|
// /api/cron/process-notifications).
|
||||||
const INSTALL_EXEMPT_PREFIXES = [
|
const INSTALL_EXEMPT_PREFIXES = [
|
||||||
"/manager",
|
|
||||||
"/install",
|
"/install",
|
||||||
"/offline",
|
"/offline",
|
||||||
"/manifest.webmanifest",
|
"/manifest.webmanifest",
|
||||||
@@ -84,17 +82,23 @@ function buildCsp(): string {
|
|||||||
].join("; ");
|
].join("; ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default auth(async (req) => {
|
export async function handleProxyRequest(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
const csp = buildCsp();
|
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(
|
const isInstallExempt = INSTALL_EXEMPT_PREFIXES.some(
|
||||||
(prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
(prefix) => pathname === prefix || pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
||||||
);
|
);
|
||||||
// The platform Manager is a separate deployment and deliberately has no
|
const { isInstalledForRequest } = await import("@/lib/installation/installation-state");
|
||||||
// business database. Its own session guard runs inside /manager pages/API.
|
const installed = await isInstalledForRequest();
|
||||||
const isManagerPath = pathname === "/manager" || pathname.startsWith("/manager/");
|
|
||||||
const installed = isManagerPath ? true : await isInstalledForRequest();
|
|
||||||
|
|
||||||
if (!installed && !isInstallExempt) {
|
if (!installed && !isInstallExempt) {
|
||||||
const response = NextResponse.redirect(new URL("/install", req.nextUrl.origin));
|
const response = NextResponse.redirect(new URL("/install", req.nextUrl.origin));
|
||||||
@@ -126,7 +130,9 @@ export default auth(async (req) => {
|
|||||||
const response = NextResponse.next();
|
const response = NextResponse.next();
|
||||||
response.headers.set("Content-Security-Policy", csp);
|
response.headers.set("Content-Security-Policy", csp);
|
||||||
return response;
|
return response;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export default auth(handleProxyRequest);
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
|
||||||
|
|||||||
Reference in new issue
Block a user