undefined} />"],
+ ["inline literal color", '
'],
+ ] as const;
+
+ for (const [expectedViolation, source] of componentMutations) {
+ expect(boundaryViolations(source, componentBoundaryRules)).toContain(
+ expectedViolation,
+ );
+ }
+ expect(
+ boundaryViolations(
+ 'const color = "bg-' + 'white";',
+ structuralBoundaryRules,
+ ),
+ ).toContain("palette color");
+ expect(
+ boundaryViolations(
+ 'const color = "text-' + 'black";',
+ structuralBoundaryRules,
+ ),
+ ).toContain("palette color");
+ });
+ it("detects hard-coded Tailwind color utilities beyond foreground and backgrounds", () => {
+ for (const source of [
+ 'className="ring-' + 'red-500"',
+ 'className="outline-' + 'blue-500"',
+ 'className="fill-' + 'green-500"',
+ 'className="stroke-' + 'purple-500"',
+ 'className="divide-' + 'amber-400"',
+ 'className="via-' + 'cyan-500"',
+ ]) {
+ expect(boundaryViolations(source, structuralBoundaryRules)).toContain(
+ "palette color",
+ );
+ }
+ });
+});
diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx
new file mode 100644
index 00000000..921db4ce
--- /dev/null
+++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx
@@ -0,0 +1,80 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+import type { HousekeepingDomainId } from "../../migration/types";
+import type { HousekeepingActor } from "../contracts";
+import type { HousekeepingNavigationDomain } from "../navigation";
+import { CommandTrigger } from "./command-trigger";
+import { ContextNav } from "./context-nav";
+import { DomainRail } from "./domain-rail";
+import { OperatorSummary } from "./operator-summary";
+
+interface HousekeepingShellProps {
+ actor: HousekeepingActor;
+ activeDomainId: HousekeepingDomainId;
+ domains: readonly HousekeepingNavigationDomain[];
+ labels: {
+ skipToContent: string;
+ primaryNavigation: string;
+ contextualNavigation: string;
+ command: string;
+ preview: string;
+ backToSite: string;
+ };
+ children: ReactNode;
+}
+
+export function HousekeepingShell({
+ actor,
+ activeDomainId,
+ domains,
+ labels,
+ children,
+}: HousekeepingShellProps) {
+ const activeDomain = domains.find((domain) => domain.id === activeDomainId);
+
+ return (
+
+ );
+}
diff --git a/src/features/housekeeping/foundation/shell/operator-summary.tsx b/src/features/housekeeping/foundation/shell/operator-summary.tsx
new file mode 100644
index 00000000..2f94c134
--- /dev/null
+++ b/src/features/housekeeping/foundation/shell/operator-summary.tsx
@@ -0,0 +1,19 @@
+import type { HousekeepingActor } from "../contracts";
+
+interface OperatorSummaryProps {
+ actor: HousekeepingActor;
+}
+
+export function OperatorSummary({ actor }: OperatorSummaryProps) {
+ return (
+
+
{actor.username}
+
+ {actor.rank}
+
+
+ );
+}
diff --git a/src/features/housekeeping/manifests.ts b/src/features/housekeeping/manifests.ts
new file mode 100644
index 00000000..b8e32485
--- /dev/null
+++ b/src/features/housekeeping/manifests.ts
@@ -0,0 +1,16 @@
+import { contentManifest } from "./domains/content/manifest";
+import { economyManifest } from "./domains/economy/manifest";
+import { hotelManifest } from "./domains/hotel/manifest";
+import { operationsManifest } from "./domains/operations/manifest";
+import { peopleManifest } from "./domains/people/manifest";
+import { systemManifest } from "./domains/system/manifest";
+import type { HousekeepingDomainManifest } from "./foundation/contracts";
+
+export const HOUSEKEEPING_MANIFESTS = [
+ operationsManifest,
+ peopleManifest,
+ contentManifest,
+ economyManifest,
+ hotelManifest,
+ systemManifest,
+] as const satisfies readonly HousekeepingDomainManifest[];
diff --git a/src/features/housekeeping/migration/content.test.ts b/src/features/housekeeping/migration/content.test.ts
new file mode 100644
index 00000000..69c56fab
--- /dev/null
+++ b/src/features/housekeeping/migration/content.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import { PERMS } from "@/lib/permission-slugs";
+import { contentMigrationEntries } from "./content";
+import { ownedLegacyPages } from "./discover-legacy-pages";
+import { validateMigrationEntries } from "./validate-matrix";
+
+const CONTENT_PREFIXES = [
+ "/admin/articles",
+ "/admin/photos",
+ "/admin/media",
+ "/admin/banners",
+ "/admin/ads",
+ "/admin/events",
+ "/admin/polls",
+ "/admin/help-questions",
+ "/admin/tags",
+ "/admin/prefixes",
+ "/admin/writeable-boxes",
+ "/admin/email-templates",
+ "/admin/theme",
+ "/admin/favicon",
+ "/admin/translations",
+] as const;
+
+describe("contentMigrationEntries", () => {
+ it("covers every content page without legacy-domain targets", () => {
+ const expected = ownedLegacyPages(CONTENT_PREFIXES);
+ expect(validateMigrationEntries(expected, contentMigrationEntries)).toEqual(
+ [],
+ );
+ expect(
+ contentMigrationEntries.every(
+ (row) =>
+ row.targetPath === null ||
+ row.targetPath.startsWith("/admin/content/"),
+ ),
+ ).toBe(true);
+ });
+
+ it("preserves dynamic ids in the consolidated workflow targets", () => {
+ expect(
+ contentMigrationEntries
+ .filter((row) => row.legacyPath.endsWith(":id"))
+ .map(({ legacyPath, targetPath }) => ({ legacyPath, targetPath })),
+ ).toEqual([
+ {
+ legacyPath: "/admin/articles/:id",
+ targetPath: "/admin/content/editorial/articles/:id",
+ },
+ {
+ legacyPath: "/admin/ads/:id",
+ targetPath: "/admin/content/media/ads/:id",
+ },
+ {
+ legacyPath: "/admin/events/:id",
+ targetPath: "/admin/content/engagement/events/:id",
+ },
+ {
+ legacyPath: "/admin/polls/:id",
+ targetPath: "/admin/content/engagement/polls/:id",
+ },
+ {
+ legacyPath: "/admin/help-questions/:id",
+ targetPath: "/admin/content/help/questions/:id",
+ },
+ ]);
+ });
+
+ it("keeps every audited workflow planned without parity claims", () => {
+ expect(
+ contentMigrationEntries.every(
+ (row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
+ ),
+ ).toBe(true);
+ });
+
+ it("requires privileged settings edits for global runtime configuration", () => {
+ for (const legacyPath of [
+ "/admin/theme",
+ "/admin/favicon",
+ "/admin/translations/cms",
+ "/admin/translations/client",
+ "/admin/translations/emulator",
+ ]) {
+ expect(contentMigrationEntries).toContainEqual(
+ expect.objectContaining({
+ legacyPath,
+ capabilities: expect.objectContaining({
+ mutate: [PERMS.SETTINGS_EDIT],
+ }),
+ auditRequirement: "PRIVILEGED_MUTATION",
+ }),
+ );
+ }
+ });
+});
diff --git a/src/features/housekeeping/migration/content.ts b/src/features/housekeeping/migration/content.ts
new file mode 100644
index 00000000..693743b8
--- /dev/null
+++ b/src/features/housekeeping/migration/content.ts
@@ -0,0 +1,572 @@
+import { PERMS } from "@/lib/permission-slugs";
+import type { MigrationEntry } from "./types";
+
+type PlannedContentEntry = Omit<
+ MigrationEntry,
+ "targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
+> & {
+ requiredTests?: MigrationEntry["requiredTests"];
+ notes?: MigrationEntry["notes"];
+};
+
+function plannedContentEntry(entry: PlannedContentEntry): MigrationEntry {
+ return {
+ ...entry,
+ targetDomain: "content",
+ requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
+ parityEvidence: [],
+ status: "PLANNED",
+ notes: entry.notes ?? [],
+ };
+}
+
+export const contentMigrationEntries: readonly MigrationEntry[] = [
+ // Batch 1: editorial publishing and media management.
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/articles",
+ sourceFile: "src/app/admin/articles/page.tsx",
+ targetPath: "/admin/content/editorial/articles",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.NEWS_VIEW],
+ mutate: [PERMS.NEWS_EDIT],
+ },
+ dependencies: {
+ queries: ["User", "WebsiteArticles"],
+ mutations: ["deleteArticle"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/articles/new",
+ sourceFile: "src/app/admin/articles/new/page.tsx",
+ targetPath: "/admin/content/editorial/articles/new",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.NEWS_EDIT],
+ mutate: [PERMS.NEWS_EDIT],
+ },
+ dependencies: {
+ queries: ["GET /api/media"],
+ mutations: ["createArticle", "uploadMediaAndReturn"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/articles/:id",
+ sourceFile: "src/app/admin/articles/[id]/page.tsx",
+ targetPath: "/admin/content/editorial/articles/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.NEWS_VIEW],
+ mutate: [PERMS.NEWS_EDIT],
+ },
+ dependencies: {
+ queries: ["GET /api/media", "WebsiteArticles"],
+ mutations: ["deleteArticle", "updateArticle", "uploadMediaAndReturn"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/photos",
+ sourceFile: "src/app/admin/photos/page.tsx",
+ targetPath: "/admin/content/media/photos",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_VIEW],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: {
+ queries: ["CameraWeb", "User"],
+ mutations: ["deletePhoto"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/media",
+ sourceFile: "src/app/admin/media/page.tsx",
+ targetPath: "/admin/content/media/library",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_VIEW],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: {
+ queries: ["GET /api/media"],
+ mutations: ["deleteMedia", "uploadMedia"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/banners",
+ sourceFile: "src/app/admin/banners/page.tsx",
+ targetPath: "/admin/content/media/banners",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.BANNERS_VIEW],
+ mutate: [PERMS.BANNERS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteBanner"],
+ mutations: ["createBanner", "deleteBanner", "updateBanner"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/ads",
+ sourceFile: "src/app/admin/ads/page.tsx",
+ targetPath: "/admin/content/media/ads",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_VIEW],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteAds"],
+ mutations: ["deleteAd"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/ads/new",
+ sourceFile: "src/app/admin/ads/new/page.tsx",
+ targetPath: "/admin/content/media/ads/new",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_EDIT],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: { queries: [], mutations: ["createAd"] },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/ads/:id",
+ sourceFile: "src/app/admin/ads/[id]/page.tsx",
+ targetPath: "/admin/content/media/ads/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_VIEW],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteAds"],
+ mutations: ["deleteAd", "updateAd"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+
+ // Batch 2: events, polls, help content, and supporting taxonomies.
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/events",
+ sourceFile: "src/app/admin/events/page.tsx",
+ targetPath: "/admin/content/engagement/events",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.EVENTS_VIEW],
+ mutate: [PERMS.EVENTS_EDIT],
+ },
+ dependencies: {
+ queries: ["fetchAdminList", "WebsiteEvent", "WebsiteEventType"],
+ mutations: ["deleteEvent"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/events/create",
+ sourceFile: "src/app/admin/events/create/page.tsx",
+ targetPath: "/admin/content/engagement/events/create",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.EVENTS_EDIT],
+ mutate: [PERMS.EVENTS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteEventType"],
+ mutations: ["createEvent"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/events/types",
+ sourceFile: "src/app/admin/events/types/page.tsx",
+ targetPath: "/admin/content/engagement/events/types",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.EVENTS_EDIT],
+ mutate: [PERMS.EVENTS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteEventType"],
+ mutations: ["createEventType", "deleteEventType", "updateEventType"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/events/:id",
+ sourceFile: "src/app/admin/events/[id]/page.tsx",
+ targetPath: "/admin/content/engagement/events/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.EVENTS_EDIT],
+ mutate: [PERMS.EVENTS_EDIT],
+ },
+ dependencies: {
+ queries: ["User", "WebsiteEvent", "WebsiteEventType"],
+ mutations: [
+ "addEventPrize",
+ "addEventWinner",
+ "deleteEventPrize",
+ "updateEvent",
+ ],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/polls",
+ sourceFile: "src/app/admin/polls/page.tsx",
+ targetPath: "/admin/content/engagement/polls",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.POLLS_VIEW],
+ mutate: [PERMS.POLLS_EDIT],
+ },
+ dependencies: {
+ queries: ["fetchAdminList", "WebsitePoll"],
+ mutations: ["deletePoll"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/polls/create",
+ sourceFile: "src/app/admin/polls/create/page.tsx",
+ targetPath: "/admin/content/engagement/polls/create",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.POLLS_EDIT],
+ mutate: [PERMS.POLLS_EDIT],
+ },
+ dependencies: { queries: [], mutations: ["createPoll"] },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/polls/:id",
+ sourceFile: "src/app/admin/polls/[id]/page.tsx",
+ targetPath: "/admin/content/engagement/polls/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.POLLS_EDIT],
+ mutate: [PERMS.POLLS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsitePoll"],
+ mutations: [
+ "addPollQuestion",
+ "deletePollQuestion",
+ "updatePoll",
+ "updatePollQuestion",
+ ],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/help-questions",
+ sourceFile: "src/app/admin/help-questions/page.tsx",
+ targetPath: "/admin/content/help/questions",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.PAGES_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["WebsiteHelpCenterCategories"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/help-questions/new",
+ sourceFile: "src/app/admin/help-questions/new/page.tsx",
+ targetPath: "/admin/content/help/questions/new",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_EDIT],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: { queries: [], mutations: ["createHelpQuestion"] },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/help-questions/:id",
+ sourceFile: "src/app/admin/help-questions/[id]/page.tsx",
+ targetPath: "/admin/content/help/questions/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_VIEW],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteHelpCenterCategories"],
+ mutations: ["deleteHelpQuestion", "updateHelpQuestion"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/tags",
+ sourceFile: "src/app/admin/tags/page.tsx",
+ targetPath: "/admin/content/editorial/tags",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_VIEW],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: {
+ queries: ["Taggables", "Tags"],
+ mutations: ["createTag", "deleteTag", "updateTag"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/prefixes",
+ sourceFile: "src/app/admin/prefixes/page.tsx",
+ targetPath: "/admin/content/engagement/prefixes",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PREFIXES_VIEW],
+ mutate: [PERMS.PREFIXES_EDIT],
+ },
+ dependencies: {
+ queries: [
+ "GET /api/admin/prefixes",
+ "GET /api/admin/prefixes/blacklist",
+ "GET /api/admin/prefixes/settings",
+ ],
+ mutations: [
+ "addBlacklistWord",
+ "createPrefix",
+ "deletePrefix",
+ "removeBlacklistWord",
+ "updatePrefix",
+ "updatePrefixSettings",
+ ],
+ },
+ auditRequirement: "MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/writeable-boxes",
+ sourceFile: "src/app/admin/writeable-boxes/page.tsx",
+ targetPath: "/admin/content/editorial/writeable-boxes",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_VIEW],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteWriteableBoxes"],
+ mutations: ["createBox", "deleteBox", "toggleBox", "updateBox"],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/email-templates",
+ sourceFile: "src/app/admin/email-templates/page.tsx",
+ targetPath: "/admin/content/help/email-templates",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PAGES_VIEW],
+ mutate: [PERMS.PAGES_EDIT],
+ },
+ dependencies: {
+ queries: ["EmailTemplates"],
+ mutations: [
+ "createEmailTemplate",
+ "deleteEmailTemplate",
+ "updateEmailTemplate",
+ ],
+ },
+ auditRequirement: "MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+
+ // Batch 3: brand presentation and global localization configuration.
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/theme",
+ sourceFile: "src/app/admin/theme/page.tsx",
+ targetPath: "/admin/content/brand/theme",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["listCustomThemes", "siteSettings.get"],
+ mutations: [
+ "applyCustomTheme",
+ "applyPreset",
+ "deleteCustomTheme",
+ "renameCustomTheme",
+ "saveCustomTheme",
+ "saveTheme",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/favicon",
+ sourceFile: "src/app/admin/favicon/page.tsx",
+ targetPath: "/admin/content/brand/favicon",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteSetting"],
+ mutations: ["deleteFavicon", "saveFavicon"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Current favicon actions do not enforce an edit permission or emit an audit record",
+ ],
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/translations",
+ sourceFile: "src/app/admin/translations/page.tsx",
+ targetPath: "/admin/content/localization",
+ decision: "MERGE",
+ capabilities: { read: [PERMS.SETTINGS_VIEW], mutate: [] },
+ dependencies: { queries: [], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "COMPLETE",
+ requiredTests: ["e2e"],
+ notes: ["Redirect-only landing page for the localization workflow"],
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/translations/client",
+ sourceFile: "src/app/admin/translations/client/page.tsx",
+ targetPath: "/admin/content/localization/client",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["CLIENT_TRANSLATION_FILES", "fs.readFile"],
+ mutations: ["saveClientTranslations"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Current file mutation does not emit an audit record"],
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/translations/cms",
+ sourceFile: "src/app/admin/translations/cms/page.tsx",
+ targetPath: "/admin/content/localization/cms",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["fs.readFile"],
+ mutations: ["saveTranslations"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Current file mutation does not emit an audit record"],
+ }),
+ plannedContentEntry({
+ surface: "admin",
+ legacyPath: "/admin/translations/emulator",
+ sourceFile: "src/app/admin/translations/emulator/page.tsx",
+ targetPath: "/admin/content/localization/emulator",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["EmulatorSettings"],
+ mutations: ["saveEmulatorSettings"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+];
diff --git a/src/features/housekeeping/migration/discover-legacy-pages.test.ts b/src/features/housekeeping/migration/discover-legacy-pages.test.ts
new file mode 100644
index 00000000..082eabbd
--- /dev/null
+++ b/src/features/housekeeping/migration/discover-legacy-pages.test.ts
@@ -0,0 +1,118 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { discoverLegacyPages } from "./discover-legacy-pages";
+
+const temporaryRoots: string[] = [];
+
+afterEach(() => {
+ for (const root of temporaryRoots.splice(0)) {
+ rmSync(root, { force: true, recursive: true });
+ }
+});
+
+function createRouteTree(files: readonly string[]): string {
+ const root = mkdtempSync(join(tmpdir(), "housekeeping-routes-"));
+ temporaryRoots.push(root);
+
+ for (const file of files) {
+ const path = join(root, file);
+ mkdirSync(join(path, ".."), { recursive: true });
+ writeFileSync(path, "export default function Page() {}\n");
+ }
+
+ return root;
+}
+
+describe("discoverLegacyPages", () => {
+ it("discovers the exact legacy administration inventory", () => {
+ const pages = discoverLegacyPages();
+
+ expect(pages).toHaveLength(137);
+ expect(pages).toContainEqual({
+ surface: "admin",
+ legacyPath: "/admin/users/:id/edit",
+ sourceFile: "src/app/admin/users/[id]/edit/page.tsx",
+ });
+ expect(pages).toContainEqual({
+ surface: "mod",
+ legacyPath: "/mod/cfh/:id",
+ sourceFile: "src/app/mod/cfh/[id]/page.tsx",
+ });
+ });
+
+ it("discovers root administration and moderation routes", () => {
+ expect(discoverLegacyPages()).toEqual(
+ expect.arrayContaining([
+ {
+ surface: "admin",
+ legacyPath: "/admin",
+ sourceFile: "src/app/admin/page.tsx",
+ },
+ {
+ surface: "mod",
+ legacyPath: "/mod",
+ sourceFile: "src/app/mod/page.tsx",
+ },
+ ]),
+ );
+ });
+
+ it("discovers root routes in a controlled route tree", () => {
+ const root = createRouteTree([
+ "src/app/admin/page.tsx",
+ "src/app/mod/page.tsx",
+ ]);
+
+ expect(discoverLegacyPages(root)).toEqual([
+ {
+ surface: "admin",
+ legacyPath: "/admin",
+ sourceFile: "src/app/admin/page.tsx",
+ },
+ {
+ surface: "mod",
+ legacyPath: "/mod",
+ sourceFile: "src/app/mod/page.tsx",
+ },
+ ]);
+ });
+ it("sorts a controlled route tree by surface, route, then source file", () => {
+ const root = createRouteTree([
+ "src/app/mod/zebra/page.tsx",
+ "src/app/admin/users/[id]/page.tsx",
+ "src/app/admin/(hidden)/alpha/page.tsx",
+ "src/app/admin/users/[...slug]/page.tsx",
+ "src/app/mod/(group)/aardvark/page.tsx",
+ ]);
+
+ expect(discoverLegacyPages(root)).toEqual([
+ {
+ surface: "admin",
+ legacyPath: "/admin/alpha",
+ sourceFile: "src/app/admin/(hidden)/alpha/page.tsx",
+ },
+ {
+ surface: "admin",
+ legacyPath: "/admin/users/:id",
+ sourceFile: "src/app/admin/users/[id]/page.tsx",
+ },
+ {
+ surface: "admin",
+ legacyPath: "/admin/users/:slug*",
+ sourceFile: "src/app/admin/users/[...slug]/page.tsx",
+ },
+ {
+ surface: "mod",
+ legacyPath: "/mod/aardvark",
+ sourceFile: "src/app/mod/(group)/aardvark/page.tsx",
+ },
+ {
+ surface: "mod",
+ legacyPath: "/mod/zebra",
+ sourceFile: "src/app/mod/zebra/page.tsx",
+ },
+ ]);
+ });
+});
diff --git a/src/features/housekeeping/migration/discover-legacy-pages.ts b/src/features/housekeeping/migration/discover-legacy-pages.ts
new file mode 100644
index 00000000..9c780c7f
--- /dev/null
+++ b/src/features/housekeeping/migration/discover-legacy-pages.ts
@@ -0,0 +1,74 @@
+import { readdirSync } from "node:fs";
+import { join, relative } from "node:path";
+import type { LegacyPage, LegacySurface } from "./types";
+
+const pageFileName = "page.tsx";
+
+function toLegacyPath(
+ surface: LegacySurface,
+ segments: readonly string[],
+): string {
+ const routeSegments = segments
+ .filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")))
+ .map((segment) =>
+ segment.replace(/^\[\.\.\.(.+)\]$/, ":$1*").replace(/^\[(.+)\]$/, ":$1"),
+ );
+
+ return `/${[surface, ...routeSegments].join("/")}`;
+}
+
+function discoverSurface(
+ rootDir: string,
+ surface: LegacySurface,
+): LegacyPage[] {
+ const surfaceDirectory = join(rootDir, "src", "app", surface);
+ const pages: LegacyPage[] = [];
+
+ function walk(directory: string): void {
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const entryPath = join(directory, entry.name);
+
+ if (entry.isDirectory()) {
+ walk(entryPath);
+ continue;
+ }
+
+ if (!entry.isFile() || entry.name !== pageFileName) continue;
+
+ const sourceFile = relative(rootDir, entryPath).replaceAll("\\", "/");
+ const routeSegments = relative(surfaceDirectory, directory)
+ .split(/[/\\]/)
+ .filter((segment) => segment && segment !== ".");
+ pages.push({
+ surface,
+ legacyPath: toLegacyPath(surface, routeSegments),
+ sourceFile,
+ });
+ }
+ }
+
+ walk(surfaceDirectory);
+ return pages;
+}
+
+export function discoverLegacyPages(rootDir = process.cwd()): LegacyPage[] {
+ return (["admin", "mod"] as const)
+ .flatMap((surface) => discoverSurface(rootDir, surface))
+ .sort((a, b) =>
+ `${a.surface}:${a.legacyPath}:${a.sourceFile}`.localeCompare(
+ `${b.surface}:${b.legacyPath}:${b.sourceFile}`,
+ ),
+ );
+}
+
+export function ownedLegacyPages(
+ prefixes: readonly string[],
+ pages: readonly LegacyPage[] = discoverLegacyPages(),
+): LegacyPage[] {
+ return pages.filter((page) =>
+ prefixes.some(
+ (prefix) =>
+ page.legacyPath === prefix || page.legacyPath.startsWith(`${prefix}/`),
+ ),
+ );
+}
diff --git a/src/features/housekeeping/migration/economy.test.ts b/src/features/housekeeping/migration/economy.test.ts
new file mode 100644
index 00000000..e93e9e76
--- /dev/null
+++ b/src/features/housekeeping/migration/economy.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from "vitest";
+import { ownedLegacyPages } from "./discover-legacy-pages";
+import { economyMigrationEntries } from "./economy";
+import { validateMigrationEntries } from "./validate-matrix";
+
+const ECONOMY_PREFIXES = [
+ "/admin/catalog",
+ "/admin/items",
+ "/admin/shop",
+ "/admin/marketplace",
+ "/admin/transactions",
+ "/admin/vouchers",
+ "/admin/subscriptions",
+ "/admin/rare-values",
+ "/admin/badges",
+ "/admin/achievements",
+ "/admin/sounds",
+ "/admin/calendar",
+] as const;
+
+describe("economyMigrationEntries", () => {
+ it("covers every economy page without legacy-domain targets", () => {
+ const expected = ownedLegacyPages(ECONOMY_PREFIXES);
+ expect(validateMigrationEntries(expected, economyMigrationEntries)).toEqual(
+ [],
+ );
+ expect(
+ economyMigrationEntries.every(
+ (row) =>
+ row.targetPath === null ||
+ row.targetPath.startsWith("/admin/economy/"),
+ ),
+ ).toBe(true);
+ });
+
+ it("keeps every audited workflow planned without parity claims", () => {
+ expect(
+ economyMigrationEntries.every(
+ (row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
+ ),
+ ).toBe(true);
+ });
+
+ it("requires privileged audit for economy mutations", () => {
+ const mutationRows = economyMigrationEntries.filter(
+ (row) => row.dependencies.mutations.length > 0,
+ );
+ expect(mutationRows.length).toBeGreaterThan(0);
+ expect(
+ mutationRows.every(
+ (row) => row.auditRequirement === "PRIVILEGED_MUTATION",
+ ),
+ ).toBe(true);
+ });
+
+ it("records the catalog search dependency on base items", () => {
+ const catalog = economyMigrationEntries.find(
+ (row) => row.legacyPath === "/admin/catalog",
+ );
+
+ expect(catalog?.dependencies.queries).toContain("ItemsBase");
+ });
+
+ it("marks the item collection localization as partial", () => {
+ const items = economyMigrationEntries.find(
+ (row) => row.legacyPath === "/admin/items",
+ );
+
+ expect(items?.localization).toBe("PARTIAL");
+ });
+});
diff --git a/src/features/housekeeping/migration/economy.ts b/src/features/housekeeping/migration/economy.ts
new file mode 100644
index 00000000..d93005f1
--- /dev/null
+++ b/src/features/housekeeping/migration/economy.ts
@@ -0,0 +1,422 @@
+import { PERMS } from "@/lib/permission-slugs";
+import type { MigrationEntry } from "./types";
+
+type PlannedEconomyEntry = Omit<
+ MigrationEntry,
+ "targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
+> & {
+ requiredTests?: MigrationEntry["requiredTests"];
+ notes?: MigrationEntry["notes"];
+};
+
+function plannedEconomyEntry(entry: PlannedEconomyEntry): MigrationEntry {
+ return {
+ ...entry,
+ targetDomain: "economy",
+ requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
+ parityEvidence: [],
+ status: "PLANNED",
+ notes: entry.notes ?? [],
+ };
+}
+
+export const economyMigrationEntries: readonly MigrationEntry[] = [
+ // Batch 1: catalog pages, Builder Club, maintenance, and base items.
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/catalog",
+ sourceFile: "src/app/admin/catalog/page.tsx",
+ targetPath: "/admin/economy/catalog",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.CATALOG_VIEW],
+ mutate: [PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: [
+ "CatalogItems",
+ "CatalogItemsBc",
+ "CatalogPages",
+ "CatalogPagesBc",
+ "ItemsBase",
+ "GET /api/admin/catalog/items",
+ "GET /api/admin/catalog/tree",
+ "getCatalogItemCounts",
+ ],
+ mutations: [
+ "createBcPage",
+ "createCatalogPage",
+ "DELETE /api/admin/catalog/tree",
+ "deleteBcTreePage",
+ "deleteCatalogPage",
+ "deleteTreePage",
+ "fixEverythingAction",
+ "PATCH /api/admin/catalog/tree",
+ "reorderBcTreePage",
+ "reorderTreePage",
+ "toggleBcPage",
+ "toggleCatalogPage",
+ "updateBcPage",
+ "updateCatalogPage",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Merge the overlapping table, tree, and visual-manager editors into one catalog workflow",
+ ],
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/catalog/:id",
+ sourceFile: "src/app/admin/catalog/[id]/page.tsx",
+ targetPath: "/admin/economy/catalog/:id",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.CATALOG_VIEW],
+ mutate: [PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: [
+ "CatalogPages",
+ "GET /api/admin/catalog/suggest",
+ "GET /api/admin/furni/search",
+ "getAncestors",
+ "loadCatalogItemsData",
+ ],
+ mutations: [
+ "bulkCreateCatalogItems",
+ "createCatalogItem",
+ "deleteCatalogItems",
+ "deleteCatalogPage",
+ "moveCatalogItems",
+ "reorderCatalogItems",
+ "translateCatalogItems",
+ "updateCatalogItem",
+ "updateCatalogPage",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Merge the detail tabs with the collection-level visual editor while preserving the page id",
+ ],
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/catalog/builder-club",
+ sourceFile: "src/app/admin/catalog/builder-club/page.tsx",
+ targetPath: "/admin/economy/catalog",
+ decision: "MERGE",
+ capabilities: { read: [PERMS.CATALOG_VIEW], mutate: [] },
+ dependencies: { queries: [], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "COMPLETE",
+ requiredTests: ["e2e"],
+ notes: ["Redirect-only entry into the Builder Club catalog view"],
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/catalog/builder-club/:id",
+ sourceFile: "src/app/admin/catalog/builder-club/[id]/page.tsx",
+ targetPath: "/admin/economy/catalog/builder-club/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.CATALOG_VIEW],
+ mutate: [PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: ["CatalogItemsBc", "CatalogPagesBc"],
+ mutations: [
+ "createBcItem",
+ "deleteBcItem",
+ "updateBcItem",
+ "updateBcPage",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/catalog/maintenance",
+ sourceFile: "src/app/admin/catalog/maintenance/page.tsx",
+ targetPath: "/admin/economy/catalog/maintenance",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.CATALOG_VIEW],
+ mutate: [PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: ["getFurniHealthAction", "previewAlignIdsAction"],
+ mutations: [
+ "applyAlignIdsAction",
+ "fixCatalogOffersAction",
+ "fixEverythingAction",
+ "fixSpriteIdsAction",
+ "reconcileIdsAction",
+ "removeDuplicateItemsBaseAction",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/items",
+ sourceFile: "src/app/admin/items/page.tsx",
+ targetPath: "/admin/economy/items",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.CATALOG_VIEW], mutate: [] },
+ dependencies: { queries: ["fetchAdminList", "ItemsBase"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/items/:id",
+ sourceFile: "src/app/admin/items/[id]/page.tsx",
+ targetPath: "/admin/economy/items/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.CATALOG_VIEW],
+ mutate: [PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: ["ItemsBase"],
+ mutations: ["updateItemsBase"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+
+ // Batch 2: commerce products, listings, vouchers, and subscriptions.
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/shop",
+ sourceFile: "src/app/admin/shop/page.tsx",
+ targetPath: "/admin/economy/commerce/shop",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["WebsitePaypalTransactions", "WebsiteShopArticles"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/shop/new",
+ sourceFile: "src/app/admin/shop/new/page.tsx",
+ targetPath: "/admin/economy/commerce/shop/new",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SHOP_EDIT],
+ mutate: [PERMS.SHOP_EDIT],
+ },
+ dependencies: { queries: [], mutations: ["createShopArticle"] },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/shop/:id",
+ sourceFile: "src/app/admin/shop/[id]/page.tsx",
+ targetPath: "/admin/economy/commerce/shop/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SHOP_VIEW],
+ mutate: [PERMS.SHOP_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteShopArticles"],
+ mutations: ["deleteShopArticle", "updateShopArticle"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/marketplace",
+ sourceFile: "src/app/admin/marketplace/page.tsx",
+ targetPath: "/admin/economy/commerce/marketplace",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SHOP_VIEW],
+ mutate: [PERMS.SHOP_EDIT],
+ },
+ dependencies: {
+ queries: ["MarketplaceItems", "User"],
+ mutations: ["cancelMarketplaceListing"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/vouchers",
+ sourceFile: "src/app/admin/vouchers/page.tsx",
+ targetPath: "/admin/economy/commerce/vouchers",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SHOP_VIEW],
+ mutate: [PERMS.SHOP_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteShopVouchers"],
+ mutations: ["createVoucher", "deleteVoucher"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/subscriptions",
+ sourceFile: "src/app/admin/subscriptions/page.tsx",
+ targetPath: "/admin/economy/commerce/subscriptions",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
+ dependencies: { queries: ["users_subscriptions"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Current route reads users_subscriptions through raw SQL"],
+ }),
+
+ // Batch 3: transaction history and rare-value configuration.
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/transactions",
+ sourceFile: "src/app/admin/transactions/page.tsx",
+ targetPath: "/admin/economy/history/transactions",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["User", "WebsitePaypalTransactions"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/rare-values",
+ sourceFile: "src/app/admin/rare-values/page.tsx",
+ targetPath: "/admin/economy/value/rare-values",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SHOP_VIEW],
+ mutate: [PERMS.SHOP_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteRareValueCategories", "WebsiteRareValues"],
+ mutations: [
+ "createCategory",
+ "createValue",
+ "deleteCategory",
+ "deleteValue",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+
+ // Batch 4: badges, achievements, soundtracks, and calendar rewards.
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/badges",
+ sourceFile: "src/app/admin/badges/page.tsx",
+ targetPath: "/admin/economy/rewards/badges",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.CATALOG_VIEW],
+ mutate: [PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteBadges"],
+ mutations: ["giveBadge", "uploadBadge"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/achievements",
+ sourceFile: "src/app/admin/achievements/page.tsx",
+ targetPath: "/admin/economy/rewards/achievements",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.CATALOG_VIEW], mutate: [] },
+ dependencies: { queries: ["Achievements"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/sounds",
+ sourceFile: "src/app/admin/sounds/page.tsx",
+ targetPath: "/admin/economy/rewards/sounds",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.CATALOG_VIEW],
+ mutate: [PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: ["CatalogItems", "siteSettings.get", "Soundtracks"],
+ mutations: [
+ "deleteSoundtrack",
+ "POST /api/admin/sounds/upload",
+ "updateSoundtrack",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/calendar",
+ sourceFile: "src/app/admin/calendar/page.tsx",
+ targetPath: "/admin/economy/rewards/calendar",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["CalendarCampaigns", "CalendarRewards"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedEconomyEntry({
+ surface: "admin",
+ legacyPath: "/admin/calendar/:id",
+ sourceFile: "src/app/admin/calendar/[id]/page.tsx",
+ targetPath: "/admin/economy/rewards/calendar/:id",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.SHOP_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["CalendarCampaigns", "CalendarRewards"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+];
diff --git a/src/features/housekeeping/migration/hotel.test.ts b/src/features/housekeeping/migration/hotel.test.ts
new file mode 100644
index 00000000..9fa43e8a
--- /dev/null
+++ b/src/features/housekeeping/migration/hotel.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it } from "vitest";
+import { PERMS } from "@/lib/permission-slugs";
+import { ownedLegacyPages } from "./discover-legacy-pages";
+import { hotelMigrationEntries } from "./hotel";
+import { validateMigrationEntries } from "./validate-matrix";
+
+const HOTEL_PREFIXES = [
+ "/admin/rooms",
+ "/admin/navigation",
+ "/admin/radio",
+ "/admin/studio",
+] as const;
+
+describe("hotelMigrationEntries", () => {
+ it("maps all hotel tools below the hotel target root", () => {
+ expect(
+ validateMigrationEntries(
+ ownedLegacyPages(HOTEL_PREFIXES),
+ hotelMigrationEntries,
+ ),
+ ).toEqual([]);
+ expect(
+ hotelMigrationEntries.every(
+ (row) =>
+ row.targetPath === null || row.targetPath.startsWith("/admin/hotel/"),
+ ),
+ ).toBe(true);
+ });
+
+ it("preserves room ids while merging only the detail aliases", () => {
+ expect(
+ hotelMigrationEntries
+ .filter((row) => row.legacyPath.startsWith("/admin/rooms"))
+ .map(({ legacyPath, targetPath, decision }) => ({
+ legacyPath,
+ targetPath,
+ decision,
+ })),
+ ).toEqual([
+ {
+ legacyPath: "/admin/rooms",
+ targetPath: "/admin/hotel/rooms",
+ decision: "REBUILD",
+ },
+ {
+ legacyPath: "/admin/rooms/:id",
+ targetPath: "/admin/hotel/rooms/:id",
+ decision: "MERGE",
+ },
+ {
+ legacyPath: "/admin/rooms/:id/furni",
+ targetPath: "/admin/hotel/rooms/:id/furni",
+ decision: "REBUILD",
+ },
+ {
+ legacyPath: "/admin/rooms/edit/:id",
+ targetPath: "/admin/hotel/rooms/:id",
+ decision: "MERGE",
+ },
+ {
+ legacyPath: "/admin/rooms/show/:id",
+ targetPath: "/admin/hotel/rooms/:id",
+ decision: "MERGE",
+ },
+ ]);
+ });
+
+ it("keeps every audited workflow planned without parity claims", () => {
+ expect(
+ hotelMigrationEntries.every(
+ (row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
+ ),
+ ).toBe(true);
+ });
+
+ it("requires privileged audit and an explicit capability for every mutation", () => {
+ const mutationRows = hotelMigrationEntries.filter(
+ (row) => row.dependencies.mutations.length > 0,
+ );
+
+ expect(mutationRows.length).toBeGreaterThan(0);
+ expect(
+ mutationRows.every(
+ (row) =>
+ row.capabilities.mutate.length > 0 &&
+ row.auditRequirement === "PRIVILEGED_MUTATION",
+ ),
+ ).toBe(true);
+ });
+
+ it("classifies Studio Audit repair operations as privileged mutations", () => {
+ const studioAudit = hotelMigrationEntries.find(
+ (row) => row.legacyPath === "/admin/studio/audit",
+ );
+
+ expect(studioAudit).toEqual(
+ expect.objectContaining({
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: expect.objectContaining({
+ mutations: expect.arrayContaining([
+ "POST /api/admin/import/audit",
+ "applyCatalogSql",
+ "ensureDirectories",
+ "repairDuplicateClassnames",
+ "repairFurniData",
+ "repairMissingIcons",
+ "repairMissingNitros",
+ "repairOrphanedCatalog",
+ ]),
+ }),
+ auditRequirement: "PRIVILEGED_MUTATION",
+ }),
+ );
+ });
+
+ it("records the actual permissions for room, radio, and Studio mutations", () => {
+ expect(hotelMigrationEntries).toContainEqual(
+ expect.objectContaining({
+ legacyPath: "/admin/rooms/edit/:id",
+ capabilities: {
+ read: [PERMS.ROOMS_EDIT],
+ mutate: [PERMS.ROOMS_EDIT, PERMS.ROOMS_DELETE],
+ },
+ }),
+ );
+ expect(hotelMigrationEntries).toContainEqual(
+ expect.objectContaining({
+ legacyPath: "/admin/radio/api-keys",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ }),
+ );
+ expect(hotelMigrationEntries).toContainEqual(
+ expect.objectContaining({
+ legacyPath: "/admin/studio/sync",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ }),
+ );
+ });
+});
diff --git a/src/features/housekeeping/migration/hotel.ts b/src/features/housekeeping/migration/hotel.ts
new file mode 100644
index 00000000..a44b56d2
--- /dev/null
+++ b/src/features/housekeeping/migration/hotel.ts
@@ -0,0 +1,671 @@
+import { PERMS } from "@/lib/permission-slugs";
+import type { MigrationEntry } from "./types";
+
+type PlannedHotelEntry = Omit<
+ MigrationEntry,
+ "targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
+> & {
+ requiredTests?: MigrationEntry["requiredTests"];
+ notes?: MigrationEntry["notes"];
+};
+
+function plannedHotelEntry(entry: PlannedHotelEntry): MigrationEntry {
+ return {
+ ...entry,
+ targetDomain: "hotel",
+ requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
+ parityEvidence: [],
+ status: "PLANNED",
+ notes: entry.notes ?? [],
+ };
+}
+
+export const hotelMigrationEntries: readonly MigrationEntry[] = [
+ // Batch 1: room directory, consolidated room detail, furniture, and navigator.
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/rooms",
+ sourceFile: "src/app/admin/rooms/page.tsx",
+ targetPath: "/admin/hotel/rooms",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.ROOMS_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["fetchAdminList", "Rooms", "GET /api/admin/export?type=rooms"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/rooms/:id",
+ sourceFile: "src/app/admin/rooms/[id]/page.tsx",
+ targetPath: "/admin/hotel/rooms/:id",
+ decision: "MERGE",
+ capabilities: { read: [PERMS.ROOMS_VIEW], mutate: [] },
+ dependencies: { queries: [], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "COMPLETE",
+ requiredTests: ["e2e"],
+ notes: ["Redirect-only alias for the room detail workflow"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/rooms/:id/furni",
+ sourceFile: "src/app/admin/rooms/[id]/furni/page.tsx",
+ targetPath: "/admin/hotel/rooms/:id/furni",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ROOMS_VIEW],
+ mutate: [PERMS.ROOMS_EDIT],
+ },
+ dependencies: {
+ queries: ["Items", "ItemsBase", "Rooms", "User", "loadRoom"],
+ mutations: [
+ "bulkDeleteRoomItems",
+ "deleteRoomItem",
+ "RCON kickall",
+ "RCON reloadroom",
+ "roomRconAction",
+ "updateRoomItem",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Preserve the furniture suffix and room id in the target route"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/rooms/edit/:id",
+ sourceFile: "src/app/admin/rooms/edit/[id]/page.tsx",
+ targetPath: "/admin/hotel/rooms/:id",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.ROOMS_EDIT],
+ mutate: [PERMS.ROOMS_EDIT, PERMS.ROOMS_DELETE],
+ },
+ dependencies: {
+ queries: ["Rooms", "User", "loadRoom"],
+ mutations: [
+ "deleteRoom",
+ "RCON kickall",
+ "RCON reloadroom",
+ "RCON updateroom",
+ "roomRconAction",
+ "updateRoom",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Merge editing and RCON controls into the room detail workflow"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/rooms/show/:id",
+ sourceFile: "src/app/admin/rooms/show/[id]/page.tsx",
+ targetPath: "/admin/hotel/rooms/:id",
+ decision: "MERGE",
+ capabilities: { read: [PERMS.ROOMS_VIEW], mutate: [] },
+ dependencies: { queries: ["Rooms", "User", "loadRoom"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Merge the canonical read-only detail alias without changing its id",
+ ],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/navigation",
+ sourceFile: "src/app/admin/navigation/page.tsx",
+ targetPath: "/admin/hotel/navigation",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.PAGES_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "NavigatorFlatcats",
+ "NavigatorPubliccats",
+ "navigator_publics raw SQL",
+ "Rooms",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+
+ // Batch 2: radio overview, monitoring, moderation, and runtime configuration.
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio",
+ sourceFile: "src/app/admin/radio/page.tsx",
+ targetPath: "/admin/hotel/radio/overview",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ dependencies: {
+ queries: ["RadioApplications", "RadioSchedules", "RadioShouts"],
+ mutations: ["deleteShout"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/api-keys",
+ sourceFile: "src/app/admin/radio/api-keys/page.tsx",
+ targetPath: "/admin/hotel/radio/api-keys",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ dependencies: {
+ queries: ["RadioApiKeys"],
+ mutations: ["createApiKey", "deleteApiKey", "toggleApiKey"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "API-key generation and lifecycle changes are credential mutations",
+ ],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/autodj",
+ sourceFile: "src/app/admin/radio/autodj/page.tsx",
+ targetPath: "/admin/hotel/radio/autodj",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ dependencies: {
+ queries: ["RadioAutoDjPlaylist"],
+ mutations: ["createTrack", "deleteTrack", "toggleTrack"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/banners",
+ sourceFile: "src/app/admin/radio/banners/page.tsx",
+ targetPath: "/admin/hotel/radio/banners",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ dependencies: {
+ queries: ["RadioBanners"],
+ mutations: [
+ "createRadioBanner",
+ "deleteRadioBanner",
+ "updateRadioBanner",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/embed",
+ sourceFile: "src/app/admin/radio/embed/page.tsx",
+ targetPath: "/admin/hotel/radio/embed",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.RADIO_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["resolveHotelName", "siteSettings.get"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/history",
+ sourceFile: "src/app/admin/radio/history/page.tsx",
+ targetPath: "/admin/hotel/radio/history",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.RADIO_VIEW], mutate: [] },
+ dependencies: { queries: ["RadioHistory", "User"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/moderation",
+ sourceFile: "src/app/admin/radio/moderation/page.tsx",
+ targetPath: "/admin/hotel/radio/moderation",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ dependencies: {
+ queries: ["RadioShouts"],
+ mutations: ["deleteShout"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Shout deletion is a radio moderation mutation"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/monitoring",
+ sourceFile: "src/app/admin/radio/monitoring/page.tsx",
+ targetPath: "/admin/hotel/radio/monitoring",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.RADIO_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "fetch radio_listeners_api_url",
+ "fetch radio_now_playing_api_url",
+ "siteSettings.get",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Monitoring probes operator-configured external HTTP endpoints"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/points",
+ sourceFile: "src/app/admin/radio/points/page.tsx",
+ targetPath: "/admin/hotel/radio/points",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ dependencies: {
+ queries: ["siteSettings.get", "WebsiteSetting"],
+ mutations: ["savePoints", "siteSettings.reload"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/ranks",
+ sourceFile: "src/app/admin/radio/ranks/page.tsx",
+ targetPath: "/admin/hotel/radio/ranks",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ dependencies: {
+ queries: ["RadioRanks"],
+ mutations: ["createRadioRank", "deleteRadioRank", "updateRadioRank"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/radio/settings",
+ sourceFile: "src/app/admin/radio/settings/page.tsx",
+ targetPath: "/admin/hotel/radio/settings",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RADIO_VIEW],
+ mutate: [PERMS.RADIO_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteSetting"],
+ mutations: [
+ "saveRadioSetting",
+ "saveRadioSettings",
+ "siteSettings.reload",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Settings include external-service credentials, runtime flags, webhooks, and custom code",
+ ],
+ }),
+
+ // Batch 3: Studio audit and asset import workflows.
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio",
+ sourceFile: "src/app/admin/studio/page.tsx",
+ targetPath: "/admin/hotel/studio/furni",
+ decision: "MERGE",
+ capabilities: { read: [PERMS.ASSETS_IMPORT], mutate: [] },
+ dependencies: { queries: [], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "COMPLETE",
+ requiredTests: ["e2e"],
+ notes: ["Redirect-only entry into the Studio furniture workflow"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/audit",
+ sourceFile: "src/app/admin/studio/audit/page.tsx",
+ targetPath: "/admin/hotel/studio/audit",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: ["runCatalogAudit"],
+ mutations: [
+ "POST /api/admin/import/audit",
+ "applyCatalogSql",
+ "ensureDirectories",
+ "repairDuplicateClassnames",
+ "repairFurniData",
+ "repairMissingIcons",
+ "repairMissingNitros",
+ "repairOrphanedCatalog",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Audit repairs can mutate asset files, catalog data, FurnitureData, and database structure",
+ ],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/badges",
+ sourceFile: "src/app/admin/studio/badges/page.tsx",
+ targetPath: "/admin/hotel/studio/badges",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT, PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: [
+ "GET /api/admin/import/badges",
+ "getBadgeData",
+ "searchBadges",
+ "WebsiteBadges",
+ ],
+ mutations: [
+ "importBadgeSynced",
+ "POST /api/admin/import/badges",
+ "updateBadge",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Imports external badge assets into the configured gamedata filesystem",
+ ],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/clone",
+ sourceFile: "src/app/admin/studio/clone/page.tsx",
+ targetPath: "/admin/hotel/studio/clone",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: [
+ "GET /api/admin/import/clone",
+ "GET /api/admin/import/clone/icon",
+ "getCloneList",
+ "listSources",
+ ],
+ mutations: [
+ "DELETE /api/admin/import/clone",
+ "POST /api/admin/import/clone",
+ "POST /api/admin/import/clone/batch",
+ "cloneSingleFurni",
+ "rcon.updateCatalog",
+ "rcon.updateItems",
+ "syncAssetsToGamedataBundle",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Clone imports combine external HTTP, database, filesystem, and RCON mutations",
+ ],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/clothing",
+ sourceFile: "src/app/admin/studio/clothing/page.tsx",
+ targetPath: "/admin/hotel/studio/clothing",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: [
+ "GET /api/admin/import/clothing",
+ "GET /api/admin/import/clothing/sets",
+ "getClothingSetList",
+ "getFigureList",
+ ],
+ mutations: [
+ "DELETE /api/admin/import/clothing",
+ "POST /api/admin/import/clothing/batch",
+ "POST /api/admin/import/clothing/sets/batch",
+ "importClothingSet",
+ "importSingleFigure",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Clothing imports download and delete asset files"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/effects",
+ sourceFile: "src/app/admin/studio/effects/page.tsx",
+ targetPath: "/admin/hotel/studio/effects",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: ["GET /api/admin/import/effects", "getEffectList"],
+ mutations: [
+ "DELETE /api/admin/import/effects",
+ "importSingleEffect",
+ "POST /api/admin/import/effects/batch",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Effect imports download and delete asset files"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/furni",
+ sourceFile: "src/app/admin/studio/furni/page.tsx",
+ targetPath: "/admin/hotel/studio/furni",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: [
+ "GET /api/admin/import/clone",
+ "GET /api/admin/import/furni",
+ "getHabboGamedataHotel",
+ "getTreeFlat",
+ "previewAutoCatalog",
+ "siteSettings.getBool",
+ ],
+ mutations: [
+ "deleteImportedFurni",
+ "PATCH /api/admin/import/furni",
+ "POST /api/admin/import/furni",
+ "POST /api/admin/import/furni/batch",
+ "PUT /api/admin/import/furni/nitro-editor",
+ "rcon.updateCatalog",
+ "rcon.updateItems",
+ "setFurnidataTranslateEnabled",
+ "syncAssetsToGamedataBundle",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Furniture imports combine official Habbo HTTP sources, catalog and item tables, asset files, and RCON reloads",
+ ],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/maintenance",
+ sourceFile: "src/app/admin/studio/maintenance/page.tsx",
+ targetPath: "/admin/hotel/studio/maintenance",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.CATALOG_EDIT],
+ mutate: [PERMS.CATALOG_EDIT],
+ },
+ dependencies: {
+ queries: ["getFurniHealthAction", "previewAlignIdsAction"],
+ mutations: [
+ "applyAlignIdsAction",
+ "fixCatalogOffersAction",
+ "fixEverythingAction",
+ "fixSpriteIdsAction",
+ "reconcileIdsAction",
+ "removeDuplicateItemsBaseAction",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["The current page relies on action-level catalog-edit enforcement"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/pets",
+ sourceFile: "src/app/admin/studio/pets/page.tsx",
+ targetPath: "/admin/hotel/studio/pets",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: [
+ "GET /api/admin/import/pets",
+ "GET /api/admin/import/pets/icon",
+ "getPetList",
+ ],
+ mutations: [
+ "DELETE /api/admin/import/pets",
+ "importSinglePet",
+ "POST /api/admin/import/pets/batch",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Pet imports download and delete asset files"],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/repair-icons",
+ sourceFile: "src/app/admin/studio/repair-icons/page.tsx",
+ targetPath: "/admin/hotel/studio/repair-icons",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: [],
+ mutations: [
+ "POST /api/admin/import/furni/repair-icons",
+ "repairMissingIcons",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Repairs icon files from local bundles or configured external sources",
+ ],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/sync",
+ sourceFile: "src/app/admin/studio/sync/page.tsx",
+ targetPath: "/admin/hotel/studio/sync",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: ["ItemsBase", "listSources"],
+ mutations: [
+ "appendFurniEntriesBatch",
+ "cloneSingleFurni",
+ "POST /api/admin/import/clone/sync-all",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Full source sync mutates item data and asset files from external clone sources",
+ ],
+ }),
+ plannedHotelEntry({
+ surface: "admin",
+ legacyPath: "/admin/studio/upload",
+ sourceFile: "src/app/admin/studio/upload/page.tsx",
+ targetPath: "/admin/hotel/studio/upload",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.ASSETS_IMPORT],
+ mutate: [PERMS.ASSETS_IMPORT],
+ },
+ dependencies: {
+ queries: [],
+ mutations: ["POST /api/admin/import/furni/upload", "uploadSingleFurni"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Uploads mutate database, catalog, FurnitureData, and asset files"],
+ }),
+];
diff --git a/src/features/housekeeping/migration/matrix.test.ts b/src/features/housekeeping/migration/matrix.test.ts
new file mode 100644
index 00000000..c4a9cf50
--- /dev/null
+++ b/src/features/housekeeping/migration/matrix.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { discoverLegacyPages } from "./discover-legacy-pages";
+import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
+import { validateMigrationEntries } from "./validate-matrix";
+
+describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
+ it("covers all 137 legacy pages exactly once", () => {
+ const discovered = discoverLegacyPages();
+
+ expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(137);
+ expect(
+ validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
+ ).toEqual([]);
+ });
+
+ it("contains no undecided evidence markers", () => {
+ expect(JSON.stringify(HOUSEKEEPING_MIGRATION_MATRIX)).not.toMatch(
+ /TBD|TODO|FIXME|UNCLASSIFIED/,
+ );
+ });
+
+ it("is sorted by legacy path", () => {
+ expect(HOUSEKEEPING_MIGRATION_MATRIX.map((row) => row.legacyPath)).toEqual(
+ [...HOUSEKEEPING_MIGRATION_MATRIX.map((row) => row.legacyPath)].sort(
+ (a, b) => a.localeCompare(b),
+ ),
+ );
+ });
+});
diff --git a/src/features/housekeeping/migration/matrix.ts b/src/features/housekeeping/migration/matrix.ts
new file mode 100644
index 00000000..05e7e3e3
--- /dev/null
+++ b/src/features/housekeeping/migration/matrix.ts
@@ -0,0 +1,15 @@
+import { contentMigrationEntries } from "./content";
+import { economyMigrationEntries } from "./economy";
+import { hotelMigrationEntries } from "./hotel";
+import { operationsMigrationEntries } from "./operations";
+import { peopleMigrationEntries } from "./people";
+import { systemMigrationEntries } from "./system";
+
+export const HOUSEKEEPING_MIGRATION_MATRIX = [
+ ...operationsMigrationEntries,
+ ...peopleMigrationEntries,
+ ...contentMigrationEntries,
+ ...economyMigrationEntries,
+ ...hotelMigrationEntries,
+ ...systemMigrationEntries,
+].sort((a, b) => a.legacyPath.localeCompare(b.legacyPath));
diff --git a/src/features/housekeeping/migration/operations.test.ts b/src/features/housekeeping/migration/operations.test.ts
new file mode 100644
index 00000000..a50c8cd3
--- /dev/null
+++ b/src/features/housekeeping/migration/operations.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { PERMS } from "@/lib/permission-slugs";
+import { operationsMigrationEntries } from "./operations";
+
+describe("operationsMigrationEntries", () => {
+ it("covers the legacy dashboard once", () => {
+ expect(operationsMigrationEntries).toHaveLength(1);
+ expect(operationsMigrationEntries[0]).toMatchObject({
+ surface: "admin",
+ legacyPath: "/admin",
+ sourceFile: "src/app/admin/page.tsx",
+ targetDomain: "operations",
+ targetPath: "/admin/work",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] },
+ dependencies: {
+ queries: [
+ "users",
+ "active bans",
+ "website articles",
+ "staff activities",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ requiredTests: ["integration", "e2e", "visual"],
+ parityEvidence: [],
+ status: "PLANNED",
+ notes: [
+ "Replace metric dashboard with capability-derived operational home",
+ ],
+ });
+ });
+});
diff --git a/src/features/housekeeping/migration/operations.ts b/src/features/housekeeping/migration/operations.ts
new file mode 100644
index 00000000..9188e7af
--- /dev/null
+++ b/src/features/housekeeping/migration/operations.ts
@@ -0,0 +1,27 @@
+import { PERMS } from "@/lib/permission-slugs";
+import type { MigrationEntry } from "./types";
+
+export const operationsMigrationEntries: readonly MigrationEntry[] = [
+ {
+ surface: "admin",
+ legacyPath: "/admin",
+ sourceFile: "src/app/admin/page.tsx",
+ targetDomain: "operations",
+ targetPath: "/admin/work",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.ADMIN_DASHBOARD], mutate: [] },
+ dependencies: {
+ queries: ["users", "active bans", "website articles", "staff activities"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ requiredTests: ["integration", "e2e", "visual"],
+ parityEvidence: [],
+ status: "PLANNED",
+ notes: [
+ "Replace metric dashboard with capability-derived operational home",
+ ],
+ },
+];
diff --git a/src/features/housekeeping/migration/people.test.ts b/src/features/housekeeping/migration/people.test.ts
new file mode 100644
index 00000000..a6950113
--- /dev/null
+++ b/src/features/housekeeping/migration/people.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vitest";
+import { discoverLegacyPages } from "./discover-legacy-pages";
+import { operationsMigrationEntries } from "./operations";
+import { peopleMigrationEntries } from "./people";
+import { validateMigrationEntries } from "./validate-matrix";
+
+const PEOPLE_PREFIXES = [
+ "/admin/users",
+ "/admin/online",
+ "/admin/guilds",
+ "/admin/applications",
+ "/admin/teams",
+ "/admin/bans",
+ "/admin/ip",
+ "/admin/vpn",
+ "/admin/wordfilter",
+ "/admin/moderation",
+ "/admin/tickets",
+ "/admin/help-tickets",
+ "/mod",
+] as const;
+
+describe("peopleMigrationEntries", () => {
+ it("merges every mod page into a people workflow", () => {
+ const modRows = peopleMigrationEntries.filter(
+ (row) => row.surface === "mod",
+ );
+
+ expect(modRows).toHaveLength(13);
+ expect(modRows.every((row) => row.decision === "MERGE")).toBe(true);
+ expect(
+ modRows.every((row) => row.targetPath?.startsWith("/admin/people/")),
+ ).toBe(true);
+ });
+
+ it("merges duplicate user detail and edit aliases", () => {
+ for (const legacyPath of ["/admin/users/:id", "/admin/users/:id/edit"]) {
+ expect(peopleMigrationEntries).toContainEqual(
+ expect.objectContaining({ legacyPath, decision: "MERGE" }),
+ );
+ }
+ });
+
+ it("keeps planned rows free of unverified parity claims", () => {
+ expect(
+ peopleMigrationEntries.every(
+ (row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
+ ),
+ ).toBe(true);
+ });
+
+ it("covers every Operations and People legacy page exactly once", () => {
+ const expected = discoverLegacyPages().filter(
+ (page) =>
+ page.legacyPath === "/admin" ||
+ PEOPLE_PREFIXES.some(
+ (prefix) =>
+ page.legacyPath === prefix ||
+ page.legacyPath.startsWith(`${prefix}/`),
+ ),
+ );
+
+ expect(
+ validateMigrationEntries(expected, [
+ ...operationsMigrationEntries,
+ ...peopleMigrationEntries,
+ ]),
+ ).toEqual([]);
+ });
+});
diff --git a/src/features/housekeeping/migration/people.ts b/src/features/housekeeping/migration/people.ts
new file mode 100644
index 00000000..54e326cf
--- /dev/null
+++ b/src/features/housekeeping/migration/people.ts
@@ -0,0 +1,835 @@
+import { PERMS } from "@/lib/permission-slugs";
+import type { MigrationEntry } from "./types";
+
+type PlannedPeopleEntry = Omit<
+ MigrationEntry,
+ "targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
+> & {
+ requiredTests?: MigrationEntry["requiredTests"];
+ notes?: MigrationEntry["notes"];
+};
+
+function plannedPeopleEntry(entry: PlannedPeopleEntry): MigrationEntry {
+ return {
+ ...entry,
+ targetDomain: "people",
+ requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
+ parityEvidence: [],
+ status: "PLANNED",
+ notes: entry.notes ?? [],
+ };
+}
+
+export const peopleMigrationEntries: readonly MigrationEntry[] = [
+ // Batch 1: users, aliases, online users, guilds, and applications.
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/users",
+ sourceFile: "src/app/admin/users/page.tsx",
+ targetPath: "/admin/people/users",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.USERS_VIEW],
+ mutate: [PERMS.USERS_EDIT, PERMS.USERS_BAN],
+ },
+ dependencies: {
+ queries: ["fetchAdminList", "User"],
+ mutations: [
+ "createUser",
+ "bulkAdjustCurrency",
+ "bulkBan",
+ "bulkGiveBadge",
+ "bulkUnban",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/users/edit/:id",
+ sourceFile: "src/app/admin/users/edit/[id]/page.tsx",
+ targetPath: "/admin/people/users/:id/edit",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.USERS_EDIT],
+ mutate: [PERMS.USERS_EDIT, PERMS.USERS_BAN, PERMS.USERS_RESET_PASSWORD],
+ },
+ dependencies: {
+ queries: ["loadUserById", "getWatchedUserIds", "permissions"],
+ mutations: [
+ "updateUser",
+ "giveBadge",
+ "removeBadge",
+ "banUser",
+ "unbanUser",
+ "alertUser",
+ "disconnectUser",
+ "muteUser",
+ "unmuteUser",
+ "resetPassword",
+ "sendCredits",
+ "setTradeLock",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/users/multi-accounts",
+ sourceFile: "src/app/admin/users/multi-accounts/page.tsx",
+ targetPath: "/admin/people/users/multi-accounts",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.USERS_VIEW], mutate: [] },
+ dependencies: { queries: ["detectMultiAccounts"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/users/show/:id",
+ sourceFile: "src/app/admin/users/show/[id]/page.tsx",
+ targetPath: "/admin/people/users/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.USERS_VIEW],
+ mutate: [PERMS.USERS_EDIT, PERMS.USERS_BAN, PERMS.USERS_RESET_PASSWORD],
+ },
+ dependencies: {
+ queries: ["loadUserById", "loadUserSanctions", "getWatchedUserIds"],
+ mutations: [
+ "alertUser",
+ "disconnectUser",
+ "muteUser",
+ "unmuteUser",
+ "resetPassword",
+ "sendCredits",
+ "setTradeLock",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/users/:id",
+ sourceFile: "src/app/admin/users/[id]/page.tsx",
+ targetPath: "/admin/people/users/:id",
+ decision: "MERGE",
+ capabilities: { read: [PERMS.USERS_VIEW], mutate: [] },
+ dependencies: { queries: [], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "COMPLETE",
+ requiredTests: ["e2e"],
+ notes: ["Redirect-only duplicate of the canonical user detail route"],
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/users/:id/edit",
+ sourceFile: "src/app/admin/users/[id]/edit/page.tsx",
+ targetPath: "/admin/people/users/:id/edit",
+ decision: "MERGE",
+ capabilities: { read: [PERMS.USERS_EDIT], mutate: [] },
+ dependencies: { queries: [], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "COMPLETE",
+ requiredTests: ["e2e"],
+ notes: ["Redirect-only duplicate of the canonical user edit route"],
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/online",
+ sourceFile: "src/app/admin/online/page.tsx",
+ targetPath: "/admin/people/community/online",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.USERS_VIEW], mutate: [] },
+ dependencies: { queries: ["fetchAdminList", "User"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/guilds",
+ sourceFile: "src/app/admin/guilds/page.tsx",
+ targetPath: "/admin/people/community/guilds",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.USERS_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["Guilds", "GuildsMembers", "User"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/guilds/:id",
+ sourceFile: "src/app/admin/guilds/[id]/page.tsx",
+ targetPath: "/admin/people/community/guilds/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.USERS_VIEW],
+ mutate: [PERMS.USERS_EDIT],
+ },
+ dependencies: {
+ queries: ["Guilds", "GuildsForumsThreads", "GuildsMembers", "User"],
+ mutations: ["disbandGuild"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/applications",
+ sourceFile: "src/app/admin/applications/page.tsx",
+ targetPath: "/admin/people/staff/applications",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.USERS_VIEW],
+ mutate: [PERMS.USERS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteStaffApplications", "User"],
+ mutations: ["dismissApplication"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+
+ // Batch 2: staff, moderation, CFH, bans, IP, VPN, and word filter.
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/teams",
+ sourceFile: "src/app/admin/teams/page.tsx",
+ targetPath: "/admin/people/staff/teams",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.USERS_VIEW],
+ mutate: [PERMS.USERS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteTeams"],
+ mutations: ["createTeam", "deleteTeam"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/moderation",
+ sourceFile: "src/app/admin/moderation/page.tsx",
+ targetPath: "/admin/people/moderation",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.MODERATION_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "getMinStaffRank",
+ "AdminAuditLog",
+ "Ban",
+ "SupportTickets",
+ "User",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/moderation/actions",
+ sourceFile: "src/app/admin/moderation/actions/page.tsx",
+ targetPath: "/admin/people/moderation/actions",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.MODERATION_EDIT],
+ mutate: [PERMS.MODERATION_EDIT],
+ },
+ dependencies: {
+ queries: [],
+ mutations: [
+ "broadcastAlert",
+ "quickAlert",
+ "quickKick",
+ "quickMute",
+ "quickRoomKick",
+ "quickUnmute",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/moderation/cfh",
+ sourceFile: "src/app/admin/moderation/cfh/page.tsx",
+ targetPath: "/admin/people/moderation/cfh",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.MODERATION_VIEW], mutate: [] },
+ dependencies: { queries: ["SupportTickets", "User"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/moderation/cfh/:id",
+ sourceFile: "src/app/admin/moderation/cfh/[id]/page.tsx",
+ targetPath: "/admin/people/moderation/cfh/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.MODERATION_VIEW],
+ mutate: [PERMS.MODERATION_EDIT],
+ },
+ dependencies: {
+ queries: ["Ban", "SupportTickets", "User"],
+ mutations: [
+ "assignCfhTicket",
+ "closeCfhTicket",
+ "quickAlert",
+ "quickKick",
+ "quickMute",
+ "updateCfhState",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/moderation/team",
+ sourceFile: "src/app/admin/moderation/team/page.tsx",
+ targetPath: "/admin/people/staff/moderation-team",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.MODERATION_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "getMinStaffRank",
+ "AdminAuditLog",
+ "SupportTickets",
+ "User",
+ "WebsiteTicket",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/bans",
+ sourceFile: "src/app/admin/bans/page.tsx",
+ targetPath: "/admin/people/moderation/bans",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.BANS_VIEW],
+ mutate: [PERMS.USERS_BAN],
+ },
+ dependencies: {
+ queries: ["Ban"],
+ mutations: ["createBan", "liftBan"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/ip",
+ sourceFile: "src/app/admin/ip/page.tsx",
+ targetPath: "/admin/people/moderation/ip",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteIpBlacklist", "WebsiteIpWhitelist"],
+ mutations: [
+ "addBlacklist",
+ "addWhitelist",
+ "deleteBlacklist",
+ "deleteWhitelist",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/vpn",
+ sourceFile: "src/app/admin/vpn/page.tsx",
+ targetPath: "/admin/people/moderation/vpn",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["siteSettings.getMany"],
+ mutations: ["saveVpn"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/wordfilter",
+ sourceFile: "src/app/admin/wordfilter/page.tsx",
+ targetPath: "/admin/people/moderation/word-filter",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.WORDFILTER_VIEW],
+ mutate: [PERMS.WORDFILTER_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteWordfilter"],
+ mutations: ["addWord", "deleteWord"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+
+ // Batch 3: website tickets and help-center tickets.
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/tickets",
+ sourceFile: "src/app/admin/tickets/page.tsx",
+ targetPath: "/admin/people/support/tickets",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.TICKETS_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["fetchUnifiedTicketInbox", "fetchTicketQueueOpenCounts"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/tickets/desk",
+ sourceFile: "src/app/admin/tickets/desk/page.tsx",
+ targetPath: "/admin/people/support/tickets/desk",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.TICKETS_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["fetchTicketQueueOpenCounts", "User", "WebsiteTicket"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/tickets/templates",
+ sourceFile: "src/app/admin/tickets/templates/page.tsx",
+ targetPath: "/admin/people/support/tickets/templates",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.TICKETS_EDIT],
+ mutate: [PERMS.TICKETS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteTicketTemplate"],
+ mutations: ["createTemplate", "deleteTemplate", "updateTemplate"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/tickets/:id",
+ sourceFile: "src/app/admin/tickets/[id]/page.tsx",
+ targetPath: "/admin/people/support/tickets/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.TICKETS_VIEW],
+ mutate: [PERMS.TICKETS_EDIT],
+ },
+ dependencies: {
+ queries: [
+ "getMinStaffRank",
+ "fetchTicketQueueOpenCounts",
+ "User",
+ "WebsiteTicket",
+ ],
+ mutations: [
+ "adminReplyTicket",
+ "assignTicket",
+ "updateTicketPriority",
+ "updateTicketStatus",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/help-tickets",
+ sourceFile: "src/app/admin/help-tickets/page.tsx",
+ targetPath: "/admin/people/support/help-tickets",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.TICKETS_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "fetchTicketQueueOpenCounts",
+ "User",
+ "WebsiteHelpCenterTicketReplies",
+ "WebsiteHelpCenterTickets",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "admin",
+ legacyPath: "/admin/help-tickets/:id",
+ sourceFile: "src/app/admin/help-tickets/[id]/page.tsx",
+ targetPath: "/admin/people/support/help-tickets/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.TICKETS_VIEW],
+ mutate: [PERMS.TICKETS_EDIT, PERMS.USERS_BAN],
+ },
+ dependencies: {
+ queries: [
+ "getMinStaffRank",
+ "fetchTicketQueueOpenCounts",
+ "Ban",
+ "User",
+ "WebsiteHelpCenterCategories",
+ "WebsiteHelpCenterTicketReplies",
+ "WebsiteHelpCenterTickets",
+ ],
+ mutations: [
+ "closeHelpCenterTicket",
+ "liftBanFromHelpTicket",
+ "reopenHelpCenterTicket",
+ "replyHelpCenterTicket",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+
+ // Batch 4: merge all moderator routes into the People workflows.
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod",
+ sourceFile: "src/app/mod/page.tsx",
+ targetPath: "/admin/people/moderation",
+ decision: "MERGE",
+ capabilities: {
+ read: [
+ PERMS.MOD_CFH_VIEW,
+ PERMS.MODERATION_VIEW,
+ PERMS.MOD_ACTIONS,
+ PERMS.MODERATION_EDIT,
+ PERMS.MOD_BANS_VIEW,
+ PERMS.BANS_VIEW,
+ PERMS.MOD_TICKETS_VIEW,
+ PERMS.TICKETS_VIEW,
+ PERMS.MOD_TEAM_VIEW,
+ PERMS.MOD_USERS_VIEW,
+ PERMS.USERS_VIEW,
+ ],
+ mutate: [],
+ },
+ dependencies: {
+ queries: [
+ "getMinStaffRank",
+ "Ban",
+ "SupportTickets",
+ "User",
+ "WebsiteHelpCenterTickets",
+ "WebsiteTicket",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ notes: ["Merge the moderator dashboard into capability-gated People work"],
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/actions",
+ sourceFile: "src/app/mod/actions/page.tsx",
+ targetPath: "/admin/people/moderation/actions",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_ACTIONS, PERMS.MODERATION_EDIT],
+ mutate: [PERMS.MOD_ACTIONS, PERMS.MODERATION_EDIT],
+ },
+ dependencies: {
+ queries: [],
+ mutations: [
+ "broadcastAlert",
+ "quickAlert",
+ "quickKick",
+ "quickMute",
+ "quickRoomKick",
+ "quickUnmute",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/bans",
+ sourceFile: "src/app/mod/bans/page.tsx",
+ targetPath: "/admin/people/moderation/bans",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_BANS_VIEW, PERMS.BANS_VIEW],
+ mutate: [],
+ },
+ dependencies: { queries: ["Ban"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/cfh",
+ sourceFile: "src/app/mod/cfh/page.tsx",
+ targetPath: "/admin/people/moderation/cfh",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW],
+ mutate: [],
+ },
+ dependencies: { queries: ["SupportTickets", "User"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/cfh/:id",
+ sourceFile: "src/app/mod/cfh/[id]/page.tsx",
+ targetPath: "/admin/people/moderation/cfh/:id",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW],
+ mutate: [PERMS.MOD_CFH_EDIT, PERMS.MODERATION_EDIT],
+ },
+ dependencies: {
+ queries: ["Ban", "SupportTickets", "User"],
+ mutations: [
+ "assignCfhTicket",
+ "closeCfhTicket",
+ "quickAlert",
+ "quickKick",
+ "quickMute",
+ "updateCfhState",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/help-tickets",
+ sourceFile: "src/app/mod/help-tickets/page.tsx",
+ targetPath: "/admin/people/support/help-tickets",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
+ mutate: [],
+ },
+ dependencies: {
+ queries: [
+ "fetchTicketQueueOpenCounts",
+ "User",
+ "WebsiteHelpCenterTicketReplies",
+ "WebsiteHelpCenterTickets",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/help-tickets/:id",
+ sourceFile: "src/app/mod/help-tickets/[id]/page.tsx",
+ targetPath: "/admin/people/support/help-tickets/:id",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
+ mutate: [PERMS.MOD_TICKETS_EDIT, PERMS.TICKETS_EDIT, PERMS.USERS_BAN],
+ },
+ dependencies: {
+ queries: [
+ "getMinStaffRank",
+ "fetchTicketQueueOpenCounts",
+ "User",
+ "WebsiteHelpCenterTicketReplies",
+ "WebsiteHelpCenterTickets",
+ ],
+ mutations: [
+ "closeHelpCenterTicket",
+ "liftBanFromHelpTicket",
+ "reopenHelpCenterTicket",
+ "replyHelpCenterTicket",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/team",
+ sourceFile: "src/app/mod/team/page.tsx",
+ targetPath: "/admin/people/staff/moderation-team",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW],
+ mutate: [],
+ },
+ dependencies: {
+ queries: [
+ "getMinStaffRank",
+ "AdminAuditLog",
+ "SupportTickets",
+ "User",
+ "WebsiteTicket",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/tickets",
+ sourceFile: "src/app/mod/tickets/page.tsx",
+ targetPath: "/admin/people/support/tickets",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
+ mutate: [],
+ },
+ dependencies: {
+ queries: ["fetchUnifiedTicketInbox", "fetchTicketQueueOpenCounts"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/tickets/desk",
+ sourceFile: "src/app/mod/tickets/desk/page.tsx",
+ targetPath: "/admin/people/support/tickets/desk",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
+ mutate: [],
+ },
+ dependencies: {
+ queries: [
+ "fetchTicketQueueOpenCounts",
+ "User",
+ "WebsiteTicket",
+ "WebsiteTicketMessage",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/tickets/:id",
+ sourceFile: "src/app/mod/tickets/[id]/page.tsx",
+ targetPath: "/admin/people/support/tickets/:id",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW],
+ mutate: [PERMS.MOD_TICKETS_EDIT, PERMS.TICKETS_EDIT],
+ },
+ dependencies: {
+ queries: [
+ "getMinStaffRank",
+ "fetchTicketQueueOpenCounts",
+ "User",
+ "WebsiteTicket",
+ "WebsiteTicketMessage",
+ ],
+ mutations: [
+ "adminReplyTicket",
+ "assignTicket",
+ "updateTicketPriority",
+ "updateTicketStatus",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/users",
+ sourceFile: "src/app/mod/users/page.tsx",
+ targetPath: "/admin/people/users",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_USERS_VIEW, PERMS.USERS_VIEW],
+ mutate: [],
+ },
+ dependencies: { queries: ["User"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+ plannedPeopleEntry({
+ surface: "mod",
+ legacyPath: "/mod/users/:id",
+ sourceFile: "src/app/mod/users/[id]/page.tsx",
+ targetPath: "/admin/people/users/:id",
+ decision: "MERGE",
+ capabilities: {
+ read: [PERMS.MOD_USERS_VIEW, PERMS.USERS_VIEW],
+ mutate: [],
+ },
+ dependencies: { queries: ["User"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "PARTIAL",
+ }),
+];
diff --git a/src/features/housekeeping/migration/system.test.ts b/src/features/housekeeping/migration/system.test.ts
new file mode 100644
index 00000000..da8b3244
--- /dev/null
+++ b/src/features/housekeeping/migration/system.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vitest";
+import { PERMS } from "@/lib/permission-slugs";
+import { ownedLegacyPages } from "./discover-legacy-pages";
+import { systemMigrationEntries } from "./system";
+import { validateMigrationEntries } from "./validate-matrix";
+
+const SYSTEM_PREFIXES = [
+ "/admin/alerts",
+ "/admin/analytics",
+ "/admin/commandocentrum",
+ "/admin/devops",
+ "/admin/emulator",
+ "/admin/housekeeping",
+ "/admin/logs",
+ "/admin/maintenance",
+ "/admin/menu",
+ "/admin/permissions",
+ "/admin/settings",
+] as const;
+
+describe("systemMigrationEntries", () => {
+ it("covers all 19 System pages exactly once", () => {
+ expect(systemMigrationEntries).toHaveLength(19);
+ expect(
+ validateMigrationEntries(
+ ownedLegacyPages(SYSTEM_PREFIXES),
+ systemMigrationEntries,
+ ),
+ ).toEqual([]);
+ });
+
+ it("removes legacy foundation-owned pages", () => {
+ expect(systemMigrationEntries).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ legacyPath: "/admin/housekeeping",
+ decision: "REMOVE",
+ targetPath: null,
+ }),
+ expect.objectContaining({
+ legacyPath: "/admin/menu",
+ decision: "REMOVE",
+ targetPath: null,
+ }),
+ ]),
+ );
+ });
+
+ it("keeps live permissions as a System workflow", () => {
+ expect(systemMigrationEntries).toContainEqual(
+ expect.objectContaining({
+ legacyPath: "/admin/permissions",
+ targetPath: "/admin/system/access/permissions",
+ decision: expect.not.stringMatching("REMOVE"),
+ }),
+ );
+ });
+
+ it("retains the read-only legacy housekeeping value in removal notes", () => {
+ const housekeeping = systemMigrationEntries.find(
+ (row) => row.legacyPath === "/admin/housekeeping",
+ );
+
+ expect(housekeeping?.dependencies.queries).toEqual(
+ expect.arrayContaining([
+ "exportPermissions",
+ "getAuditLogs",
+ "legacy ACL comparison",
+ ]),
+ );
+ expect(housekeeping?.notes.join(" ")).toMatch(
+ /read-only.*export.*comparison/i,
+ );
+ });
+
+ it("uses only the approved System target roots", () => {
+ const allowedRoots = [
+ "/admin/system/access",
+ "/admin/system/configuration",
+ "/admin/system/observability",
+ "/admin/system/operations",
+ ];
+
+ expect(
+ systemMigrationEntries.every(
+ (row) =>
+ row.targetPath === null ||
+ allowedRoots.some(
+ (root) =>
+ row.targetPath === root || row.targetPath?.startsWith(`${root}/`),
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ it("keeps non-removal workflows planned without parity claims", () => {
+ expect(
+ systemMigrationEntries
+ .filter((row) => row.decision !== "REMOVE")
+ .every(
+ (row) => row.status === "PLANNED" && row.parityEvidence.length === 0,
+ ),
+ ).toBe(true);
+ });
+
+ it("records authoritative capabilities for privileged System mutations", () => {
+ expect(systemMigrationEntries).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ legacyPath: "/admin/commandocentrum",
+ capabilities: {
+ read: [PERMS.RCON_EXECUTE],
+ mutate: [PERMS.RCON_EXECUTE],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ }),
+ expect.objectContaining({
+ legacyPath: "/admin/permissions/:id",
+ capabilities: {
+ read: [PERMS.PERMISSIONS_MANAGE],
+ mutate: [PERMS.PERMISSIONS_MANAGE],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ }),
+ ]),
+ );
+ });
+});
diff --git a/src/features/housekeeping/migration/system.ts b/src/features/housekeeping/migration/system.ts
new file mode 100644
index 00000000..d990c1d6
--- /dev/null
+++ b/src/features/housekeeping/migration/system.ts
@@ -0,0 +1,442 @@
+import { PERMS } from "@/lib/permission-slugs";
+import type { MigrationEntry } from "./types";
+
+type PlannedSystemEntry = Omit<
+ MigrationEntry,
+ "targetDomain" | "requiredTests" | "parityEvidence" | "status" | "notes"
+> & {
+ requiredTests?: MigrationEntry["requiredTests"];
+ notes?: MigrationEntry["notes"];
+};
+
+function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry {
+ return {
+ ...entry,
+ targetDomain: "system",
+ requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"],
+ parityEvidence: [],
+ status: "PLANNED",
+ notes: entry.notes ?? [],
+ };
+}
+
+export const systemMigrationEntries: readonly MigrationEntry[] = [
+ // Operator alerts and read-only analytics.
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/alerts",
+ sourceFile: "src/app/admin/alerts/page.tsx",
+ targetPath: "/admin/system/operations/alerts",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.NOTIFICATIONS_VIEW],
+ mutate: [PERMS.NOTIFICATIONS_EDIT, PERMS.NOTIFICATIONS_VIEW],
+ },
+ dependencies: {
+ queries: ["AlertLogs"],
+ mutations: [
+ "markAllAlertsRead",
+ "rcon.send hotelalert",
+ "sendHotelAlert",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Marking alerts read and broadcasting a hotel alert use different current permission slugs",
+ ],
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/analytics",
+ sourceFile: "src/app/admin/analytics/page.tsx",
+ targetPath: "/admin/system/observability/analytics",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.ANALYTICS_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "Ban",
+ "Rooms",
+ "RoomTradeLog",
+ "User",
+ "chatlogs_room raw SQL",
+ "redisCache",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/analytics/activity",
+ sourceFile: "src/app/admin/analytics/activity/page.tsx",
+ targetPath: "/admin/system/observability/analytics/activity",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.ANALYTICS_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "Ban",
+ "User",
+ "bans raw SQL",
+ "chatlogs_room raw SQL",
+ "commandlogs raw SQL",
+ "redisCache",
+ "users raw SQL",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/analytics/economy",
+ sourceFile: "src/app/admin/analytics/economy/page.tsx",
+ targetPath: "/admin/system/observability/analytics/economy",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.ANALYTICS_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "LogsShopPurchases",
+ "MarketplaceItems",
+ "RoomTradeLog",
+ "User",
+ "logs_shop_purchases raw SQL",
+ "redisCache",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+
+ // Runtime operations, health, and emulator configuration.
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/commandocentrum",
+ sourceFile: "src/app/admin/commandocentrum/page.tsx",
+ targetPath: "/admin/system/operations/command-center",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.RCON_EXECUTE],
+ mutate: [PERMS.RCON_EXECUTE],
+ },
+ dependencies: {
+ queries: [
+ "EmulatorErrors",
+ "StaffActivities",
+ "User",
+ "fetchOpsHealth",
+ "fetchOpsOnlineUsers",
+ "siteSettings.getBool",
+ ],
+ mutations: [
+ "alertUser",
+ "disconnectUser",
+ "executeCommand",
+ "forwardUser",
+ "giveBadge",
+ "giveCredits",
+ "giveDiamonds",
+ "giveDuckets",
+ "hotelAlert",
+ "sendGift",
+ "setMotto",
+ "setRank",
+ "updateCatalog",
+ "updateNavigator",
+ "updateWordFilter",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "The current set-rank action combines RCON with a database update and rank-relative authorization",
+ ],
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/devops",
+ sourceFile: "src/app/admin/devops/page.tsx",
+ targetPath: "/admin/system/observability/devops",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "EmulatorErrors",
+ "EmulatorSettings",
+ "GET /api/admin/devops/health",
+ "fetchOpsHealth",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Health checks probe the database, Redis, RCON, and online users"],
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/devops/errors",
+ sourceFile: "src/app/admin/devops/errors/page.tsx",
+ targetPath: "/admin/system/observability/devops/errors",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [] },
+ dependencies: { queries: ["EmulatorErrors"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/emulator",
+ sourceFile: "src/app/admin/emulator/page.tsx",
+ targetPath: "/admin/system/configuration/emulator",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["EmulatorSettings", "EmulatorTexts"],
+ mutations: ["updateEmulatorSetting", "updateEmulatorText"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["The current actions upsert emulator-owned settings and texts"],
+ }),
+
+ // Foundation-owned legacy pages are retired, not migrated as workflows.
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/housekeeping",
+ sourceFile: "src/app/admin/housekeeping/page.tsx",
+ targetPath: null,
+ decision: "REMOVE",
+ capabilities: { read: [PERMS.SETTINGS_VIEW], mutate: [] },
+ dependencies: {
+ queries: [
+ "AclModelPermission",
+ "AclPermission",
+ "AclRole",
+ "WebsiteHousekeepingPermissions",
+ "exportPermissions",
+ "getAuditLogs",
+ "legacy ACL comparison",
+ ],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ requiredTests: ["integration", "e2e"],
+ notes: [
+ "Remove the legacy foundation surface while preserving its read-only export and ACL comparison value in the live access workflow",
+ "Legacy housekeeping writes already redirect to live Permissions",
+ ],
+ }),
+
+ // Staff, audit, chat, command, and trade observability.
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/logs",
+ sourceFile: "src/app/admin/logs/page.tsx",
+ targetPath: "/admin/system/observability/logs/staff",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
+ dependencies: { queries: ["StaffActivities", "User"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/logs/audit",
+ sourceFile: "src/app/admin/logs/audit/page.tsx",
+ targetPath: "/admin/system/observability/logs/audit",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
+ dependencies: { queries: ["getAuditLogs"], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/logs/chat",
+ sourceFile: "src/app/admin/logs/chat/page.tsx",
+ targetPath: "/admin/system/observability/logs/chat",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["chatlogs_room raw SQL", "loadChatLogList", "users raw SQL"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/logs/commands",
+ sourceFile: "src/app/admin/logs/commands/page.tsx",
+ targetPath: "/admin/system/observability/logs/commands",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["commandlogs raw SQL", "loadCommandLogList", "users raw SQL"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/logs/trades",
+ sourceFile: "src/app/admin/logs/trades/page.tsx",
+ targetPath: "/admin/system/observability/logs/trades",
+ decision: "REBUILD",
+ capabilities: { read: [PERMS.LOGS_VIEW], mutate: [] },
+ dependencies: {
+ queries: ["loadTradeLogList", "room_trade_log raw SQL", "users raw SQL"],
+ mutations: [],
+ },
+ auditRequirement: "NONE",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ }),
+
+ // Global configuration and access control.
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/maintenance",
+ sourceFile: "src/app/admin/maintenance/page.tsx",
+ targetPath: "/admin/system/operations/maintenance",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteSetting"],
+ mutations: ["saveMaintenance", "siteSettings.reload"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Maintenance changes global login availability and splash content"],
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/menu",
+ sourceFile: "src/app/admin/menu/page.tsx",
+ targetPath: null,
+ decision: "REMOVE",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["ADMIN_NAV_GROUPS", "siteSettings.get"],
+ mutations: ["saveAdminNavConfig", "siteSettings.update"],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ requiredTests: ["integration", "e2e"],
+ notes: [
+ "Remove the manual legacy menu editor because foundation manifests own navigation",
+ ],
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/permissions",
+ sourceFile: "src/app/admin/permissions/page.tsx",
+ targetPath: "/admin/system/access/permissions",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PERMISSIONS_MANAGE],
+ mutate: [PERMS.PERMISSIONS_MANAGE],
+ },
+ dependencies: {
+ queries: ["fetchEmulatorRankSummaries", "users grouped by rank"],
+ mutations: [
+ "createRank",
+ "deleteRank",
+ "repairAdminNavAclGrants",
+ "rcon.send updatepermissions",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: ["Keep this live ACL and emulator-rank workflow in System access"],
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/permissions/:id",
+ sourceFile: "src/app/admin/permissions/[id]/page.tsx",
+ targetPath: "/admin/system/access/permissions/:id",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.PERMISSIONS_MANAGE],
+ mutate: [PERMS.PERMISSIONS_MANAGE],
+ },
+ dependencies: {
+ queries: [
+ "AclModelPermission",
+ "AclPermission",
+ "AclRole",
+ "User",
+ "fetchEmulatorRankForEdit",
+ ],
+ mutations: [
+ "AclRole load-time synchronization",
+ "rcon.send updatepermissions",
+ "saveRank",
+ "setCmsPermissions",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "The current route can create or retitle a CMS role while loading rank detail",
+ ],
+ }),
+ plannedSystemEntry({
+ surface: "admin",
+ legacyPath: "/admin/settings",
+ sourceFile: "src/app/admin/settings/page.tsx",
+ targetPath: "/admin/system/configuration/settings",
+ decision: "REBUILD",
+ capabilities: {
+ read: [PERMS.SETTINGS_VIEW],
+ mutate: [PERMS.SETTINGS_EDIT],
+ },
+ dependencies: {
+ queries: ["WebsiteSetting"],
+ mutations: [
+ "createSetting",
+ "deleteSetting",
+ "saveManagedSettings",
+ "siteSettings.reload",
+ "updateSetting",
+ ],
+ },
+ auditRequirement: "PRIVILEGED_MUTATION",
+ localization: "PARTIAL",
+ accessibility: "PARTIAL",
+ notes: [
+ "Global setting changes can also invalidate official gamedata and badge caches",
+ ],
+ }),
+];
diff --git a/src/features/housekeeping/migration/types.ts b/src/features/housekeeping/migration/types.ts
new file mode 100644
index 00000000..41544a0b
--- /dev/null
+++ b/src/features/housekeeping/migration/types.ts
@@ -0,0 +1,40 @@
+export const HOUSEKEEPING_DOMAIN_IDS = [
+ "operations",
+ "people",
+ "content",
+ "economy",
+ "hotel",
+ "system",
+] as const;
+
+export type HousekeepingDomainId = (typeof HOUSEKEEPING_DOMAIN_IDS)[number];
+export type LegacySurface = "admin" | "mod";
+export type MigrationDecision = "REHOST" | "REBUILD" | "MERGE" | "REMOVE";
+export type AuditRequirement = "NONE" | "MUTATION" | "PRIVILEGED_MUTATION";
+export type AuditState = "UNAUDITED" | "PARTIAL" | "COMPLETE";
+export type MigrationStatus =
+ | "PLANNED"
+ | "IN_PROGRESS"
+ | "VERIFIED"
+ | "REMOVED";
+
+export interface LegacyPage {
+ surface: LegacySurface;
+ legacyPath: string;
+ sourceFile: string;
+}
+
+export interface MigrationEntry extends LegacyPage {
+ targetDomain: HousekeepingDomainId;
+ targetPath: string | null;
+ decision: MigrationDecision;
+ capabilities: { read: readonly string[]; mutate: readonly string[] };
+ dependencies: { queries: readonly string[]; mutations: readonly string[] };
+ auditRequirement: AuditRequirement;
+ localization: AuditState;
+ accessibility: AuditState;
+ requiredTests: readonly ("unit" | "integration" | "e2e" | "visual")[];
+ parityEvidence: readonly string[];
+ status: MigrationStatus;
+ notes: readonly string[];
+}
diff --git a/src/features/housekeeping/migration/validate-matrix.test.ts b/src/features/housekeeping/migration/validate-matrix.test.ts
new file mode 100644
index 00000000..87e4a1d2
--- /dev/null
+++ b/src/features/housekeeping/migration/validate-matrix.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest";
+import type { LegacyPage, MigrationEntry } from "./types";
+import { validateMigrationEntries } from "./validate-matrix";
+
+const page = (legacyPath: string): LegacyPage => ({
+ surface: legacyPath.startsWith("/mod") ? "mod" : "admin",
+ legacyPath,
+ sourceFile: `src/app${legacyPath}/page.tsx`,
+});
+
+const entry = (legacyPath: string): MigrationEntry => ({
+ ...page(legacyPath),
+ targetDomain: "system",
+ targetPath: "/admin/system/example",
+ decision: "REHOST",
+ capabilities: { read: ["admin.dashboard"], mutate: [] },
+ dependencies: { queries: [], mutations: [] },
+ auditRequirement: "NONE",
+ localization: "COMPLETE",
+ accessibility: "COMPLETE",
+ requiredTests: ["unit"],
+ parityEvidence: [],
+ status: "PLANNED",
+ notes: [],
+});
+
+describe("validateMigrationEntries", () => {
+ it("reports missing, duplicate, and unknown legacy rows", () => {
+ const discovered = [page("/admin"), page("/admin/users")];
+
+ expect(
+ validateMigrationEntries(discovered, [
+ entry("/admin"),
+ entry("/admin"),
+ entry("/admin/ghost"),
+ ]),
+ ).toEqual([
+ "duplicate legacyPath: /admin",
+ "missing legacyPath: /admin/users",
+ "unknown legacyPath: /admin/ghost",
+ ]);
+ });
+
+ it("rejects incomplete decisions", () => {
+ const issues = validateMigrationEntries(
+ [page("/admin")],
+ [{ ...entry("/admin"), targetPath: null, decision: "REBUILD" }],
+ );
+
+ expect(issues).toContain("REBUILD requires targetPath: /admin");
+ });
+
+ it("rejects a discovery surface mismatch for the same legacy path", () => {
+ expect(
+ validateMigrationEntries(
+ [page("/admin")],
+ [{ ...entry("/admin"), surface: "mod" }],
+ ),
+ ).toEqual([
+ "surface mismatch for legacyPath /admin: expected admin, received mod",
+ ]);
+ });
+
+ it("rejects a discovery source file mismatch for the same legacy path", () => {
+ expect(
+ validateMigrationEntries(
+ [page("/admin")],
+ [
+ {
+ ...entry("/admin"),
+ sourceFile: "src/app/admin/renamed/page.tsx",
+ },
+ ],
+ ),
+ ).toEqual([
+ "sourceFile mismatch for legacyPath /admin: expected src/app/admin/page.tsx, received src/app/admin/renamed/page.tsx",
+ ]);
+ });
+
+ it("rejects removal with a non-null target", () => {
+ expect(
+ validateMigrationEntries(
+ [page("/admin")],
+ [
+ {
+ ...entry("/admin"),
+ decision: "REMOVE",
+ targetPath: "/admin/system/legacy",
+ },
+ ],
+ ),
+ ).toEqual(["REMOVE requires null targetPath: /admin"]);
+ });
+
+ it("enforces migration safety evidence", () => {
+ const issues = validateMigrationEntries(
+ [page("/admin")],
+ [
+ {
+ ...entry("/admin"),
+ targetPath: "/mod/unsupported",
+ decision: "MERGE",
+ requiredTests: [],
+ dependencies: { queries: [], mutations: ["users.disable"] },
+ status: "VERIFIED",
+ notes: ["TODO confirm ownership"],
+ },
+ ],
+ );
+
+ expect(issues).toEqual([
+ "MERGE requires requiredTests: /admin",
+ "mutation dependencies require auditRequirement: /admin",
+ "mutation dependencies require mutate capabilities: /admin",
+ "non-REMOVE targetPath must start with /admin/: /admin",
+ "prohibited placeholder: /admin",
+ "VERIFIED requires parityEvidence: /admin",
+ ]);
+ });
+
+ it("allows removal without a target and rejects non-removal null targets", () => {
+ expect(
+ validateMigrationEntries(
+ [page("/admin")],
+ [{ ...entry("/admin"), decision: "REMOVE", targetPath: null }],
+ ),
+ ).toEqual([]);
+
+ expect(
+ validateMigrationEntries(
+ [page("/admin")],
+ [{ ...entry("/admin"), targetPath: null }],
+ ),
+ ).toEqual(["REHOST requires targetPath: /admin"]);
+ });
+});
diff --git a/src/features/housekeeping/migration/validate-matrix.ts b/src/features/housekeeping/migration/validate-matrix.ts
new file mode 100644
index 00000000..0e6f9231
--- /dev/null
+++ b/src/features/housekeeping/migration/validate-matrix.ts
@@ -0,0 +1,125 @@
+import type { LegacyPage, MigrationEntry } from "./types";
+
+const prohibitedPlaceholder = /\b(?:TBD|TODO|FIXME|UNCLASSIFIED)\b/i;
+
+function hasProhibitedPlaceholder(value: unknown): boolean {
+ if (typeof value === "string") return prohibitedPlaceholder.test(value);
+ if (Array.isArray(value)) return value.some(hasProhibitedPlaceholder);
+ if (value && typeof value === "object")
+ return Object.values(value).some(hasProhibitedPlaceholder);
+
+ return false;
+}
+
+export function validateMigrationEntries(
+ discovered: readonly LegacyPage[],
+ entries: readonly MigrationEntry[],
+): string[] {
+ const issues = new Set
();
+ const discoveredByPath = new Map();
+ for (const page of discovered) {
+ const matchingPages = discoveredByPath.get(page.legacyPath) ?? [];
+ matchingPages.push(page);
+ discoveredByPath.set(page.legacyPath, matchingPages);
+ }
+
+ const entriesByPath = new Map();
+
+ for (const entry of entries) {
+ const matchingEntries = entriesByPath.get(entry.legacyPath) ?? [];
+ matchingEntries.push(entry);
+ entriesByPath.set(entry.legacyPath, matchingEntries);
+
+ const matchingPages = discoveredByPath.get(entry.legacyPath);
+ if (!matchingPages) {
+ issues.add(`unknown legacyPath: ${entry.legacyPath}`);
+ continue;
+ }
+
+ const surfaceMatch = matchingPages.find(
+ (page) => page.surface === entry.surface,
+ );
+ const identityMatch = matchingPages.some(
+ (page) =>
+ page.surface === entry.surface && page.sourceFile === entry.sourceFile,
+ );
+ if (!surfaceMatch) {
+ const expectedSurface = matchingPages
+ .map((page) => page.surface)
+ .join(" or ");
+ issues.add(
+ `surface mismatch for legacyPath ${entry.legacyPath}: expected ${expectedSurface}, received ${entry.surface}`,
+ );
+ } else if (!identityMatch) {
+ issues.add(
+ `sourceFile mismatch for legacyPath ${entry.legacyPath}: expected ${surfaceMatch.sourceFile}, received ${entry.sourceFile}`,
+ );
+ }
+ }
+
+ for (const page of discovered) {
+ const matchingEntries = entriesByPath.get(page.legacyPath) ?? [];
+
+ if (matchingEntries.length === 0) {
+ issues.add(`missing legacyPath: ${page.legacyPath}`);
+ continue;
+ }
+
+ if (matchingEntries.length > 1) {
+ issues.add(`duplicate legacyPath: ${page.legacyPath}`);
+ }
+ }
+
+ for (const entry of entries) {
+ const { legacyPath } = entry;
+
+ if (entry.decision === "REMOVE" && entry.targetPath !== null) {
+ issues.add(`REMOVE requires null targetPath: ${legacyPath}`);
+ }
+
+ if (entry.targetPath === null && entry.decision !== "REMOVE") {
+ issues.add(`${entry.decision} requires targetPath: ${legacyPath}`);
+ }
+
+ if (
+ entry.targetPath !== null &&
+ entry.decision !== "REMOVE" &&
+ !entry.targetPath.startsWith("/admin/")
+ ) {
+ issues.add(
+ `non-REMOVE targetPath must start with /admin/: ${legacyPath}`,
+ );
+ }
+
+ if (
+ (entry.decision === "MERGE" || entry.decision === "REBUILD") &&
+ entry.requiredTests.length === 0
+ ) {
+ issues.add(`${entry.decision} requires requiredTests: ${legacyPath}`);
+ }
+
+ if (entry.dependencies.mutations.length > 0) {
+ if (entry.capabilities.mutate.length === 0) {
+ issues.add(
+ `mutation dependencies require mutate capabilities: ${legacyPath}`,
+ );
+ }
+
+ if (entry.auditRequirement === "NONE") {
+ issues.add(
+ `mutation dependencies require auditRequirement: ${legacyPath}`,
+ );
+ }
+ }
+
+ if (entry.status === "VERIFIED" && entry.parityEvidence.length === 0) {
+ issues.add(`VERIFIED requires parityEvidence: ${legacyPath}`);
+ }
+
+ if (hasProhibitedPlaceholder(entry)) {
+ issues.add(`prohibited placeholder: ${legacyPath}`);
+ }
+ }
+
+ return [...issues].sort((a, b) => a.localeCompare(b));
+}
diff --git a/src/lib/admin-theme-source-audit.test.ts b/src/lib/admin-theme-source-audit.test.ts
index fc683238..ee641851 100644
--- a/src/lib/admin-theme-source-audit.test.ts
+++ b/src/lib/admin-theme-source-audit.test.ts
@@ -2,7 +2,12 @@ import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
-const ROOTS = ["src/app/admin", "src/components/admin"];
+const ROOTS = [
+ "src/app/admin",
+ "src/components/admin",
+ "src/app/admin-next",
+ "src/features/housekeeping",
+];
const GRAPHICAL_ALLOWLIST = [
"src/app/admin/favicon/favicon-generator.tsx",
"src/app/admin/import/clone/import-clone-client.tsx",
@@ -34,7 +39,9 @@ function sourceFiles(directory: string): string[] {
const path = join(directory, entry.name);
return entry.isDirectory()
? sourceFiles(path)
- : /\.(?:ts|tsx)$/.test(entry.name)
+ : /\.(?:ts|tsx)$/.test(entry.name) &&
+ !entry.name.endsWith(".test.ts") &&
+ !entry.name.endsWith(".test.tsx")
? [path]
: [];
});
diff --git a/src/messages/en.json b/src/messages/en.json
index e590ccf9..01bd05db 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -3256,6 +3256,63 @@
"failed": "Failed"
}
}
+ },
+ "housekeeping": {
+ "preview": {
+ "badge": "Foundation preview",
+ "commandDisabled": "Search and commands are enabled in a later subproject.",
+ "backToSite": "Back to site"
+ },
+ "navigation": {
+ "skipToContent": "Skip to content",
+ "primary": "Housekeeping domains",
+ "contextual": "Domain navigation"
+ },
+ "domains": {
+ "operations": {
+ "title": "Operations",
+ "description": "Operational overview and daily queues"
+ },
+ "people": {
+ "title": "People",
+ "description": "Users, moderation, and support"
+ },
+ "content": {
+ "title": "Content",
+ "description": "Editorial content and community engagement"
+ },
+ "economy": {
+ "title": "Economy",
+ "description": "Catalog and shop operations"
+ },
+ "hotel": {
+ "title": "Hotel",
+ "description": "Rooms, radio, and asset tools"
+ },
+ "system": {
+ "title": "System",
+ "description": "Configuration, observability, and access"
+ }
+ },
+ "states": {
+ "loading": {
+ "title": "Loading housekeeping",
+ "description": "Preparing the available housekeeping tools."
+ },
+ "empty": {
+ "title": "Nothing available",
+ "description": "No housekeeping content is available for this domain."
+ },
+ "partial": {
+ "label": "Partial data",
+ "title": "Some information is unavailable",
+ "description": "Review the available information and try again later."
+ },
+ "error": {
+ "title": "Unable to load housekeeping",
+ "description": "Try again later or contact an administrator."
+ }
+ }
}
}
}
diff --git a/src/messages/it.json b/src/messages/it.json
index ec5e352d..4b02a6d7 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -3261,6 +3261,63 @@
"failed": "Failed"
}
}
+ },
+ "housekeeping": {
+ "preview": {
+ "badge": "Anteprima della struttura",
+ "commandDisabled": "Ricerca e comandi saranno abilitati in un sottoprogetto successivo.",
+ "backToSite": "Torna al sito"
+ },
+ "navigation": {
+ "skipToContent": "Vai al contenuto",
+ "primary": "Aree housekeeping",
+ "contextual": "Navigazione del dominio"
+ },
+ "domains": {
+ "operations": {
+ "title": "Operazioni",
+ "description": "Panoramica operativa e code giornaliere"
+ },
+ "people": {
+ "title": "Persone",
+ "description": "Utenti, moderazione e supporto"
+ },
+ "content": {
+ "title": "Contenuti",
+ "description": "Contenuti editoriali e coinvolgimento della community"
+ },
+ "economy": {
+ "title": "Economia",
+ "description": "Operazioni di catalogo e shop"
+ },
+ "hotel": {
+ "title": "Hotel",
+ "description": "Stanze, radio e strumenti per gli asset"
+ },
+ "system": {
+ "title": "Sistema",
+ "description": "Configurazione, osservabilità e accesso"
+ }
+ },
+ "states": {
+ "loading": {
+ "title": "Caricamento in corso",
+ "description": "Stiamo preparando gli strumenti di housekeeping disponibili."
+ },
+ "empty": {
+ "title": "Nessun contenuto disponibile",
+ "description": "Non ci sono contenuti housekeeping disponibili per questo dominio."
+ },
+ "partial": {
+ "label": "Dati parziali",
+ "title": "Alcune informazioni non sono disponibili",
+ "description": "Controlla le informazioni disponibili e riprova più tardi."
+ },
+ "error": {
+ "title": "Impossibile caricare housekeeping",
+ "description": "Riprova più tardi o contatta un amministratore."
+ }
+ }
}
}
}