feat(housekeeping): dispatch canonical domain routes

This commit is contained in:
Simo committed 2026-08-28 21:35:08 +02:00
1 parent 2970dff563
commit e5c230ba35
13 files changed
+680 -82

No files matched your search

@@ -47,6 +47,13 @@ const routeMocks = vi.hoisted(() => {
redirect: vi.fn((href: string): never => {
throw new Error(`NEXT_REDIRECT:${href}`);
}),
renderHousekeepingRoute: vi.fn(
async (input: {
context: { actor: { username: string } };
match: { routeId: string };
}) =>
`Rendered ${input.match.routeId} for ${input.context.actor.username}`,
),
translate,
};
});
@@ -77,9 +84,62 @@ vi.mock("@/actions", () => {
vi.mock("@/app/actions", () => {
throw new Error("preview routes must not import actions");
});
vi.mock("@/features/housekeeping/manifests", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/features/housekeeping/manifests")>();
return {
HOUSEKEEPING_MANIFESTS: actual.HOUSEKEEPING_MANIFESTS.map((manifest) =>
manifest.id === "people"
? {
...manifest,
routes: [
{
id: "people.users",
labelKey: "pages.housekeeping.domains.people.title",
href: "/ase/people/users" as const,
capability: {
mode: "any" as const,
slugs: ["admin.users.view", "mod.cfh.view"],
},
},
{
id: "people.user-detail",
labelKey: "pages.housekeeping.domains.people.title",
href: "/ase/people/users/:id" as const,
capability: {
mode: "any" as const,
slugs: ["admin.users.view", "mod.cfh.view"],
},
},
{
id: "people.bans",
labelKey: "pages.housekeeping.domains.people.title",
href: "/ase/people/bans" as const,
capability: {
mode: "any" as const,
slugs: ["admin.bans.view"],
},
},
],
}
: manifest,
),
};
});
vi.mock("@/features/housekeeping/route-handlers", () => ({
HOUSEKEEPING_ROUTE_HANDLERS: [
{ routeId: "people.users", render: routeMocks.renderHousekeepingRoute },
{
routeId: "people.user-detail",
render: routeMocks.renderHousekeepingRoute,
},
{ routeId: "people.bans", render: routeMocks.renderHousekeepingRoute },
],
}));
import AdminNextDomainPage from "@/app/ase-next/[domain]/[[...segments]]/page";
import AdminNextDomainLayout from "@/app/ase-next/[domain]/layout";
import AdminNextDomainPage from "@/app/ase-next/[domain]/page";
import AdminNextLayout from "@/app/ase-next/layout";
import AdminNextPage from "@/app/ase-next/page";
@@ -87,7 +147,7 @@ const routeFiles = [
"src/app/ase-next/layout.tsx",
"src/app/ase-next/page.tsx",
"src/app/ase-next/[domain]/layout.tsx",
"src/app/ase-next/[domain]/page.tsx",
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
] as const;
const forbiddenModuleRoots = [
@@ -407,15 +467,15 @@ describe("/ase-next first visible domain", () => {
vi.clearAllMocks();
});
it("redirects an administrator away from the Operations preview root", async () => {
it("redirects an administrator to the first permitted registered route", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
);
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/ase-next/operations",
"NEXT_REDIRECT:/ase-next/people/users",
);
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/operations");
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people/users");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
@@ -428,9 +488,9 @@ describe("/ase-next first visible domain", () => {
);
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/ase-next/people",
"NEXT_REDIRECT:/ase-next/people/users",
);
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people");
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people/users");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
@@ -515,34 +575,86 @@ describe("/ase-next/[domain] layout", () => {
});
});
describe("/ase-next/[domain] page", () => {
describe("/ase-next/[domain]/[[...segments]] page", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the real localized manifest and empty state without reloading access", async () => {
it("maps a permitted preview path and renders its handler with one context", async () => {
const context = capabilityContext([PERMS.MOD_CFH_VIEW]);
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
const html = await renderRoute(
AdminNextDomainPage({
params: Promise.resolve({ domain: "people" }),
params: Promise.resolve({ domain: "people", segments: ["users"] }),
}),
);
expect(html).toContain("HK::people-title");
expect(html).toContain("Localized People description");
expect(html).toContain("Localized empty title");
expect(html).toContain("Localized empty description");
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).toHaveBeenCalledTimes(1);
expect(html).toContain("Rendered people.users for refreshed-moderator");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledTimes(1);
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
match: {
routeId: "people.users",
domain: "people",
params: {},
canonicalHref: "/ase/people/users",
},
});
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
});
it("rejects an unknown domain before translating", async () => {
it("maps nested preview segments to canonical dynamic parameters", async () => {
const context = capabilityContext([PERMS.USERS_VIEW]);
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
await renderRoute(
AdminNextDomainPage({
params: Promise.resolve({
domain: "people",
segments: ["users", "42"],
}),
}),
);
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
context,
match: {
routeId: "people.user-detail",
domain: "people",
params: { id: "42" },
canonicalHref: "/ase/people/users/42",
},
});
});
it("returns 404 for an inaccessible matched route without invoking it", async () => {
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
capabilityContext([PERMS.MOD_CFH_VIEW]),
);
await expect(
AdminNextDomainPage({
params: Promise.resolve({ domain: "unknown" }),
params: Promise.resolve({ domain: "people", segments: ["bans"] }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
1,
);
expect(routeMocks.renderHousekeepingRoute).not.toHaveBeenCalled();
});
it("rejects an unknown route before loading capability context", async () => {
await expect(
AdminNextDomainPage({
params: Promise.resolve({ domain: "people", segments: ["unknown"] }),
}),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
expect(routeMocks.getTranslations).not.toHaveBeenCalled();
expect(routeMocks.renderHousekeepingRoute).not.toHaveBeenCalled();
});
});
@@ -699,8 +811,8 @@ describe("preview route import boundary", () => {
],
[
"nested template-expression dynamic action import",
"src/app/ase-next/[domain]/page.tsx",
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`,
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../../actions/nested")}\` : ""}\`;`,
"src/actions/nested",
],
[
@@ -759,8 +871,8 @@ describe("preview route import boundary", () => {
],
[
"domain relative permissions export",
"src/app/ase-next/[domain]/page.tsx",
'export { getAdminContext } from "../../../lib/permissions";',
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
'export { getAdminContext } from "../../../../lib/permissions";',
"src/lib/permissions",
],
[
@@ -269,6 +269,32 @@ describe("housekeeping registry", () => {
).toThrow("duplicate route href");
});
it("rejects duplicate dynamic route shapes regardless of parameter name", () => {
const base = manifest("people");
expect(() =>
createHousekeepingRegistry([
{
...base,
routes: [
{
id: "people.user-detail",
labelKey: "pages.housekeeping.routes.people.user-detail",
href: "/ase/people/users/:id",
capability: anyCapability(PERMS.USERS_VIEW),
},
{
id: "people.user-profile",
labelKey: "pages.housekeeping.routes.people.user-profile",
href: "/ase/people/users/:username",
capability: anyCapability(PERMS.USERS_VIEW),
},
],
},
]),
).toThrow("duplicate dynamic route shape");
});
it("rejects empty route fields and unknown capability slugs", () => {
const route = {
id: "users",
@@ -23,6 +23,7 @@ export function createHousekeepingRegistry(
const domainIds = new Set<string>();
const routeIds = new Set<string>();
const routeHrefs = new Set<string>();
const routeShapes = new Set<string>();
const registryEntryIds = new Set<string>();
for (const manifest of manifests) {
@@ -83,6 +84,12 @@ export function createHousekeepingRegistry(
}
routeHrefs.add(route.href);
const routeShape = dynamicRouteShape(route.href);
if (routeShape && routeShapes.has(routeShape)) {
throw new Error(`duplicate dynamic route shape: ${routeShape}`);
}
if (routeShape) routeShapes.add(routeShape);
validateNonEmpty(route.labelKey, "route label key");
validateCapability(route.capability);
}
@@ -91,6 +98,20 @@ export function createHousekeepingRegistry(
return { domains: Object.freeze([...manifests]) };
}
function dynamicRouteShape(href: string): string | null {
let hasDynamicSegment = false;
const shape = href
.split("/")
.map((segment) => {
if (!segment.startsWith(":")) return segment;
hasDynamicSegment = true;
return ":";
})
.join("/");
return hasDynamicSegment ? shape : null;
}
function validateOwnedRegistryEntries(
entries: readonly OwnedRegistryEntry[],
owner: HousekeepingDomainManifest["id"],
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type CanonicalHousekeepingHref,
type HousekeepingDomainManifest,
} from "../contracts";
import { createHousekeepingRegistry } from "../registry";
import { matchHousekeepingRoute } from "./match-route";
const requirement = anyCapability(PERMS.USERS_VIEW);
function peopleManifest(
routes: HousekeepingDomainManifest["routes"],
): HousekeepingDomainManifest {
return {
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
canonicalHref: "/ase/people",
capability: requirement,
routes,
searchProviders: [],
inboxSources: [],
widgets: [],
};
}
function route(
id: string,
href: CanonicalHousekeepingHref,
): HousekeepingDomainManifest["routes"][number] {
return {
id,
labelKey: `pages.housekeeping.routes.${id}`,
href,
capability: requirement,
};
}
const registry = createHousekeepingRegistry([
peopleManifest([
route("people.user-detail", "/ase/people/users/:id"),
route("people.user-create", "/ase/people/users/new"),
route("people.users", "/ase/people/users"),
route("people.ticket-note", "/ase/people/tickets/:ticketId/notes/:noteId"),
route("people.literal-tool", "/ase/people/tools/a+b[1]"),
]),
]);
describe("matchHousekeepingRoute", () => {
it("matches a static route without parameters", () => {
expect(matchHousekeepingRoute(registry, "/ase/people/users")).toEqual({
routeId: "people.users",
domain: "people",
params: {},
canonicalHref: "/ase/people/users",
});
});
it("prefers a static route over a dynamic route with the same depth", () => {
expect(matchHousekeepingRoute(registry, "/ase/people/users/new")).toEqual({
routeId: "people.user-create",
domain: "people",
params: {},
canonicalHref: "/ase/people/users/new",
});
});
it("matches a dynamic segment and decodes its parameter", () => {
expect(
matchHousekeepingRoute(registry, "/ase/people/users/Ada%20Lovelace"),
).toEqual({
routeId: "people.user-detail",
domain: "people",
params: { id: "Ada Lovelace" },
canonicalHref: "/ase/people/users/Ada%20Lovelace",
});
});
it("matches nested dynamic segments independently", () => {
expect(
matchHousekeepingRoute(
registry,
"/ase/people/tickets/42/notes/follow-up",
),
).toEqual({
routeId: "people.ticket-note",
domain: "people",
params: { ticketId: "42", noteId: "follow-up" },
canonicalHref: "/ase/people/tickets/42/notes/follow-up",
});
});
it("treats regular-expression metacharacters as literal segment text", () => {
expect(
matchHousekeepingRoute(registry, "/ase/people/tools/a+b[1]"),
)?.toMatchObject({ routeId: "people.literal-tool" });
expect(
matchHousekeepingRoute(registry, "/ase/people/tools/ab1"),
).toBeNull();
});
it.each([
"/ase/people/unknown",
"/ase/economy/users",
"/ase/system/users/Ada",
])("returns null for unknown or cross-domain path %s", (path) => {
expect(matchHousekeepingRoute(registry, path)).toBeNull();
});
it.each([
"ase/people/users",
"/ase//people/users",
"/ase/people/users/",
"/ase/people/users?tab=active",
"/ase/people/users/%",
"/ase/people/users/%2Fsystem",
"/ase/people/users/..",
"/ase\\people\\users",
])("fails closed for malformed canonical path %s", (path) => {
expect(matchHousekeepingRoute(registry, path)).toBeNull();
});
});
@@ -0,0 +1,138 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type { CanonicalHousekeepingHref } from "../contracts";
import type { HousekeepingRegistry } from "../registry";
export interface HousekeepingRouteMatch {
routeId: string;
domain: HousekeepingDomainId;
params: Readonly<Record<string, string>>;
canonicalHref: CanonicalHousekeepingHref;
}
interface ParsedCanonicalPath {
rawSegments: readonly string[];
decodedSegments: readonly string[];
}
interface RouteCandidate {
routeId: string;
domain: HousekeepingDomainId;
patternSegments: readonly string[];
dynamicSegments: number;
}
export function matchHousekeepingRoute(
registry: HousekeepingRegistry,
canonicalPath: string,
): HousekeepingRouteMatch | null {
const path = parseCanonicalPath(canonicalPath);
if (!path) return null;
const candidates = registry.domains
.flatMap((domain) =>
domain.routes
.filter((route) =>
routeBelongsToDomain(route.href, domain.canonicalHref),
)
.map((route) => {
const patternSegments = route.href.slice(1).split("/");
return {
routeId: route.id,
domain: domain.id,
patternSegments,
dynamicSegments: patternSegments.filter((segment) =>
segment.startsWith(":"),
).length,
};
}),
)
.sort((left, right) => left.dynamicSegments - right.dynamicSegments);
for (const candidate of candidates) {
const params = matchSegments(candidate, path);
if (!params) continue;
return {
routeId: candidate.routeId,
domain: candidate.domain,
params: Object.freeze(params),
canonicalHref: canonicalPath as CanonicalHousekeepingHref,
};
}
return null;
}
function parseCanonicalPath(canonicalPath: string): ParsedCanonicalPath | null {
if (
canonicalPath !== canonicalPath.trim() ||
!canonicalPath.startsWith("/ase") ||
canonicalPath.includes("?") ||
canonicalPath.includes("#") ||
canonicalPath.includes("\\") ||
canonicalPath.endsWith("/")
) {
return null;
}
const rawSegments = canonicalPath.slice(1).split("/");
if (rawSegments[0] !== "ase" || rawSegments.some((segment) => !segment)) {
return null;
}
const decodedSegments: string[] = [];
for (const segment of rawSegments) {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return null;
}
if (
!decoded ||
decoded === "." ||
decoded === ".." ||
decoded.includes("/") ||
decoded.includes("\\")
) {
return null;
}
decodedSegments.push(decoded);
}
return { rawSegments, decodedSegments };
}
function routeBelongsToDomain(
routeHref: CanonicalHousekeepingHref,
domainHref: CanonicalHousekeepingHref,
): boolean {
return (
routeHref === domainHref ||
(domainHref !== "/ase" && routeHref.startsWith(`${domainHref}/`)) ||
(domainHref === "/ase" && routeHref === "/ase")
);
}
function matchSegments(
candidate: RouteCandidate,
path: ParsedCanonicalPath,
): Record<string, string> | null {
if (candidate.patternSegments.length !== path.rawSegments.length) return null;
const params: Record<string, string> = {};
for (const [index, patternSegment] of candidate.patternSegments.entries()) {
if (!patternSegment.startsWith(":")) {
if (patternSegment !== path.rawSegments[index]) return null;
continue;
}
const parameterName = patternSegment.slice(1);
if (!parameterName || parameterName in params) return null;
params[parameterName] = path.decodedSegments[index] ?? "";
}
return params;
}
@@ -79,9 +79,7 @@ async function invokeRequestConsumers() {
children: null,
params: Promise.resolve({ domain: "operations" }),
});
await expect(AdminNextPage()).rejects.toThrow(
"NEXT_REDIRECT:/ase-next/operations",
);
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
const commandContext = await requireHousekeepingCapability(
anyCapability("admin.dashboard"),
);
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { createHousekeepingRegistry } from "./foundation/registry";
import { HOUSEKEEPING_MANIFESTS } from "./manifests";
import { HOUSEKEEPING_ROUTE_HANDLERS } from "./route-handlers";
describe("housekeeping route handlers", () => {
it("has exactly one handler for every registered route and no placeholders", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const routeIds = registry.domains.flatMap((domain) =>
domain.routes.map((route) => route.id),
);
const handlerIds = HOUSEKEEPING_ROUTE_HANDLERS.map(
(handler) => handler.routeId,
);
expect(new Set(handlerIds).size).toBe(handlerIds.length);
expect([...handlerIds].sort()).toEqual([...routeIds].sort());
expect(handlerIds).toEqual([]);
});
});
@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
export interface HousekeepingPageInput {
readonly context: HousekeepingCapabilityContext;
readonly match: HousekeepingRouteMatch;
}
export interface HousekeepingRouteHandler {
routeId: string;
render(input: HousekeepingPageInput): Promise<ReactNode>;
}
export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] =
Object.freeze([]);