Fixed the remaining Manager runtime coupling.
CI / ci (push) Canceled after 0s

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:
Stefan Lazic committed 2026-09-04 21:57:47 +01:00
1 parent 69bc0adf29
commit bc45608023
11 files changed
+161 -17

No files matched your search

+19 -2
View File
@@ -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 { 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(),
@@ -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 });
}
+8 -2
View File
@@ -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 {
+11 -2
View File
@@ -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
</a>
<Providers>{children}</Providers>
{process.env.MANAGER_MODE === "true" ? (
<ManagerProviders>{children}</ManagerProviders>
) : (
<BusinessProviders>{children}</BusinessProviders>
)}
</body>
</html>
);
}
async function BusinessProviders({ children }: { children: React.ReactNode }) {
const { Providers } = await import("@/components/providers");
return <Providers>{children}</Providers>;
}
+15 -1
View File
@@ -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<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();
return {
name: branding.name,