diff --git a/.env.example b/.env.example
index 346d985be6..869e715e7c 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,8 @@ NODE_ENV=production
PORT=3002
NEXT_TELEMETRY_DISABLED=1
UV_THREADPOOL_SIZE=16
+# Non-production preview only; production always returns 404.
+HOUSEKEEPING_NEXT_PREVIEW_ENABLED=false
# --- HOTEL & URLS ---
HOTEL_NAME=EPIC WEB CONTROL
diff --git a/src/app/admin-next/[domain]/layout.tsx b/src/app/admin-next/[domain]/layout.tsx
new file mode 100644
index 0000000000..cedf4ca22b
--- /dev/null
+++ b/src/app/admin-next/[domain]/layout.tsx
@@ -0,0 +1,59 @@
+import { notFound } from "next/navigation";
+import { getTranslations } from "next-intl/server";
+import type { ReactNode } from "react";
+import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
+import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation";
+import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
+import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
+import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell";
+import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
+
+const MESSAGE_PREFIX = "pages.housekeeping.";
+
+function namespaceKey(key: string): string {
+ if (!key.startsWith(MESSAGE_PREFIX)) {
+ throw new Error(`invalid housekeeping message key: ${key}`);
+ }
+
+ return key.slice(MESSAGE_PREFIX.length);
+}
+
+export default async function AdminNextDomainLayout({
+ children,
+ params,
+}: {
+ children: ReactNode;
+ params: Promise<{ domain: string }>;
+}) {
+ const { domain } = await params;
+ const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
+ const activeDomain = registry.domains.find((entry) => entry.id === domain);
+
+ if (!activeDomain) notFound();
+
+ const context = await getHousekeepingCapabilityContext();
+ if (!satisfiesCapability(context, activeDomain.capability)) notFound();
+
+ const translate = await getTranslations("pages.housekeeping");
+ const navigation = buildHousekeepingNavigation(registry, context, (key) =>
+ translate(namespaceKey(key) as never),
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/admin-next/[domain]/page.tsx b/src/app/admin-next/[domain]/page.tsx
new file mode 100644
index 0000000000..cd9f51ef44
--- /dev/null
+++ b/src/app/admin-next/[domain]/page.tsx
@@ -0,0 +1,45 @@
+import { notFound } from "next/navigation";
+import { getTranslations } from "next-intl/server";
+import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell";
+import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state";
+import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
+import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
+
+const MESSAGE_PREFIX = "pages.housekeeping.";
+
+function namespaceKey(key: string): string {
+ if (!key.startsWith(MESSAGE_PREFIX)) {
+ throw new Error(`invalid housekeeping message key: ${key}`);
+ }
+
+ return key.slice(MESSAGE_PREFIX.length);
+}
+
+export default async function AdminNextDomainPage({
+ params,
+}: {
+ params: Promise<{ domain: string }>;
+}) {
+ const { domain } = await params;
+ const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
+ const activeDomain = registry.domains.find((entry) => entry.id === domain);
+
+ if (!activeDomain) notFound();
+
+ const translate = await getTranslations("pages.housekeeping");
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/admin-next/layout.tsx b/src/app/admin-next/layout.tsx
new file mode 100644
index 0000000000..d99a539293
--- /dev/null
+++ b/src/app/admin-next/layout.tsx
@@ -0,0 +1,17 @@
+import { notFound } from "next/navigation";
+import type { ReactNode } from "react";
+import { env } from "@/env";
+import { isHousekeepingPreviewEnabled } from "@/features/housekeeping/foundation/preview-gate";
+
+export default function AdminNextLayout({ children }: { children: ReactNode }) {
+ if (
+ !isHousekeepingPreviewEnabled({
+ nodeEnv: env.NODE_ENV,
+ flag: env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED,
+ })
+ ) {
+ notFound();
+ }
+
+ return children;
+}
diff --git a/src/app/admin-next/page.tsx b/src/app/admin-next/page.tsx
new file mode 100644
index 0000000000..b36e4e7d50
--- /dev/null
+++ b/src/app/admin-next/page.tsx
@@ -0,0 +1,17 @@
+import { notFound, redirect } from "next/navigation";
+import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context";
+import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry";
+import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
+import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
+
+export default async function AdminNextPage() {
+ const context = await getHousekeepingCapabilityContext();
+ const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
+ const firstVisibleDomain = registry.domains.find((domain) =>
+ satisfiesCapability(context, domain.capability),
+ );
+
+ if (!firstVisibleDomain) notFound();
+
+ redirect(firstVisibleDomain.previewHref);
+}
diff --git a/src/env.ts b/src/env.ts
index 9d30081bf1..4b6e751a74 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -9,6 +9,10 @@ const schema = z
NODE_ENV: z
.enum(["development", "test", "production"])
.default("development"),
+ HOUSEKEEPING_NEXT_PREVIEW_ENABLED: z
+ .string()
+ .optional()
+ .transform((value) => value === "true" || value === "1"),
DATABASE_URL: z.string().url(),
DATABASE_POOL_SIZE: z.coerce.number().int().positive().default(10),
DATABASE_IDLE_TIMEOUT_MS: z.coerce
diff --git a/src/features/housekeeping/foundation/preview-gate.test.ts b/src/features/housekeeping/foundation/preview-gate.test.ts
new file mode 100644
index 0000000000..3f322b7033
--- /dev/null
+++ b/src/features/housekeeping/foundation/preview-gate.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+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);
+ });
+});
+
+describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
+ const originalSkipValidation = process.env.SKIP_ENV_VALIDATION;
+ const originalPreviewFlag = process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
+
+ beforeEach(() => {
+ vi.resetModules();
+ delete process.env.SKIP_ENV_VALIDATION;
+ });
+
+ afterEach(() => {
+ if (originalSkipValidation === undefined) {
+ delete process.env.SKIP_ENV_VALIDATION;
+ } else {
+ process.env.SKIP_ENV_VALIDATION = originalSkipValidation;
+ }
+
+ if (originalPreviewFlag === undefined) {
+ delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
+ } else {
+ process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = originalPreviewFlag;
+ }
+
+ vi.resetModules();
+ });
+
+ it.each([
+ ["true", true],
+ ["1", true],
+ ["false", false],
+ ["yes", false],
+ [undefined, false],
+ ] as const)("normalizes %s to %s", async (value, expected) => {
+ if (value === undefined) {
+ delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
+ } else {
+ process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = value;
+ }
+
+ const { env } = await import("@/env");
+
+ expect(env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED).toBe(expected);
+ });
+});
diff --git a/src/features/housekeeping/foundation/preview-gate.ts b/src/features/housekeeping/foundation/preview-gate.ts
new file mode 100644
index 0000000000..5b12116361
--- /dev/null
+++ b/src/features/housekeeping/foundation/preview-gate.ts
@@ -0,0 +1,6 @@
+export function isHousekeepingPreviewEnabled(input: {
+ nodeEnv: "development" | "test" | "production";
+ flag: boolean;
+}): boolean {
+ return input.nodeEnv !== "production" && input.flag;
+}
diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts
new file mode 100644
index 0000000000..a78ec0ec75
--- /dev/null
+++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts
@@ -0,0 +1,308 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { createElement, type ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { PERMS } from "@/lib/permission-slugs";
+import type { HousekeepingCapabilityContext } from "./contracts";
+
+const routeMocks = vi.hoisted(() => {
+ const messages: Record = {
+ "preview.badge": "Localized preview",
+ "preview.commandDisabled": "Localized disabled command",
+ "preview.backToSite": "Localized back to site",
+ "navigation.skipToContent": "Localized skip to content",
+ "navigation.primary": "Localized primary navigation",
+ "navigation.contextual": "Localized contextual navigation",
+ "domains.people.title": "Localized People",
+ "domains.people.description": "Localized People description",
+ "domains.economy.title": "Localized Economy",
+ "domains.economy.description": "Localized Economy description",
+ "states.empty.title": "Localized empty title",
+ "states.empty.description": "Localized empty description",
+ };
+ const translate = vi.fn((key: string) => {
+ const message = messages[key];
+ if (message === undefined)
+ throw new Error(`Unexpected translation: ${key}`);
+ return message;
+ });
+
+ return {
+ env: {
+ NODE_ENV: "test" as "development" | "test" | "production",
+ HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
+ },
+ getHousekeepingCapabilityContext: vi.fn(),
+ getTranslations: vi.fn(async (namespace: string) => {
+ if (namespace !== "pages.housekeeping") {
+ throw new Error(`Unexpected namespace: ${namespace}`);
+ }
+ return translate;
+ }),
+ notFound: vi.fn((): never => {
+ throw new Error("NEXT_NOT_FOUND");
+ }),
+ redirect: vi.fn((href: string): never => {
+ throw new Error(`NEXT_REDIRECT:${href}`);
+ }),
+ translate,
+ };
+});
+
+vi.mock("@/env", () => ({ env: routeMocks.env }));
+vi.mock("next/navigation", () => ({
+ notFound: routeMocks.notFound,
+ redirect: routeMocks.redirect,
+}));
+vi.mock("next-intl/server", () => ({
+ getTranslations: routeMocks.getTranslations,
+}));
+vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
+ getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
+}));
+vi.mock("@/lib/db", () => {
+ throw new Error("preview routes must not import the database");
+});
+vi.mock("@/lib/auth", () => {
+ throw new Error("preview routes must not call auth directly");
+});
+vi.mock("@/lib/permissions", () => {
+ throw new Error("preview routes must not reload permissions directly");
+});
+vi.mock("@/actions", () => {
+ throw new Error("preview routes must not import actions");
+});
+vi.mock("@/app/actions", () => {
+ throw new Error("preview routes must not import actions");
+});
+
+import AdminNextDomainLayout from "@/app/admin-next/[domain]/layout";
+import AdminNextDomainPage from "@/app/admin-next/[domain]/page";
+import AdminNextLayout from "@/app/admin-next/layout";
+import AdminNextPage from "@/app/admin-next/page";
+
+const routeFiles = [
+ "src/app/admin-next/layout.tsx",
+ "src/app/admin-next/page.tsx",
+ "src/app/admin-next/[domain]/layout.tsx",
+ "src/app/admin-next/[domain]/page.tsx",
+] as const;
+
+function capabilityContext(
+ granted: readonly string[],
+ actor = { id: 42, username: "refreshed-moderator", rank: 3 },
+): HousekeepingCapabilityContext {
+ const capabilities = new Set(granted);
+
+ return {
+ actor,
+ isSuperAdmin: false,
+ has: (slug) => capabilities.has(slug),
+ hasAny: (...slugs) => slugs.some((slug) => capabilities.has(slug)),
+ hasAll: (...slugs) => slugs.every((slug) => capabilities.has(slug)),
+ };
+}
+
+async function renderRoute(route: ReactNode | Promise) {
+ return renderToStaticMarkup(await route);
+}
+
+describe("/admin-next preview gate", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ routeMocks.env.NODE_ENV = "test";
+ routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
+ });
+
+ it.each([
+ ["production", true],
+ ["production", false],
+ ["development", false],
+ ] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => {
+ routeMocks.env.NODE_ENV = nodeEnv;
+ routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = flag;
+
+ await expect(async () =>
+ renderRoute(
+ AdminNextLayout({
+ children: createElement("p", null, "Preview child"),
+ }),
+ ),
+ ).rejects.toThrow("NEXT_NOT_FOUND");
+ expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
+ });
+
+ it.each(["development", "test"] as const)(
+ "renders children in %s when explicitly enabled",
+ async (nodeEnv) => {
+ routeMocks.env.NODE_ENV = nodeEnv;
+
+ const html = await renderRoute(
+ AdminNextLayout({
+ children: createElement("p", null, "Preview child"),
+ }),
+ );
+
+ expect(html).toContain("Preview child");
+ expect(routeMocks.notFound).not.toHaveBeenCalled();
+ },
+ );
+});
+
+describe("/admin-next first visible domain", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("redirects an administrator to Operations in locked registry order", async () => {
+ routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
+ capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
+ );
+
+ await expect(AdminNextPage()).rejects.toThrow(
+ "NEXT_REDIRECT:/admin-next/operations",
+ );
+ expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/operations");
+ expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
+ 1,
+ );
+ expect(routeMocks.getTranslations).not.toHaveBeenCalled();
+ });
+
+ it("redirects a moderator with only an approved mod view capability to People", async () => {
+ routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
+ capabilityContext([PERMS.MOD_CFH_VIEW]),
+ );
+
+ await expect(AdminNextPage()).rejects.toThrow(
+ "NEXT_REDIRECT:/admin-next/people",
+ );
+ expect(routeMocks.redirect).toHaveBeenCalledWith("/admin-next/people");
+ expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
+ 1,
+ );
+ });
+
+ it("returns 404 when the operator has no visible domain", async () => {
+ routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
+ capabilityContext([]),
+ );
+
+ await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
+ expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
+ expect(routeMocks.redirect).not.toHaveBeenCalled();
+ expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
+ 1,
+ );
+ });
+});
+
+describe("/admin-next/[domain] layout", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("rejects an unknown domain before loading capability context", async () => {
+ await expect(
+ AdminNextDomainLayout({
+ children: createElement("p", null, "Unknown body"),
+ params: Promise.resolve({ domain: "unknown" }),
+ }),
+ ).rejects.toThrow("NEXT_NOT_FOUND");
+ expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
+ expect(routeMocks.getTranslations).not.toHaveBeenCalled();
+ });
+
+ it("rejects a known domain that the operator cannot access", async () => {
+ routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
+ capabilityContext([PERMS.MOD_CFH_VIEW]),
+ );
+
+ await expect(
+ AdminNextDomainLayout({
+ children: createElement("p", null, "Economy body"),
+ params: Promise.resolve({ domain: "economy" }),
+ }),
+ ).rejects.toThrow("NEXT_NOT_FOUND");
+ expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
+ 1,
+ );
+ expect(routeMocks.getTranslations).not.toHaveBeenCalled();
+ });
+
+ it("renders localized People shell from one refreshed capability context", async () => {
+ routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
+ capabilityContext([PERMS.MOD_CFH_VIEW]),
+ );
+
+ const html = await renderRoute(
+ AdminNextDomainLayout({
+ children: createElement("p", null, "People body"),
+ params: Promise.resolve({ domain: "people" }),
+ }),
+ );
+
+ expect(html).toContain("refreshed-moderator");
+ expect(html).toContain("Localized preview");
+ expect(html).toContain("Localized primary navigation");
+ expect(html).toContain("Localized People");
+ expect(html).toContain("People body");
+ expect(html).not.toContain("Localized Economy");
+ expect(routeMocks.translate).not.toHaveBeenCalledWith(
+ "domains.economy.title",
+ );
+ expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
+ 1,
+ );
+ expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("/admin-next/[domain] page", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the real localized manifest and empty state without reloading access", async () => {
+ const html = await renderRoute(
+ AdminNextDomainPage({
+ params: Promise.resolve({ domain: "people" }),
+ }),
+ );
+
+ expect(html).toContain("Localized People");
+ expect(html).toContain("Localized People description");
+ expect(html).toContain("Localized empty title");
+ expect(html).toContain("Localized empty description");
+ expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
+ expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
+ });
+
+ it("rejects an unknown domain before translating", async () => {
+ await expect(
+ AdminNextDomainPage({
+ params: Promise.resolve({ domain: "unknown" }),
+ }),
+ ).rejects.toThrow("NEXT_NOT_FOUND");
+ expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
+ expect(routeMocks.getTranslations).not.toHaveBeenCalled();
+ });
+});
+
+describe("preview route import boundary", () => {
+ it("rejects data, actions, direct auth, old chrome, and legacy route imports", () => {
+ for (const path of routeFiles) {
+ const source = readFileSync(resolve(process.cwd(), path), "utf8");
+
+ expect(source, path).not.toMatch(
+ /@\/lib\/db|@\/(?:app\/)?actions(?:\/|["'])/,
+ );
+ expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/);
+ expect(source, path).not.toMatch(/@\/lib\/(?:auth|permissions)/);
+ expect(source, path).not.toMatch(
+ /(?:@\/app\/(?:admin|mod)|\.\.\/+(?:admin|mod))(?:\/|["'])/,
+ );
+ }
+ });
+});