From e5c230ba35aec2b4c471608d32b8a16ecc1ee382 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 28 Aug 2026 21:35:08 +0200 Subject: [PATCH] feat(housekeeping): dispatch canonical domain routes --- .../task-9-report.md | 140 ++++++++++++++++ .../[domain]/[[...segments]]/page.tsx | 43 +++++ src/app/ase-next/[domain]/layout.tsx | 2 +- src/app/ase-next/[domain]/page.tsx | 45 ----- src/app/ase-next/page.tsx | 24 +-- .../foundation/preview-route-contract.test.ts | 158 +++++++++++++++--- .../housekeeping/foundation/registry.test.ts | 26 +++ .../housekeeping/foundation/registry.ts | 21 +++ .../foundation/routing/match-route.test.ts | 125 ++++++++++++++ .../foundation/routing/match-route.ts | 138 +++++++++++++++ .../server-capability-context.test.ts | 4 +- .../housekeeping/route-handlers.test.ts | 20 +++ src/features/housekeeping/route-handlers.ts | 16 ++ 13 files changed, 680 insertions(+), 82 deletions(-) create mode 100644 .superpowers/sdd/2026-08-26-housekeeping-completion/task-9-report.md create mode 100644 src/app/ase-next/[domain]/[[...segments]]/page.tsx delete mode 100644 src/app/ase-next/[domain]/page.tsx create mode 100644 src/features/housekeeping/foundation/routing/match-route.test.ts create mode 100644 src/features/housekeeping/foundation/routing/match-route.ts create mode 100644 src/features/housekeeping/route-handlers.test.ts create mode 100644 src/features/housekeeping/route-handlers.ts diff --git a/.superpowers/sdd/2026-08-26-housekeeping-completion/task-9-report.md b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-9-report.md new file mode 100644 index 00000000..20af223c --- /dev/null +++ b/.superpowers/sdd/2026-08-26-housekeeping-completion/task-9-report.md @@ -0,0 +1,140 @@ +# Task 9 report — canonical route dispatch + +## Status + +- DONE: matcher, registry collision guard, empty handler aggregate, preview root routing, and catch-all dispatch are implemented. +- Base verified before edits: `2970dff56378cf5259fd125794bf0d89bec25b25` on `codex/housekeeping-complete`. +- Commit message: `feat(housekeeping): dispatch canonical domain routes`. +- `.remember/` remained untouched and untracked. +- No pull, push, PR/MR update, deployment, database operation, Task 10 work, or worktree was performed. + +## TDD evidence + +### RED — tests written before production + +Exact command: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/foundation/routing/match-route.test.ts src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/preview-route-contract.test.ts +``` + +Observed exit 1: + +```text +Test Files 4 failed (4) +Tests 1 failed | 14 passed (15) + +Cannot find module './match-route' +Cannot find module './route-handlers' +Cannot find package '@/app/ase-next/[domain]/[[...segments]]/page' + +registry > rejects duplicate dynamic route shapes regardless of parameter name +AssertionError: expected [Function] to throw an error +``` + +This proved the three missing production boundaries and the existing registry's acceptance of equivalent `:id` / `:username` route shapes. + +### First targeted GREEN + +The same exact command after the minimum implementation exited 0: + +```text +Test Files 4 passed (4) +Tests 94 passed (94) +``` + +An intermediate run had 92/94 passing because two import-boundary fixtures still used the old route file's relative depth. The fixture imports were moved one directory higher for the new catch-all location; the forbidden-module assertions were unchanged. + +### Full-suite contract correction + +The first full housekeeping run correctly exposed one obsolete Task 1 expectation: + +```text +Test Files 1 failed | 37 passed (38) +Tests 1 failed | 348 passed (349) +Expected NEXT_REDIRECT:/ase-next/operations +Received NEXT_NOT_FOUND +``` + +`server-capability-context.test.ts` was updated to the Task 9 ruling: with the real registered route set still empty, `/ase-next` calls `notFound()` and must not redirect to an empty Operations placeholder. Its request-scoped context isolation assertions remain intact. + +## Implemented behavior + +- `matchHousekeepingRoute` compares decoded path segments without constructing a regular expression from route text. +- Static routes win over same-depth dynamic routes; dynamic and nested parameters are returned in a frozen readonly record. +- Unknown, cross-domain, malformed, repeated-separator, trailing-separator, query/fragment, invalid-percent, encoded-separator, dot-segment, and backslash paths fail closed. +- Registry construction rejects duplicate dynamic shapes even when parameter names differ. +- `HousekeepingPageInput` is exactly the readonly `{ context, match }` pair. +- The global route-handler aggregate is empty and its test proves one-to-one equality with the currently empty manifest route set; no placeholder handlers were added. +- `/ase-next` searches registered routes in manifest order, requires both domain and route capability, redirects to the first permitted route, and calls `notFound()` when none exists. +- `/ase-next//` derives the domain's canonical `/ase` path, matches it, finds the exact handler, reacquires the cached request-scoped context, rechecks domain and route capability, and invokes the handler with that same context and match. +- Unknown routes fail before context loading; inaccessible matched routes load one context and never invoke a handler. + +## Verification evidence + +Targeted routing/registry/handler/preview tests: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/foundation/routing/match-route.test.ts src/features/housekeeping/foundation/registry.test.ts src/features/housekeeping/route-handlers.test.ts src/features/housekeeping/foundation/preview-route-contract.test.ts +Test Files 4 passed (4) +Tests 94 passed (94) +``` + +Directly affected context contract: + +```text +pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/foundation/server-capability-context.test.ts +Test Files 1 passed (1) +Tests 2 passed (2) +``` + +Full housekeeping suite: + +```text +pnpm test:housekeeping +Test Files 38 passed (38) +Tests 349 passed (349) +``` + +TypeScript: + +```text +pnpm typecheck +$ tsc --noEmit +exit 0 +``` + +The only output note was the existing engine warning: local Node `26.7.0` is below the package request `>=26.8.1 <27`. + +Targeted Biome with formatting disabled: + +```text +pnpm exec biome check --formatter-enabled=false <11 changed Task 9 source/test files> +Checked 11 files in 47ms. No fixes applied. +``` + +`git diff --check` exited 0 before staging. Cached-diff and committed-tree checks are run as the final staging/commit gates. + +## Exact Task 9 files + +```text +src/app/ase-next/[domain]/[[...segments]]/page.tsx +src/app/ase-next/[domain]/layout.tsx +src/app/ase-next/[domain]/page.tsx (deleted) +src/app/ase-next/page.tsx +src/features/housekeeping/foundation/preview-route-contract.test.ts +src/features/housekeeping/foundation/registry.test.ts +src/features/housekeeping/foundation/registry.ts +src/features/housekeeping/foundation/routing/match-route.test.ts +src/features/housekeeping/foundation/routing/match-route.ts +src/features/housekeeping/foundation/server-capability-context.test.ts +src/features/housekeeping/route-handlers.test.ts +src/features/housekeeping/route-handlers.ts +.superpowers/sdd/2026-08-26-housekeeping-completion/task-9-report.md +``` + +## Self-review and tooling + +- Mutation check: dynamic-name normalization removal, regex-style static matching, static-priority removal, decoded-separator acceptance, domain mismatch acceptance, skipped route ACL, context reload inside the handler, missing handler lookup, placeholder handler addition, and empty-domain redirect each break a focused test. +- The catch-all route imports only housekeeping foundation/manifests/handlers and retains the existing forbidden database/auth/permissions/actions/legacy-page boundary audit. +- `apply_patch` created all new files, but the Windows sandbox helper repeatedly failed to read existing files with `apply deny-read ACLs`. Existing-file edits therefore used controller-approved exact-anchor/full-file fallbacks only after resolving absolute paths and validating every target under `E:\Users\simol\Desktop\EpicNext-cms`. diff --git a/src/app/ase-next/[domain]/[[...segments]]/page.tsx b/src/app/ase-next/[domain]/[[...segments]]/page.tsx new file mode 100644 index 00000000..e6e33ecf --- /dev/null +++ b/src/app/ase-next/[domain]/[[...segments]]/page.tsx @@ -0,0 +1,43 @@ +import { notFound } from "next/navigation"; +import { satisfiesCapability } from "@/features/housekeeping/foundation/capability-context"; +import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; +import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route"; +import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; +import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; +import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers"; + +export default async function HousekeepingPreviewRoutePage({ + params, +}: { + params: Promise<{ domain: string; segments?: readonly string[] }>; +}) { + const { domain, segments = [] } = await params; + const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); + const activeDomain = registry.domains.find((entry) => entry.id === domain); + if (!activeDomain) notFound(); + + const suffix = segments + .map((segment) => encodeURIComponent(segment)) + .join("/"); + const canonicalPath = suffix + ? `${activeDomain.canonicalHref}/${suffix}` + : activeDomain.canonicalHref; + const match = matchHousekeepingRoute(registry, canonicalPath); + if (!match || match.domain !== activeDomain.id) notFound(); + + const route = activeDomain.routes.find((entry) => entry.id === match.routeId); + const handler = HOUSEKEEPING_ROUTE_HANDLERS.find( + (entry) => entry.routeId === match.routeId, + ); + if (!route || !handler) notFound(); + + const context = await getHousekeepingCapabilityContext(); + if ( + !satisfiesCapability(context, activeDomain.capability) || + !satisfiesCapability(context, route.capability) + ) { + notFound(); + } + + return handler.render({ context, match }); +} diff --git a/src/app/ase-next/[domain]/layout.tsx b/src/app/ase-next/[domain]/layout.tsx index 72d73818..c23291b8 100644 --- a/src/app/ase-next/[domain]/layout.tsx +++ b/src/app/ase-next/[domain]/layout.tsx @@ -18,7 +18,7 @@ function namespaceKey(key: string): string { return key.slice(MESSAGE_PREFIX.length); } -export default async function AdminNextDomainLayout({ +export default async function HousekeepingPreviewDomainLayout({ children, params, }: { diff --git a/src/app/ase-next/[domain]/page.tsx b/src/app/ase-next/[domain]/page.tsx deleted file mode 100644 index cd9f51ef..00000000 --- a/src/app/ase-next/[domain]/page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { notFound } from "next/navigation"; -import { getTranslations } from "next-intl/server"; -import { HousekeepingPageShell } from "@/features/housekeeping/foundation/page/housekeeping-page-shell"; -import { HousekeepingPageState } from "@/features/housekeeping/foundation/page/housekeeping-page-state"; -import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; -import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; - -const MESSAGE_PREFIX = "pages.housekeeping."; - -function namespaceKey(key: string): string { - if (!key.startsWith(MESSAGE_PREFIX)) { - throw new Error(`invalid housekeeping message key: ${key}`); - } - - return key.slice(MESSAGE_PREFIX.length); -} - -export default async function AdminNextDomainPage({ - params, -}: { - params: Promise<{ domain: string }>; -}) { - const { domain } = await params; - const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); - const activeDomain = registry.domains.find((entry) => entry.id === domain); - - if (!activeDomain) notFound(); - - const translate = await getTranslations("pages.housekeeping"); - - return ( - - - - ); -} diff --git a/src/app/ase-next/page.tsx b/src/app/ase-next/page.tsx index 9cf4442a..c45421ac 100644 --- a/src/app/ase-next/page.tsx +++ b/src/app/ase-next/page.tsx @@ -5,19 +5,23 @@ import { toHousekeepingHref } from "@/features/housekeeping/foundation/routing/h import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; -export default async function AdminNextPage() { +export default async function HousekeepingPreviewRootPage() { const context = await getHousekeepingCapabilityContext(); const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); - const firstVisibleDomain = registry.domains.find((domain) => - satisfiesCapability(context, domain.capability), - ); - if (!firstVisibleDomain) notFound(); + for (const domain of registry.domains) { + if (!satisfiesCapability(context, domain.capability)) continue; - const previewHref = toHousekeepingHref( - firstVisibleDomain.canonicalHref, - "preview", - ); + const route = domain.routes.find((entry) => + satisfiesCapability(context, entry.capability), + ); + if (!route) continue; - redirect(previewHref === "/ase-next" ? "/ase-next/operations" : previewHref); + const previewHref = toHousekeepingHref(route.href, "preview"); + redirect( + previewHref === "/ase-next" ? "/ase-next/operations" : previewHref, + ); + } + + notFound(); } diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 67061d98..63c678ee 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -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(); + 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", ], [ diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index 209815be..8c4d01ba 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -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", diff --git a/src/features/housekeeping/foundation/registry.ts b/src/features/housekeeping/foundation/registry.ts index bc63f55b..07f97eca 100644 --- a/src/features/housekeeping/foundation/registry.ts +++ b/src/features/housekeeping/foundation/registry.ts @@ -23,6 +23,7 @@ export function createHousekeepingRegistry( const domainIds = new Set(); const routeIds = new Set(); const routeHrefs = new Set(); + const routeShapes = new Set(); const registryEntryIds = new Set(); 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"], diff --git a/src/features/housekeeping/foundation/routing/match-route.test.ts b/src/features/housekeeping/foundation/routing/match-route.test.ts new file mode 100644 index 00000000..2c12b009 --- /dev/null +++ b/src/features/housekeeping/foundation/routing/match-route.test.ts @@ -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(); + }); +}); diff --git a/src/features/housekeeping/foundation/routing/match-route.ts b/src/features/housekeeping/foundation/routing/match-route.ts new file mode 100644 index 00000000..ce795ce1 --- /dev/null +++ b/src/features/housekeeping/foundation/routing/match-route.ts @@ -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>; + 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 | null { + if (candidate.patternSegments.length !== path.rawSegments.length) return null; + + const params: Record = {}; + 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; +} diff --git a/src/features/housekeeping/foundation/server-capability-context.test.ts b/src/features/housekeeping/foundation/server-capability-context.test.ts index b3a93a68..82bf391c 100644 --- a/src/features/housekeeping/foundation/server-capability-context.test.ts +++ b/src/features/housekeeping/foundation/server-capability-context.test.ts @@ -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"), ); diff --git a/src/features/housekeeping/route-handlers.test.ts b/src/features/housekeeping/route-handlers.test.ts new file mode 100644 index 00000000..7756aad8 --- /dev/null +++ b/src/features/housekeeping/route-handlers.test.ts @@ -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([]); + }); +}); diff --git a/src/features/housekeeping/route-handlers.ts b/src/features/housekeeping/route-handlers.ts new file mode 100644 index 00000000..3f0dbf69 --- /dev/null +++ b/src/features/housekeeping/route-handlers.ts @@ -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; +} + +export const HOUSEKEEPING_ROUTE_HANDLERS: readonly HousekeepingRouteHandler[] = + Object.freeze([]);