diff --git a/.env.example b/.env.example index 869e715e..285ab245 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ NODE_ENV=production PORT=3002 NEXT_TELEMETRY_DISABLED=1 UV_THREADPOOL_SIZE=16 -# Non-production preview only; production always returns 404. +# Production requires this kill switch plus housekeeping.preview.access. HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false # --- HOTEL & URLS --- diff --git a/drizzle/migrations/0024_housekeeping_preview_access.sql b/drizzle/migrations/0024_housekeeping_preview_access.sql new file mode 100644 index 00000000..2f4fbf80 --- /dev/null +++ b/drizzle/migrations/0024_housekeeping_preview_access.sql @@ -0,0 +1,3 @@ +INSERT INTO `acl_permissions` (`slug`, `title`) +VALUES ('housekeeping.preview.access', 'Access Housekeeping preview') +ON DUPLICATE KEY UPDATE `title` = VALUES(`title`); diff --git a/src/app/admin-next/layout.tsx b/src/app/admin-next/layout.tsx index d99a5392..e01f9681 100644 --- a/src/app/admin-next/layout.tsx +++ b/src/app/admin-next/layout.tsx @@ -2,12 +2,26 @@ import { notFound } from "next/navigation"; import type { ReactNode } from "react"; import { env } from "@/env"; import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate"; +import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; +import { PERMS } from "@/lib/permission-slugs"; + +export default async function AdminNextLayout({ + children, +}: { + children: ReactNode; +}) { + const hasProductionAccess = + env.NODE_ENV === "production" && env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED + ? (await getHousekeepingCapabilityContext()).has( + PERMS.HOUSEKEEPING_PREVIEW_ACCESS, + ) + : false; -export default function AdminNextLayout({ children }: { children: ReactNode }) { if ( !isHousekeepingPreviewEnabled({ nodeEnv: env.NODE_ENV, flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED, + hasProductionAccess, }) ) { notFound(); diff --git a/src/features/housekeeping/foundation/preview-gate.test.ts b/src/features/housekeeping/foundation/preview-gate.test.ts index 3f322b70..57b84d21 100644 --- a/src/features/housekeeping/foundation/preview-gate.test.ts +++ b/src/features/housekeeping/foundation/preview-gate.test.ts @@ -3,14 +3,24 @@ import { isHousekeepingPreviewEnabled } from "./preview-gate"; describe("isHousekeepingPreviewEnabled", () => { it.each([ - ["development", true, true], - ["test", true, true], - ["development", false, false], - ["production", true, false], - ["production", false, false], - ] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => { - expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected); - }); + ["development", true, false, true], + ["test", true, false, true], + ["development", false, true, false], + ["production", true, true, true], + ["production", true, false, false], + ["production", false, true, false], + ] as const)( + "NODE_ENV=%s flag=%s productionAccess=%s => %s", + (nodeEnv, flag, hasProductionAccess, expected) => { + expect( + isHousekeepingPreviewEnabled({ + nodeEnv, + flag, + hasProductionAccess, + }), + ).toBe(expected); + }, + ); }); describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => { diff --git a/src/features/housekeeping/foundation/preview-gate.ts b/src/features/housekeeping/foundation/preview-gate.ts index 5b121163..cc729c11 100644 --- a/src/features/housekeeping/foundation/preview-gate.ts +++ b/src/features/housekeeping/foundation/preview-gate.ts @@ -1,6 +1,10 @@ export function isHousekeepingPreviewEnabled(input: { nodeEnv: "development" | "test" | "production"; flag: boolean; + hasProductionAccess?: boolean; }): boolean { - return input.nodeEnv !== "production" && input.flag; + return ( + input.flag && + (input.nodeEnv !== "production" || input.hasProductionAccess === true) + ); } diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index ae9bd9b6..66973e20 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -365,10 +365,12 @@ describe("/admin-next preview gate", () => { vi.clearAllMocks(); routeMocks.env.NODE_ENV = "test"; routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true; + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext([]), + ); }); it.each([ - ["production", true], ["production", false], ["development", false], ] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => { @@ -385,6 +387,35 @@ describe("/admin-next preview gate", () => { expect(routeMocks.notFound).toHaveBeenCalledTimes(1); }); + it("returns 404 in production without the dedicated preview permission", async () => { + routeMocks.env.NODE_ENV = "production"; + + await expect(async () => + renderRoute( + AdminNextLayout({ + children: createElement("p", null, "Preview child"), + }), + ), + ).rejects.toThrow("NEXT_NOT_FOUND"); + expect(routeMocks.notFound).toHaveBeenCalledTimes(1); + }); + + it("renders in production for a rank with the dedicated preview permission", async () => { + routeMocks.env.NODE_ENV = "production"; + routeMocks.getHousekeepingCapabilityContext.mockResolvedValue( + capabilityContext(["housekeeping.preview.access"]), + ); + + const html = await renderRoute( + AdminNextLayout({ + children: createElement("p", null, "Preview child"), + }), + ); + + expect(html).toContain("Preview child"); + expect(routeMocks.notFound).not.toHaveBeenCalled(); + }); + it.each(["development", "test"] as const)( "renders children in %s when explicitly enabled", async (nodeEnv) => { diff --git a/src/lib/permission-slugs.ts b/src/lib/permission-slugs.ts index bf957fd6..659a6cb4 100644 --- a/src/lib/permission-slugs.ts +++ b/src/lib/permission-slugs.ts @@ -3,6 +3,7 @@ // (modules.ts → sidebar.tsx) without pulling in db/server-only deps. export const PERMS = { + HOUSEKEEPING_PREVIEW_ACCESS: "housekeeping.preview.access", ADMIN_DASHBOARD: "admin.dashboard", USERS_VIEW: "admin.users.view", USERS_EDIT: "admin.users.edit",