diff --git a/src/__tests__/manager-auth-route.test.ts b/src/__tests__/manager-auth-route.test.ts new file mode 100644 index 0000000..90ffa7f --- /dev/null +++ b/src/__tests__/manager-auth-route.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The Manager must not load the business Auth.js module even if a stale +// client requests `/api/auth/session`. +vi.mock("@/auth", () => { + throw new Error("Business Auth.js must not load in Manager mode."); +}); + +import { GET } from "@/app/api/auth/[...nextauth]/route"; + +describe("Manager business-auth boundary", () => { + const originalManagerMode = process.env.MANAGER_MODE; + + afterEach(() => { + if (originalManagerMode === undefined) delete process.env.MANAGER_MODE; + else process.env.MANAGER_MODE = originalManagerMode; + }); + + it("returns 404 without importing business Auth.js", async () => { + process.env.MANAGER_MODE = "true"; + + const response = await GET(new Request("https://manager.example.com/api/auth/session")); + + expect(response.status).toBe(404); + }); +}); diff --git a/src/__tests__/manager-framework-routes.test.ts b/src/__tests__/manager-framework-routes.test.ts new file mode 100644 index 0000000..941019b --- /dev/null +++ b/src/__tests__/manager-framework-routes.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db/prisma", () => { + throw new Error("Business Prisma must not load in Manager framework routes."); +}); + +vi.mock("@/lib/business/branding", () => { + throw new Error("Business branding must not load in Manager framework routes."); +}); + +import { GET as health } from "@/app/api/health/route"; +import manifest from "@/app/manifest"; + +describe("Manager framework routes", () => { + const originalManagerMode = process.env.MANAGER_MODE; + + afterEach(() => { + if (originalManagerMode === undefined) delete process.env.MANAGER_MODE; + else process.env.MANAGER_MODE = originalManagerMode; + }); + + it("serves health without Prisma", async () => { + process.env.MANAGER_MODE = "true"; + + const response = await health(); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ status: "healthy", deployment: "manager" }); + }); + + it("serves Manager manifest without business branding", async () => { + process.env.MANAGER_MODE = "true"; + + const result = await manifest(); + + expect(result.name).toBe("NextChair Manager"); + expect(result.start_url).toBe("/manager"); + }); +}); diff --git a/src/__tests__/proxy-manager-mode.test.ts b/src/__tests__/proxy-manager-mode.test.ts index dbe8e5e..8e4f4fe 100644 --- a/src/__tests__/proxy-manager-mode.test.ts +++ b/src/__tests__/proxy-manager-mode.test.ts @@ -26,16 +26,26 @@ describe("proxy deployment separation", () => { isInstalledForRequest.mockReset(); }); - it("serves the Manager root without loading or checking business installation state", async () => { + it("routes the Manager root before loading or checking business installation state", async () => { process.env.MANAGER_MODE = "true"; const response = await handleProxyRequest(request("/")); - expect(response.status).toBe(200); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://manager.example.com/manager"); expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); expect(isInstalledForRequest).not.toHaveBeenCalled(); }); + it("serves Manager routes without business installation state", async () => { + process.env.MANAGER_MODE = "true"; + + const response = await handleProxyRequest(request("/manager")); + + expect(response.status).toBe(200); + expect(isInstalledForRequest).not.toHaveBeenCalled(); + }); + it("checks installation state for a normal business request", async () => { process.env.MANAGER_MODE = "false"; isInstalledForRequest.mockResolvedValue(false); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 86c9f3d..04f79dc 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,3 +1,20 @@ -import { handlers } from "@/auth"; +/** + * Business Auth.js is intentionally unavailable in the standalone Manager + * deployment. Keeping this check before the dynamic import prevents a stale + * SessionProvider or direct request from initialising Prisma there. + */ +function managerAuthDisabled() { + return process.env.MANAGER_MODE === "true"; +} -export const { GET, POST } = handlers; +export async function GET(request: Request) { + if (managerAuthDisabled()) return new Response(null, { status: 404 }); + const { handlers } = await import("@/auth"); + return handlers.GET(request); +} + +export async function POST(request: Request) { + if (managerAuthDisabled()) return new Response(null, { status: 404 }); + const { handlers } = await import("@/auth"); + return handlers.POST(request); +} diff --git a/src/app/api/cron/process-notifications/route.ts b/src/app/api/cron/process-notifications/route.ts index 651b3fc..dbad571 100644 --- a/src/app/api/cron/process-notifications/route.ts +++ b/src/app/api/cron/process-notifications/route.ts @@ -1,10 +1,5 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/db/prisma"; -import { isInstalledForRequest } from "@/lib/installation/installation-state"; import { secretsMatch } from "@/lib/security/secrets-match"; -import { expireOverdueRewards } from "@/services/loyalty/loyalty.service"; -import { getEmailProvider, getPushProvider } from "@/services/notifications/providers"; -import { processDueNotificationDeliveries } from "@/services/notifications/reminder-worker"; /** * The external trigger point for the background notification processor — @@ -30,6 +25,8 @@ import { processDueNotificationDeliveries } from "@/services/notifications/remin * expected secret. */ export async function POST(request: Request) { + if (process.env.MANAGER_MODE === "true") return NextResponse.json({ error: "Not available on Manager." }, { status: 404 }); + const secret = process.env.CRON_SECRET; if (!secret) { return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 }); @@ -44,10 +41,18 @@ export async function POST(request: Request) { // src/lib/installation/installation-state.ts. A 200 with a no-op result // (not an error) so an external scheduler hitting this on a fixed // interval before day-one setup doesn't page anyone. + const { isInstalledForRequest } = await import("@/lib/installation/installation-state"); if (!(await isInstalledForRequest())) { return NextResponse.json({ skipped: "not_installed" }); } + const [{ prisma }, { expireOverdueRewards }, { getEmailProvider, getPushProvider }, { processDueNotificationDeliveries }] = await Promise.all([ + import("@/lib/db/prisma"), + import("@/services/loyalty/loyalty.service"), + import("@/services/notifications/providers"), + import("@/services/notifications/reminder-worker"), + ]); + const result = await processDueNotificationDeliveries(prisma, { emailProvider: getEmailProvider(), pushProvider: getPushProvider(), diff --git a/src/app/api/cron/sync-google-reviews/route.ts b/src/app/api/cron/sync-google-reviews/route.ts index 5cfec13..64dd29c 100644 --- a/src/app/api/cron/sync-google-reviews/route.ts +++ b/src/app/api/cron/sync-google-reviews/route.ts @@ -1,8 +1,5 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/db/prisma"; -import { isInstalledForRequest } from "@/lib/installation/installation-state"; import { secretsMatch } from "@/lib/security/secrets-match"; -import { syncGoogleReviewsForAllBusinesses } from "@/services/reviews/review-sync.service"; /** * External trigger for the Google Reviews sync — mirrors @@ -19,6 +16,8 @@ import { syncGoogleReviewsForAllBusinesses } from "@/services/reviews/review-syn * never duplicates anything. */ export async function POST(request: Request) { + if (process.env.MANAGER_MODE === "true") return NextResponse.json({ error: "Not available on Manager." }, { status: 404 }); + const secret = process.env.CRON_SECRET; if (!secret) { return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 }); @@ -29,10 +28,15 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); } + const { isInstalledForRequest } = await import("@/lib/installation/installation-state"); if (!(await isInstalledForRequest())) { return NextResponse.json({ skipped: "not_installed" }); } + const [{ prisma }, { syncGoogleReviewsForAllBusinesses }] = await Promise.all([ + import("@/lib/db/prisma"), + import("@/services/reviews/review-sync.service"), + ]); const results = await syncGoogleReviewsForAllBusinesses(prisma); return NextResponse.json({ results }); } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 665f9da..4910843 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,6 +1,4 @@ import { NextResponse } from "next/server"; -import { prisma } from "@/lib/db/prisma"; -import { getInstallationState } from "@/lib/installation/installation-state"; /** * Docker/orchestrator health check — deliberately never requires @@ -11,6 +9,14 @@ import { getInstallationState } from "@/lib/installation/installation-state"; * trace — just enough for a health probe to act on. */ export async function GET() { + if (process.env.MANAGER_MODE === "true") { + return NextResponse.json({ status: "healthy", deployment: "manager" }); + } + + const [{ prisma }, { getInstallationState }] = await Promise.all([ + import("@/lib/db/prisma"), + import("@/lib/installation/installation-state"), + ]); try { await prisma.$queryRaw`SELECT 1`; } catch { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c3a3219..dd761df 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; -import { Providers } from "@/components/providers"; +import { ManagerProviders } from "@/components/manager-providers"; import "./globals.css"; const inter = Inter({ @@ -86,8 +86,17 @@ export default function RootLayout({ > Skip to main content - {children} + {process.env.MANAGER_MODE === "true" ? ( + {children} + ) : ( + {children} + )} ); } + +async function BusinessProviders({ children }: { children: React.ReactNode }) { + const { Providers } = await import("@/components/providers"); + return {children}; +} diff --git a/src/app/manifest.ts b/src/app/manifest.ts index 41761ce..a139e9f 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -1,5 +1,4 @@ import type { MetadataRoute } from "next"; -import { getBranding } from "@/lib/business/branding"; /** * Next.js's manifest file convention — auto-served at /manifest.webmanifest @@ -26,6 +25,21 @@ import { getBranding } from "@/lib/business/branding"; export const dynamic = "force-dynamic"; export default async function manifest(): Promise { + if (process.env.MANAGER_MODE === "true") { + return { + name: "NextChair Manager", + short_name: "Manager", + description: "Manage NextChair business deployments.", + start_url: "/manager", + scope: "/", + display: "standalone", + background_color: "#f4f6f4", + theme_color: "#111b15", + orientation: "portrait-primary", + icons: [{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" }], + }; + } + const { getBranding } = await import("@/lib/business/branding"); const branding = await getBranding(); return { name: branding.name, diff --git a/src/components/manager-providers.tsx b/src/components/manager-providers.tsx new file mode 100644 index 0000000..5eef00d --- /dev/null +++ b/src/components/manager-providers.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +/** + * Manager authentication uses its own signed cookie. This provider is kept + * free of next-auth/react so the Manager never polls `/api/auth/session`. + */ +export function ManagerProviders({ children }: { children: ReactNode }) { + return <>{children}; +} diff --git a/src/proxy.ts b/src/proxy.ts index bd6df29..ba7288d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -91,6 +91,11 @@ export async function handleProxyRequest(req: AuthenticatedNextRequest) { // 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") { + if (pathname === "/") { + const response = NextResponse.redirect(new URL("/manager", req.nextUrl.origin)); + response.headers.set("Content-Security-Policy", csp); + return response; + } const response = NextResponse.next(); response.headers.set("Content-Security-Policy", csp); return response;