diff --git a/src/features/housekeeping/domains/content/manifest.ts b/src/features/housekeeping/domains/content/manifest.ts
index 729dd6469a..f367587cf3 100644
--- a/src/features/housekeeping/domains/content/manifest.ts
+++ b/src/features/housekeeping/domains/content/manifest.ts
@@ -17,6 +17,17 @@ export const contentManifest = {
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PREFIXES_VIEW,
+ PERMS.NEWS_EDIT,
+ PERMS.PAGES_EDIT,
+ PERMS.BANNERS_EDIT,
+ PERMS.EVENTS_EDIT,
+ PERMS.POLLS_EDIT,
+ PERMS.PREFIXES_EDIT,
+ PERMS.SETTINGS_VIEW,
+ PERMS.SETTINGS_EDIT,
),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
} satisfies HousekeepingDomainManifest;
diff --git a/src/features/housekeeping/domains/economy/manifest.ts b/src/features/housekeeping/domains/economy/manifest.ts
index 728a3f1c9d..8eddf3b576 100644
--- a/src/features/housekeeping/domains/economy/manifest.ts
+++ b/src/features/housekeeping/domains/economy/manifest.ts
@@ -10,6 +10,14 @@ export const economyManifest = {
descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem",
previewHref: "/admin-next/economy",
- capability: anyCapability(PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW),
+ capability: anyCapability(
+ PERMS.CATALOG_VIEW,
+ PERMS.SHOP_VIEW,
+ PERMS.CATALOG_EDIT,
+ PERMS.SHOP_EDIT,
+ ),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
} satisfies HousekeepingDomainManifest;
diff --git a/src/features/housekeeping/domains/hotel/manifest.ts b/src/features/housekeeping/domains/hotel/manifest.ts
index 3e55782d78..8c0ef95b34 100644
--- a/src/features/housekeeping/domains/hotel/manifest.ts
+++ b/src/features/housekeeping/domains/hotel/manifest.ts
@@ -14,6 +14,14 @@ export const hotelManifest = {
PERMS.ROOMS_VIEW,
PERMS.RADIO_VIEW,
PERMS.ASSETS_IMPORT,
+ PERMS.ROOMS_EDIT,
+ PERMS.ROOMS_DELETE,
+ PERMS.RADIO_EDIT,
+ PERMS.PAGES_VIEW,
+ PERMS.CATALOG_EDIT,
),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
} satisfies HousekeepingDomainManifest;
diff --git a/src/features/housekeeping/domains/operations/manifest.ts b/src/features/housekeeping/domains/operations/manifest.ts
index d5b170a6d8..c06d268739 100644
--- a/src/features/housekeeping/domains/operations/manifest.ts
+++ b/src/features/housekeeping/domains/operations/manifest.ts
@@ -12,4 +12,7 @@ export const operationsManifest = {
previewHref: "/admin-next/operations",
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
} satisfies HousekeepingDomainManifest;
diff --git a/src/features/housekeeping/domains/people/manifest.ts b/src/features/housekeeping/domains/people/manifest.ts
index 5b914e58d3..21d1f3ea8b 100644
--- a/src/features/housekeeping/domains/people/manifest.ts
+++ b/src/features/housekeeping/domains/people/manifest.ts
@@ -21,6 +21,21 @@ export const peopleManifest = {
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.MOD_BANS_VIEW,
+ PERMS.USERS_EDIT,
+ PERMS.USERS_BAN,
+ PERMS.USERS_RESET_PASSWORD,
+ PERMS.MODERATION_EDIT,
+ PERMS.TICKETS_EDIT,
+ PERMS.SETTINGS_VIEW,
+ PERMS.SETTINGS_EDIT,
+ PERMS.WORDFILTER_VIEW,
+ PERMS.WORDFILTER_EDIT,
+ PERMS.MOD_ACTIONS,
+ PERMS.MOD_CFH_EDIT,
+ PERMS.MOD_TICKETS_EDIT,
),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
} satisfies HousekeepingDomainManifest;
diff --git a/src/features/housekeeping/domains/system/manifest.ts b/src/features/housekeeping/domains/system/manifest.ts
index 91d4402eb2..f4ba234584 100644
--- a/src/features/housekeeping/domains/system/manifest.ts
+++ b/src/features/housekeeping/domains/system/manifest.ts
@@ -18,6 +18,11 @@ export const systemManifest = {
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
+ PERMS.SETTINGS_EDIT,
+ PERMS.NOTIFICATIONS_EDIT,
),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
} satisfies HousekeepingDomainManifest;
diff --git a/src/features/housekeeping/foundation/contracts/contracts.test.ts b/src/features/housekeeping/foundation/contracts/contracts.test.ts
index bd53822627..caf21dd0c0 100644
--- a/src/features/housekeeping/foundation/contracts/contracts.test.ts
+++ b/src/features/housekeeping/foundation/contracts/contracts.test.ts
@@ -40,6 +40,9 @@ const searchResult = {
} satisfies HousekeepingSearchResult;
const searchProvider: HousekeepingSearchProvider = {
+ id: "people.users",
+ owner: "people",
+ capability,
search: async () => ok([searchResult], "search-1"),
};
@@ -49,10 +52,17 @@ const inboxSourceResult = {
} satisfies HousekeepingInboxSourceResult;
const inboxSource: HousekeepingInboxSource = {
+ id: "people.tickets",
+ owner: "people",
+ capability,
getItems: async () => ok(inboxSourceResult, "inbox-1"),
};
const widget: HousekeepingWidgetDefinition = {
+ id: "people.queue",
+ owner: "people",
+ capability,
+ kind: "mandatory",
load: async () => ok({ count: 2 }, "widget-1"),
};
@@ -80,6 +90,9 @@ const manifest: HousekeepingDomainManifest = {
previewHref: "/admin-next/people",
capability,
routes: [],
+ searchProviders: [searchProvider],
+ inboxSources: [inboxSource],
+ widgets: [widget],
};
describe("housekeeping foundation contracts", () => {
@@ -118,4 +131,23 @@ describe("housekeeping foundation contracts", () => {
requirements,
]).toHaveLength(8);
});
+
+ it("carries stable ownership and capability metadata for registry entries", () => {
+ expect(searchProvider).toMatchObject({
+ id: "people.users",
+ owner: "people",
+ capability,
+ });
+ expect(inboxSource).toMatchObject({
+ id: "people.tickets",
+ owner: "people",
+ capability,
+ });
+ expect(widget).toMatchObject({
+ id: "people.queue",
+ owner: "people",
+ capability,
+ kind: "mandatory",
+ });
+ });
});
diff --git a/src/features/housekeeping/foundation/contracts/domain.ts b/src/features/housekeeping/foundation/contracts/domain.ts
index 8c548b5673..9ccb4f5409 100644
--- a/src/features/housekeeping/foundation/contracts/domain.ts
+++ b/src/features/housekeeping/foundation/contracts/domain.ts
@@ -1,5 +1,8 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type { CapabilityRequirement } from "./capability";
+import type { HousekeepingInboxSource } from "./inbox";
+import type { HousekeepingSearchProvider } from "./search";
+import type { HousekeepingWidgetDefinition } from "./widget";
export interface HousekeepingRouteDefinition {
id: string;
@@ -17,4 +20,7 @@ export interface HousekeepingDomainManifest {
previewHref: `/admin-next/${HousekeepingDomainId}`;
capability: CapabilityRequirement;
routes: readonly HousekeepingRouteDefinition[];
+ searchProviders: readonly HousekeepingSearchProvider[];
+ inboxSources: readonly HousekeepingInboxSource[];
+ widgets: readonly HousekeepingWidgetDefinition[];
}
diff --git a/src/features/housekeeping/foundation/contracts/inbox.ts b/src/features/housekeeping/foundation/contracts/inbox.ts
index c402c0f86a..72bc4e8999 100644
--- a/src/features/housekeeping/foundation/contracts/inbox.ts
+++ b/src/features/housekeeping/foundation/contracts/inbox.ts
@@ -29,6 +29,9 @@ export interface HousekeepingInboxSourceResult {
}
export interface HousekeepingInboxSource {
+ id: string;
+ owner: HousekeepingDomainId;
+ capability: CapabilityRequirement;
getItems(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
diff --git a/src/features/housekeeping/foundation/contracts/index.ts b/src/features/housekeeping/foundation/contracts/index.ts
index ab48192f58..55f607fb4a 100644
--- a/src/features/housekeeping/foundation/contracts/index.ts
+++ b/src/features/housekeeping/foundation/contracts/index.ts
@@ -30,4 +30,7 @@ export type {
HousekeepingSearchProvider,
HousekeepingSearchResult,
} from "./search";
-export type { HousekeepingWidgetDefinition } from "./widget";
+export type {
+ HousekeepingWidgetDefinition,
+ HousekeepingWidgetKind,
+} from "./widget";
diff --git a/src/features/housekeeping/foundation/contracts/search.ts b/src/features/housekeeping/foundation/contracts/search.ts
index 744aa3d836..b12ca33495 100644
--- a/src/features/housekeeping/foundation/contracts/search.ts
+++ b/src/features/housekeeping/foundation/contracts/search.ts
@@ -1,5 +1,8 @@
import type { HousekeepingDomainId } from "../../migration/types";
-import type { HousekeepingCapabilityContext } from "./capability";
+import type {
+ CapabilityRequirement,
+ HousekeepingCapabilityContext,
+} from "./capability";
import type { HousekeepingResult } from "./result";
export interface HousekeepingSearchInput {
@@ -15,6 +18,9 @@ export interface HousekeepingSearchResult {
}
export interface HousekeepingSearchProvider {
+ id: string;
+ owner: HousekeepingDomainId;
+ capability: CapabilityRequirement;
search(
context: HousekeepingCapabilityContext,
input: HousekeepingSearchInput,
diff --git a/src/features/housekeeping/foundation/contracts/widget.ts b/src/features/housekeeping/foundation/contracts/widget.ts
index af3929e35f..3fee43234d 100644
--- a/src/features/housekeeping/foundation/contracts/widget.ts
+++ b/src/features/housekeeping/foundation/contracts/widget.ts
@@ -1,7 +1,17 @@
-import type { HousekeepingCapabilityContext } from "./capability";
+import type { HousekeepingDomainId } from "../../migration/types";
+import type {
+ CapabilityRequirement,
+ HousekeepingCapabilityContext,
+} from "./capability";
import type { HousekeepingResult } from "./result";
+export type HousekeepingWidgetKind = "mandatory" | "optional";
+
export interface HousekeepingWidgetDefinition {
+ id: string;
+ owner: HousekeepingDomainId;
+ capability: CapabilityRequirement;
+ kind: HousekeepingWidgetKind;
load(
context: HousekeepingCapabilityContext,
): Promise>;
diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts
index 8f00c82c08..3c7f541a00 100644
--- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts
+++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts
@@ -265,6 +265,8 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
}
} else if (value.type === "ImportExpression") {
recordArgument(value.source, "import");
+ } else if (value.type === "TSImportType") {
+ recordArgument(value.argument, "import");
} else if (
value.type === "CallExpression" ||
value.type === "OptionalCallExpression"
@@ -445,6 +447,12 @@ describe("housekeeping runtime import boundary", () => {
'import workflow from "../domains/people/workflow";',
"src/features/housekeeping/domains/people/workflow",
],
+ [
+ "TypeScript import type database access",
+ "src/features/housekeeping/foundation/registry.ts",
+ 'type PrismaClient = import("@prisma/client").PrismaClient;',
+ "@prisma/client",
+ ],
] as const)("detects %s", (_name, sourceFile, source, expectedPath) => {
expect(
findHousekeepingImportBoundaryViolations(source, sourceFile),
@@ -510,7 +518,7 @@ describe("housekeeping runtime import boundary", () => {
'import database from "@/lib/database";',
'import authentication from "@/lib/authentication";',
'import commandKit from "cmdkit";',
- 'import manifest from "../domains/people/manifest";',
+ 'import manifest from "./domains/people/manifest";',
"const documentation = \"import db from '@/lib/db'\";",
'// import action from "@/actions/users";',
].join("\n");
diff --git a/src/features/housekeeping/foundation/navigation.test.ts b/src/features/housekeeping/foundation/navigation.test.ts
index 82ae3c0d7c..88dce23636 100644
--- a/src/features/housekeeping/foundation/navigation.test.ts
+++ b/src/features/housekeeping/foundation/navigation.test.ts
@@ -26,6 +26,9 @@ describe("housekeeping navigation", () => {
previewHref: "/admin-next/people",
capability: anyCapability(PERMS.MOD_CFH_VIEW),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
},
{
id: "economy",
@@ -35,6 +38,9 @@ describe("housekeeping navigation", () => {
previewHref: "/admin-next/economy",
capability: anyCapability(PERMS.CATALOG_VIEW),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
},
]);
@@ -79,6 +85,9 @@ describe("housekeeping navigation", () => {
capability: anyCapability(PERMS.BANS_VIEW),
},
],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
},
{
id: "system",
@@ -88,6 +97,9 @@ describe("housekeeping navigation", () => {
previewHref: "/admin-next/system",
capability: anyCapability(PERMS.SETTINGS_VIEW),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
},
]);
const translate = vi.fn((key: string) => `translated:${key}`);
@@ -118,7 +130,7 @@ describe("housekeeping navigation", () => {
expect(translate).not.toHaveBeenCalledWith("system.title");
expect(translate).not.toHaveBeenCalledWith("system.description");
});
- it("shows the real People manifest only for its view capabilities", () => {
+ it("shows real domains to operators authorized by owned migration capabilities", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const translate = (key: string) => key;
@@ -137,15 +149,21 @@ describe("housekeeping navigation", () => {
).map((domain) => domain.id),
).not.toContain("economy");
- for (const slug of [PERMS.MOD_ACTIONS, PERMS.MOD_CFH_EDIT]) {
+ for (const [slug, expectedDomain] of [
+ [PERMS.MOD_ACTIONS, "people"],
+ [PERMS.USERS_EDIT, "people"],
+ [PERMS.SETTINGS_VIEW, "people"],
+ [PERMS.NEWS_EDIT, "content"],
+ [PERMS.SHOP_EDIT, "economy"],
+ [PERMS.ROOMS_EDIT, "hotel"],
+ ] as const) {
const visibleDomainIds = buildHousekeepingNavigation(
registry,
context([slug]),
translate,
).map((domain) => domain.id);
- expect(visibleDomainIds).not.toContain("people");
- expect(visibleDomainIds).not.toContain("economy");
+ expect(visibleDomainIds, slug).toContain(expectedDomain);
}
});
});
diff --git a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx
index cbb7cd4857..b6c9ecbfac 100644
--- a/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx
+++ b/src/features/housekeeping/foundation/page/housekeeping-page-state.test.tsx
@@ -1,5 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
+import en from "@/messages/en.json";
+import itMessages from "@/messages/it.json";
import { HousekeepingPageShell } from "./housekeeping-page-shell";
import { HousekeepingPageState } from "./housekeeping-page-state";
@@ -10,20 +12,29 @@ describe("HousekeepingPageState", () => {
["partial", "status"],
["error", "alert"],
] as const)("renders %s with the %s role", (state, role) => {
- const html = renderToStaticMarkup(
- ,
- );
+ const pageState =
+ state === "partial" ? (
+
+ ) : (
+
+ );
+ const html = renderToStaticMarkup(pageState);
expect(html).toContain(`role="${role}"`);
expect(html).toContain(`>${state} title<`);
expect(html).toContain(`>${state} description<`);
});
- it("announces loading politely and makes the partial warning visible", () => {
+ it("announces loading politely", () => {
const loading = renderToStaticMarkup(
{
description="Loading description"
/>,
);
- const partial = renderToStaticMarkup(
- ,
- );
expect(loading).toContain('aria-live="polite"');
- expect(partial).toContain("--admin-warning-border");
- expect(partial).toContain("--admin-warning-subtle");
- expect(partial).toContain(">Partial title<");
});
+ it.each([
+ ["English", en.pages.housekeeping.states.partial],
+ ["Italian", itMessages.pages.housekeeping.states.partial],
+ ] as const)(
+ "preserves the partial-state heading and separate localized warning in %s",
+ (_locale, copy) => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toMatch(new RegExp(`]*>${copy.title}
`));
+ expect(html).toContain(`>${copy.label}
`);
+ expect(html).toContain('data-state-tone="warning"');
+ expect(html).toContain("--admin-warning-border");
+ expect(html).toContain("--admin-warning-subtle");
+ },
+ );
+
it("renders supplied retry content for an error without owning a callback", () => {
const html = renderToStaticMarkup(
- {title}
+ {partialLabel}
- ) : (
- {title}
- )}
+ ) : null}
+ {title}
{description}
diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts
index 52a8d753a8..ae9bd9b634 100644
--- a/src/features/housekeeping/foundation/preview-route-contract.test.ts
+++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts
@@ -98,6 +98,9 @@ const forbiddenModuleRoots = [
"src/app/actions",
"src/app/admin",
"src/app/mod",
+ "@prisma/client",
+ "drizzle-orm",
+ "mysql2",
] as const;
interface BabelNode {
@@ -287,10 +290,6 @@ function canonicalizeLocalSpecifier(
const suffixIndex = specifier.search(/[?#]/);
const withoutSuffix =
suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
- const slashNormalized = withoutSuffix.replaceAll("\\", "/");
- if (!slashNormalized.startsWith("@/") && !slashNormalized.startsWith(".")) {
- return { candidates: [], violation: null };
- }
let decoded: string;
try {
@@ -305,12 +304,15 @@ function canonicalizeLocalSpecifier(
} else if (decoded.startsWith(".")) {
normalized = posix.normalize(posix.join(posix.dirname(routeFile), decoded));
} else {
- return { candidates: [], violation: "" };
+ normalized = posix.normalize(decoded);
}
const extensionless = normalized.replace(resolverExtensionPattern, "");
return {
- candidates: [...new Set([normalized, extensionless])],
+ candidates:
+ decoded.startsWith("@/") || decoded.startsWith(".")
+ ? [...new Set([normalized, extensionless])]
+ : [normalized],
violation: null,
};
}
@@ -785,6 +787,24 @@ describe("preview route import boundary", () => {
'import("../mod/users/page")',
"src/app/mod/users/page",
],
+ [
+ "Prisma TypeScript import type",
+ "src/app/admin-next/page.tsx",
+ 'type PrismaClient = import("@prisma/client").PrismaClient;',
+ "@prisma/client",
+ ],
+ [
+ "Drizzle package import",
+ "src/app/admin-next/page.tsx",
+ 'import { sql } from "drizzle-orm";',
+ "drizzle-orm",
+ ],
+ [
+ "mysql2 package import",
+ "src/app/admin-next/page.tsx",
+ 'import type { Pool } from "mysql2";',
+ "mysql2",
+ ],
] as const)("detects %s", (_name, routeFile, source, expectedPath) => {
expect(findRouteImportBoundaryViolations(source, routeFile)).toContain(
expectedPath,
@@ -824,6 +844,9 @@ describe("preview route import boundary", () => {
'import dbTools from "../../lib/db-tools.ts";',
'import auth from "../../lib/authentication";',
'import preview from "../admin-next-shared";',
+ 'import prismaTools from "@prisma/client-tools";',
+ 'import drizzleTools from "drizzle-orm-kit";',
+ 'import mysqlTools from "mysql2-wrapper";',
"const documentation = \"import db from '../../lib/db'\";",
'const rawTemplate = `import("../../actions/users")`;',
'// import db from "../../lib/db";',
diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts
index 97c3d07de1..5bbc528d88 100644
--- a/src/features/housekeeping/foundation/registry.test.ts
+++ b/src/features/housekeeping/foundation/registry.test.ts
@@ -1,11 +1,20 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
+import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
import {
HOUSEKEEPING_DOMAIN_IDS,
type HousekeepingDomainId,
} from "../migration/types";
-import { anyCapability, type HousekeepingDomainManifest } from "./contracts";
+import {
+ anyCapability,
+ type CapabilityRequirement,
+ type HousekeepingDomainManifest,
+ type HousekeepingInboxSource,
+ type HousekeepingSearchProvider,
+ type HousekeepingWidgetDefinition,
+ ok,
+} from "./contracts";
import { createHousekeepingRegistry } from "./registry";
const manifest = (
@@ -19,6 +28,45 @@ const manifest = (
previewHref: `/admin-next/${id}`,
capability: anyCapability(capabilitySlug),
routes: [],
+ searchProviders: [],
+ inboxSources: [],
+ widgets: [],
+});
+
+const providerCapability = anyCapability(PERMS.USERS_VIEW);
+
+const searchProvider = (
+ id: string,
+ owner: HousekeepingDomainId = "people",
+ capability: CapabilityRequirement = providerCapability,
+): HousekeepingSearchProvider => ({
+ id,
+ owner,
+ capability,
+ search: async () => ok([], "search"),
+});
+
+const inboxSource = (
+ id: string,
+ owner: HousekeepingDomainId = "people",
+ capability: CapabilityRequirement = providerCapability,
+): HousekeepingInboxSource => ({
+ id,
+ owner,
+ capability,
+ getItems: async () => ok({ items: [], availability: "available" }, "inbox"),
+});
+
+const widget = (
+ id: string,
+ owner: HousekeepingDomainId = "people",
+ capability: CapabilityRequirement = providerCapability,
+): HousekeepingWidgetDefinition => ({
+ id,
+ owner,
+ capability,
+ kind: "optional",
+ load: async () => ok(null, "widget"),
});
const expectedManifests = [
@@ -47,6 +95,18 @@ const expectedManifests = [
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.MOD_BANS_VIEW,
+ PERMS.USERS_EDIT,
+ PERMS.USERS_BAN,
+ PERMS.USERS_RESET_PASSWORD,
+ PERMS.MODERATION_EDIT,
+ PERMS.TICKETS_EDIT,
+ PERMS.SETTINGS_VIEW,
+ PERMS.SETTINGS_EDIT,
+ PERMS.WORDFILTER_VIEW,
+ PERMS.WORDFILTER_EDIT,
+ PERMS.MOD_ACTIONS,
+ PERMS.MOD_CFH_EDIT,
+ PERMS.MOD_TICKETS_EDIT,
],
},
{
@@ -62,6 +122,14 @@ const expectedManifests = [
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PREFIXES_VIEW,
+ PERMS.NEWS_EDIT,
+ PERMS.PAGES_EDIT,
+ PERMS.BANNERS_EDIT,
+ PERMS.EVENTS_EDIT,
+ PERMS.POLLS_EDIT,
+ PERMS.PREFIXES_EDIT,
+ PERMS.SETTINGS_VIEW,
+ PERMS.SETTINGS_EDIT,
],
},
{
@@ -70,7 +138,12 @@ const expectedManifests = [
previewHref: "/admin-next/economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
- slugs: [PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW],
+ slugs: [
+ PERMS.CATALOG_VIEW,
+ PERMS.SHOP_VIEW,
+ PERMS.CATALOG_EDIT,
+ PERMS.SHOP_EDIT,
+ ],
},
{
id: "hotel",
@@ -78,7 +151,16 @@ const expectedManifests = [
previewHref: "/admin-next/hotel",
labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description",
- slugs: [PERMS.ROOMS_VIEW, PERMS.RADIO_VIEW, PERMS.ASSETS_IMPORT],
+ slugs: [
+ PERMS.ROOMS_VIEW,
+ PERMS.RADIO_VIEW,
+ PERMS.ASSETS_IMPORT,
+ PERMS.ROOMS_EDIT,
+ PERMS.ROOMS_DELETE,
+ PERMS.RADIO_EDIT,
+ PERMS.PAGES_VIEW,
+ PERMS.CATALOG_EDIT,
+ ],
},
{
id: "system",
@@ -94,6 +176,8 @@ const expectedManifests = [
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
+ PERMS.SETTINGS_EDIT,
+ PERMS.NOTIFICATIONS_EDIT,
],
},
] as const;
@@ -240,10 +324,149 @@ describe("housekeeping registry", () => {
descriptionKey: expected.descriptionKey,
});
expect(actual.routes).toEqual([]);
+ expect(actual.searchProviders).toEqual([]);
+ expect(actual.inboxSources).toEqual([]);
+ expect(actual.widgets).toEqual([]);
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
}
});
+ it("covers every capability attributed to each domain's migration rows", () => {
+ for (const manifest of HOUSEKEEPING_MANIFESTS) {
+ const attributedCapabilities = new Set(
+ HOUSEKEEPING_MIGRATION_MATRIX.filter(
+ (entry) => entry.targetDomain === manifest.id,
+ ).flatMap((entry) => [
+ ...entry.capabilities.read,
+ ...entry.capabilities.mutate,
+ ]),
+ );
+
+ for (const slug of attributedCapabilities) {
+ expect(
+ manifest.capability.slugs,
+ `domain ${manifest.id}: ${slug}`,
+ ).toContain(slug);
+ }
+ }
+ });
+
+ it("rejects duplicate provider and widget ids across domain manifests", () => {
+ expect(() =>
+ createHousekeepingRegistry([
+ { ...manifest("people"), searchProviders: [searchProvider("shared")] },
+ {
+ ...manifest("content"),
+ searchProviders: [searchProvider("shared", "content")],
+ },
+ ]),
+ ).toThrow("duplicate search provider id: shared");
+ expect(() =>
+ createHousekeepingRegistry([
+ { ...manifest("people"), inboxSources: [inboxSource("shared")] },
+ {
+ ...manifest("content"),
+ inboxSources: [inboxSource("shared", "content")],
+ },
+ ]),
+ ).toThrow("duplicate inbox source id: shared");
+ expect(() =>
+ createHousekeepingRegistry([
+ { ...manifest("people"), widgets: [widget("shared")] },
+ {
+ ...manifest("content"),
+ widgets: [widget("shared", "content")],
+ },
+ ]),
+ ).toThrow("duplicate widget id: shared");
+ expect(() =>
+ createHousekeepingRegistry([
+ { ...manifest("people"), searchProviders: [searchProvider("shared")] },
+ {
+ ...manifest("content"),
+ widgets: [widget("shared", "content")],
+ },
+ ]),
+ ).toThrow("duplicate widget id: shared");
+ });
+
+ it("rejects provider and widget ownership outside their manifest", () => {
+ expect(() =>
+ createHousekeepingRegistry([
+ {
+ ...manifest("people"),
+ searchProviders: [searchProvider("people.users", "content")],
+ },
+ ]),
+ ).toThrow("search provider owner mismatch: people.users");
+ expect(() =>
+ createHousekeepingRegistry([
+ {
+ ...manifest("people"),
+ inboxSources: [inboxSource("people.tickets", "content")],
+ },
+ ]),
+ ).toThrow("inbox source owner mismatch: people.tickets");
+ expect(() =>
+ createHousekeepingRegistry([
+ {
+ ...manifest("people"),
+ widgets: [widget("people.queue", "content")],
+ },
+ ]),
+ ).toThrow("widget owner mismatch: people.queue");
+ });
+
+ it("rejects empty ids and invalid capability shapes for registry metadata", () => {
+ const invalidCapability = {
+ mode: "some",
+ slugs: [PERMS.USERS_VIEW],
+ } as unknown as CapabilityRequirement;
+
+ expect(() =>
+ createHousekeepingRegistry([
+ { ...manifest("people"), searchProviders: [searchProvider(" ")] },
+ ]),
+ ).toThrow("empty search provider id");
+ expect(() =>
+ createHousekeepingRegistry([
+ {
+ ...manifest("people"),
+ inboxSources: [
+ inboxSource("people.tickets", "people", invalidCapability),
+ ],
+ },
+ ]),
+ ).toThrow("invalid capability requirement");
+ expect(() =>
+ createHousekeepingRegistry([
+ {
+ ...manifest("people"),
+ widgets: [
+ widget(
+ "people.queue",
+ "people",
+ anyCapability("admin.ghost.widget"),
+ ),
+ ],
+ },
+ ]),
+ ).toThrow("unknown capability slug: admin.ghost.widget");
+ });
+
+ it("rejects widget kinds outside the mandatory or optional contract", () => {
+ expect(() =>
+ createHousekeepingRegistry([
+ {
+ ...manifest("people"),
+ widgets: [
+ { ...widget("people.queue"), kind: "fixed" as "mandatory" },
+ ],
+ },
+ ]),
+ ).toThrow("invalid widget kind: people.queue");
+ });
+
it("rejects empty domain and route capability requirements", () => {
expect(() =>
createHousekeepingRegistry([
diff --git a/src/features/housekeeping/foundation/registry.ts b/src/features/housekeeping/foundation/registry.ts
index dc6c58ae71..f301ebbeb3 100644
--- a/src/features/housekeeping/foundation/registry.ts
+++ b/src/features/housekeeping/foundation/registry.ts
@@ -7,6 +7,11 @@ import type {
const approvedDomainIds = new Set(HOUSEKEEPING_DOMAIN_IDS);
const knownCapabilitySlugs = new Set(Object.values(PERMS));
+interface OwnedRegistryEntry {
+ id: string;
+ owner: HousekeepingDomainManifest["id"];
+ capability: CapabilityRequirement;
+}
export interface HousekeepingRegistry {
domains: readonly HousekeepingDomainManifest[];
@@ -18,6 +23,7 @@ export function createHousekeepingRegistry(
const domainIds = new Set();
const routeIds = new Set();
const routeHrefs = new Set();
+ const registryEntryIds = new Set();
for (const manifest of manifests) {
if (!approvedDomainIds.has(manifest.id)) {
@@ -34,6 +40,29 @@ export function createHousekeepingRegistry(
validateNonEmpty(manifest.labelKey, "label key");
validateNonEmpty(manifest.descriptionKey, "description key");
validateCapability(manifest.capability);
+ validateOwnedRegistryEntries(
+ manifest.searchProviders,
+ manifest.id,
+ "search provider",
+ registryEntryIds,
+ );
+ validateOwnedRegistryEntries(
+ manifest.inboxSources,
+ manifest.id,
+ "inbox source",
+ registryEntryIds,
+ );
+ validateOwnedRegistryEntries(
+ manifest.widgets,
+ manifest.id,
+ "widget",
+ registryEntryIds,
+ );
+ for (const widget of manifest.widgets) {
+ if (widget.kind !== "mandatory" && widget.kind !== "optional") {
+ throw new Error(`invalid widget kind: ${widget.id}`);
+ }
+ }
for (const route of manifest.routes) {
validateNonEmpty(route.id, "route id");
@@ -56,18 +85,51 @@ export function createHousekeepingRegistry(
return { domains: Object.freeze([...manifests]) };
}
+function validateOwnedRegistryEntries(
+ entries: readonly OwnedRegistryEntry[],
+ owner: HousekeepingDomainManifest["id"],
+ kind: "search provider" | "inbox source" | "widget",
+ ids: Set,
+): void {
+ for (const entry of entries) {
+ validateNonEmpty(entry.id, `${kind} id`);
+ if (ids.has(entry.id)) {
+ throw new Error(`duplicate ${kind} id: ${entry.id}`);
+ }
+ ids.add(entry.id);
+
+ if (entry.owner !== owner) {
+ throw new Error(`${kind} owner mismatch: ${entry.id}`);
+ }
+
+ validateCapability(entry.capability);
+ }
+}
+
function validateNonEmpty(value: string, name: string): void {
- if (!value.trim()) {
+ if (typeof value !== "string" || !value.trim()) {
throw new Error(`empty ${name}`);
}
}
function validateCapability(requirement: CapabilityRequirement): void {
+ if (
+ typeof requirement !== "object" ||
+ requirement === null ||
+ (requirement.mode !== "any" && requirement.mode !== "all") ||
+ !Array.isArray(requirement.slugs)
+ ) {
+ throw new Error("invalid capability requirement");
+ }
+
if (requirement.slugs.length === 0) {
throw new Error("empty capability requirement");
}
for (const slug of requirement.slugs) {
+ if (typeof slug !== "string") {
+ throw new Error("invalid capability requirement");
+ }
if (!knownCapabilitySlugs.has(slug)) {
throw new Error(`unknown capability slug: ${slug}`);
}
diff --git a/src/features/housekeeping/migration/validate-matrix.test.ts b/src/features/housekeeping/migration/validate-matrix.test.ts
index f2d0f3d54e..87e4a1d238 100644
--- a/src/features/housekeeping/migration/validate-matrix.test.ts
+++ b/src/features/housekeeping/migration/validate-matrix.test.ts
@@ -50,6 +50,48 @@ describe("validateMigrationEntries", () => {
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")],
diff --git a/src/features/housekeeping/migration/validate-matrix.ts b/src/features/housekeeping/migration/validate-matrix.ts
index 222e9d02b8..0e6f92314a 100644
--- a/src/features/housekeeping/migration/validate-matrix.ts
+++ b/src/features/housekeeping/migration/validate-matrix.ts
@@ -16,7 +16,13 @@ export function validateMigrationEntries(
entries: readonly MigrationEntry[],
): string[] {
const issues = new Set();
- const discoveredPaths = new Set(discovered.map((page) => page.legacyPath));
+ 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) {
@@ -24,8 +30,30 @@ export function validateMigrationEntries(
matchingEntries.push(entry);
entriesByPath.set(entry.legacyPath, matchingEntries);
- if (!discoveredPaths.has(entry.legacyPath)) {
+ 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}`,
+ );
}
}
@@ -45,6 +73,10 @@ export function validateMigrationEntries(
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}`);
}