1. Root cause of the database error The Manager proxy was bypassing installation checks, but / still resolved to src/app/page.tsx, which statically imported: - @/auth - Prisma - business branding - business permissions - primary business data The root route now redirects to /manager inside the proxy before the business homepage is evaluated. 2. Root cause of the Auth.js error The global business SessionProvider was mounted around Manager pages. It automatically requested: The business homepage is not evaluated first. 5. Provider and authentication separation Manager mode: - Uses ManagerProviders - Uses the signed Manager cookie session - Does not mount SessionProvider - Does not call /api/auth/session - Does not import business Auth.js or Prisma Business mode: - Dynamically loads the existing business Providers - Continues using Auth.js and SessionProvider - Preserves installation, /owner, and /staff behavior 6. Database independence Manager mode now avoids database initialization for: - Proxy requests - Root metadata and viewport - Root routing - Manager pages - Manager APIs - Manifest requests - Health requests - Business Auth.js requests - Business cron requests No fake DATABASE_* variables were added. 7. Tests Added coverage for: - Manager / routing - Manager /manager access - No installation-state check in Manager mode - Prisma import protection - Business installation checks - /owner protection - Business Auth.js 404 boundary - Manager health route without Prisma - Manager manifest without business branding 8. Validation
This commit is contained in:
11 files changed
+161
-17
No files matched your search
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,16 +26,26 @@ describe("proxy deployment separation", () => {
|
|||||||
isInstalledForRequest.mockReset();
|
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";
|
process.env.MANAGER_MODE = "true";
|
||||||
|
|
||||||
const response = await handleProxyRequest(request("/"));
|
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(response.headers.get("content-security-policy")).toContain("default-src 'self'");
|
||||||
expect(isInstalledForRequest).not.toHaveBeenCalled();
|
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 () => {
|
it("checks installation state for a normal business request", async () => {
|
||||||
process.env.MANAGER_MODE = "false";
|
process.env.MANAGER_MODE = "false";
|
||||||
isInstalledForRequest.mockResolvedValue(false);
|
isInstalledForRequest.mockResolvedValue(false);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { 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 —
|
* The external trigger point for the background notification processor —
|
||||||
@@ -30,6 +25,8 @@ import { processDueNotificationDeliveries } from "@/services/notifications/remin
|
|||||||
* expected secret.
|
* expected secret.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
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;
|
const secret = process.env.CRON_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 });
|
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
|
// 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
|
// (not an error) so an external scheduler hitting this on a fixed
|
||||||
// interval before day-one setup doesn't page anyone.
|
// interval before day-one setup doesn't page anyone.
|
||||||
|
const { isInstalledForRequest } = await import("@/lib/installation/installation-state");
|
||||||
if (!(await isInstalledForRequest())) {
|
if (!(await isInstalledForRequest())) {
|
||||||
return NextResponse.json({ skipped: "not_installed" });
|
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, {
|
const result = await processDueNotificationDeliveries(prisma, {
|
||||||
emailProvider: getEmailProvider(),
|
emailProvider: getEmailProvider(),
|
||||||
pushProvider: getPushProvider(),
|
pushProvider: getPushProvider(),
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
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 { secretsMatch } from "@/lib/security/secrets-match";
|
||||||
import { syncGoogleReviewsForAllBusinesses } from "@/services/reviews/review-sync.service";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* External trigger for the Google Reviews sync — mirrors
|
* External trigger for the Google Reviews sync — mirrors
|
||||||
@@ -19,6 +16,8 @@ import { syncGoogleReviewsForAllBusinesses } from "@/services/reviews/review-syn
|
|||||||
* never duplicates anything.
|
* never duplicates anything.
|
||||||
*/
|
*/
|
||||||
export async function POST(request: Request) {
|
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;
|
const secret = process.env.CRON_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 });
|
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 });
|
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { isInstalledForRequest } = await import("@/lib/installation/installation-state");
|
||||||
if (!(await isInstalledForRequest())) {
|
if (!(await isInstalledForRequest())) {
|
||||||
return NextResponse.json({ skipped: "not_installed" });
|
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);
|
const results = await syncGoogleReviewsForAllBusinesses(prisma);
|
||||||
return NextResponse.json({ results });
|
return NextResponse.json({ results });
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { NextResponse } from "next/server";
|
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
|
* 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.
|
* trace — just enough for a health probe to act on.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
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 {
|
try {
|
||||||
await prisma.$queryRaw`SELECT 1`;
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+11
-2
@@ -1,6 +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 { ManagerProviders } from "@/components/manager-providers";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -86,8 +86,17 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
Skip to main content
|
Skip to main content
|
||||||
</a>
|
</a>
|
||||||
<Providers>{children}</Providers>
|
{process.env.MANAGER_MODE === "true" ? (
|
||||||
|
<ManagerProviders>{children}</ManagerProviders>
|
||||||
|
) : (
|
||||||
|
<BusinessProviders>{children}</BusinessProviders>
|
||||||
|
)}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function BusinessProviders({ children }: { children: React.ReactNode }) {
|
||||||
|
const { Providers } = await import("@/components/providers");
|
||||||
|
return <Providers>{children}</Providers>;
|
||||||
|
}
|
||||||
+15
-1
@@ -1,5 +1,4 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { getBranding } from "@/lib/business/branding";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js's manifest file convention — auto-served at /manifest.webmanifest
|
* 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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
export default async function manifest(): Promise<MetadataRoute.Manifest> {
|
||||||
|
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();
|
const branding = await getBranding();
|
||||||
return {
|
return {
|
||||||
name: branding.name,
|
name: branding.name,
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
@@ -91,6 +91,11 @@ export async function handleProxyRequest(req: AuthenticatedNextRequest) {
|
|||||||
// Manager is a separate deployment and hostname, so its root pathname is
|
// Manager is a separate deployment and hostname, so its root pathname is
|
||||||
// normally `/`, not `/manager`. Branch before loading any business module.
|
// normally `/`, not `/manager`. Branch before loading any business module.
|
||||||
if (process.env.MANAGER_MODE === "true") {
|
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();
|
const response = NextResponse.next();
|
||||||
response.headers.set("Content-Security-Policy", csp);
|
response.headers.set("Content-Security-Policy", csp);
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
Reference in new issue
Block a user