diff --git a/src/features/housekeeping/domains/content/manifest.ts b/src/features/housekeeping/domains/content/manifest.ts index 1b3bb223..eeb3f87b 100644 --- a/src/features/housekeeping/domains/content/manifest.ts +++ b/src/features/housekeeping/domains/content/manifest.ts @@ -26,6 +26,7 @@ export const contentManifest = { PERMS.SETTINGS_VIEW, PERMS.SETTINGS_EDIT, ), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], diff --git a/src/features/housekeeping/domains/economy/manifest.ts b/src/features/housekeeping/domains/economy/manifest.ts index adeae0c6..d379c530 100644 --- a/src/features/housekeeping/domains/economy/manifest.ts +++ b/src/features/housekeeping/domains/economy/manifest.ts @@ -16,6 +16,7 @@ export const economyManifest = { PERMS.CATALOG_EDIT, PERMS.SHOP_EDIT, ), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], diff --git a/src/features/housekeeping/domains/hotel/manifest.ts b/src/features/housekeeping/domains/hotel/manifest.ts index b7a043b4..2be6ce77 100644 --- a/src/features/housekeeping/domains/hotel/manifest.ts +++ b/src/features/housekeeping/domains/hotel/manifest.ts @@ -20,6 +20,7 @@ export const hotelManifest = { PERMS.PAGES_VIEW, PERMS.CATALOG_EDIT, ), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], diff --git a/src/features/housekeeping/domains/operations/manifest.ts b/src/features/housekeeping/domains/operations/manifest.ts index 823bddc1..cc9183fa 100644 --- a/src/features/housekeeping/domains/operations/manifest.ts +++ b/src/features/housekeeping/domains/operations/manifest.ts @@ -11,6 +11,7 @@ export const operationsManifest = { iconId: "inbox", previewHref: "/ase-next/operations", capability: anyCapability(PERMS.ADMIN_DASHBOARD), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], diff --git a/src/features/housekeeping/domains/people/manifest.ts b/src/features/housekeeping/domains/people/manifest.ts index 3ca6e4d1..4d35c22a 100644 --- a/src/features/housekeeping/domains/people/manifest.ts +++ b/src/features/housekeeping/domains/people/manifest.ts @@ -34,6 +34,7 @@ export const peopleManifest = { PERMS.MOD_CFH_EDIT, PERMS.MOD_TICKETS_EDIT, ), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], diff --git a/src/features/housekeeping/domains/system/manifest.ts b/src/features/housekeeping/domains/system/manifest.ts index ad665a58..7dbeb997 100644 --- a/src/features/housekeeping/domains/system/manifest.ts +++ b/src/features/housekeeping/domains/system/manifest.ts @@ -21,6 +21,7 @@ export const systemManifest = { PERMS.SETTINGS_EDIT, PERMS.NOTIFICATIONS_EDIT, ), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], diff --git a/src/features/housekeeping/foundation/contracts/contracts.test.ts b/src/features/housekeeping/foundation/contracts/contracts.test.ts index 7dfa659e..6d5dd288 100644 --- a/src/features/housekeeping/foundation/contracts/contracts.test.ts +++ b/src/features/housekeeping/foundation/contracts/contracts.test.ts @@ -89,6 +89,7 @@ const manifest: HousekeepingDomainManifest = { iconId: "users", previewHref: "/ase-next/people", capability, + landingRouteId: null, routes: [], searchProviders: [searchProvider], inboxSources: [inboxSource], diff --git a/src/features/housekeeping/foundation/contracts/domain.ts b/src/features/housekeeping/foundation/contracts/domain.ts index ccb4f329..836060d2 100644 --- a/src/features/housekeeping/foundation/contracts/domain.ts +++ b/src/features/housekeeping/foundation/contracts/domain.ts @@ -4,10 +4,12 @@ import type { HousekeepingInboxSource } from "./inbox"; import type { HousekeepingSearchProvider } from "./search"; import type { HousekeepingWidgetDefinition } from "./widget"; +export type HousekeepingPreviewHref = `/ase-next${"" | `/${string}`}`; + export interface HousekeepingRouteDefinition { id: string; labelKey: string; - href: string; + href: HousekeepingPreviewHref; capability: CapabilityRequirement; matchPrefixes?: readonly string[]; } @@ -19,6 +21,7 @@ export interface HousekeepingDomainManifest { iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings"; previewHref: `/ase-next/${HousekeepingDomainId}`; capability: CapabilityRequirement; + landingRouteId: string | null; routes: readonly HousekeepingRouteDefinition[]; searchProviders: readonly HousekeepingSearchProvider[]; inboxSources: readonly HousekeepingInboxSource[]; diff --git a/src/features/housekeeping/foundation/contracts/index.ts b/src/features/housekeeping/foundation/contracts/index.ts index 55f607fb..1a67ddcf 100644 --- a/src/features/housekeeping/foundation/contracts/index.ts +++ b/src/features/housekeeping/foundation/contracts/index.ts @@ -8,6 +8,7 @@ export { export type { HousekeepingCommand } from "./command"; export type { HousekeepingDomainManifest, + HousekeepingPreviewHref, HousekeepingRouteDefinition, } from "./domain"; export type { diff --git a/src/features/housekeeping/foundation/navigation.test.ts b/src/features/housekeeping/foundation/navigation.test.ts index 32465ac6..204a0fc7 100644 --- a/src/features/housekeeping/foundation/navigation.test.ts +++ b/src/features/housekeeping/foundation/navigation.test.ts @@ -25,6 +25,7 @@ describe("housekeeping navigation", () => { iconId: "users", previewHref: "/ase-next/people", capability: anyCapability(PERMS.MOD_CFH_VIEW), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], @@ -37,6 +38,7 @@ describe("housekeeping navigation", () => { iconId: "gem", previewHref: "/ase-next/economy", capability: anyCapability(PERMS.CATALOG_VIEW), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], @@ -71,6 +73,7 @@ describe("housekeeping navigation", () => { iconId: "users", previewHref: "/ase-next/people", capability: anyCapability(PERMS.USERS_VIEW), + landingRouteId: "users", routes: [ { id: "users", @@ -96,6 +99,7 @@ describe("housekeeping navigation", () => { iconId: "settings", previewHref: "/ase-next/system", capability: anyCapability(PERMS.SETTINGS_VIEW), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], diff --git a/src/features/housekeeping/foundation/registry.test.ts b/src/features/housekeeping/foundation/registry.test.ts index b82dcb4a..924881a4 100644 --- a/src/features/housekeeping/foundation/registry.test.ts +++ b/src/features/housekeeping/foundation/registry.test.ts @@ -11,6 +11,7 @@ import { type CapabilityRequirement, type HousekeepingDomainManifest, type HousekeepingInboxSource, + type HousekeepingRouteDefinition, type HousekeepingSearchProvider, type HousekeepingWidgetDefinition, ok, @@ -27,6 +28,7 @@ const manifest = ( iconId: "settings", previewHref: `/ase-next/${id}`, capability: anyCapability(capabilitySlug), + landingRouteId: null, routes: [], searchProviders: [], inboxSources: [], @@ -221,6 +223,43 @@ describe("housekeeping registry", () => { ).toThrow("empty description key"); }); + it("requires a valid landing route for every non-empty manifest", () => { + const usersRoute = { + id: "people.users", + labelKey: "pages.housekeeping.people.users.title", + href: "/ase-next/people/users" as const, + capability: anyCapability(PERMS.USERS_VIEW), + }; + + expect(() => + createHousekeepingRegistry([ + { ...manifest("people"), routes: [usersRoute] }, + ]), + ).toThrow("missing landing route: people"); + expect(() => + createHousekeepingRegistry([ + { + ...manifest("people"), + landingRouteId: "people.unknown", + routes: [usersRoute], + }, + ]), + ).toThrow("invalid landing route: people.unknown"); + expect(() => + createHousekeepingRegistry([ + { ...manifest("people"), landingRouteId: "people.users" }, + ]), + ).toThrow("landing route without routes: people.users"); + expect(() => + createHousekeepingRegistry([ + { + ...manifest("people"), + landingRouteId: "people.users", + routes: [usersRoute], + }, + ]), + ).not.toThrow(); + }); it("rejects duplicate route identities and hrefs", () => { const base = manifest("people"); @@ -269,7 +308,7 @@ describe("housekeeping registry", () => { }); it("rejects empty route fields and unknown capability slugs", () => { - const route = { + const route: HousekeepingRouteDefinition = { id: "users", labelKey: "pages.housekeeping.domains.people.title", href: "/ase-next/people/users", @@ -283,7 +322,12 @@ describe("housekeeping registry", () => { ).toThrow("empty route id"); expect(() => createHousekeepingRegistry([ - { ...manifest("people"), routes: [{ ...route, href: " " }] }, + { + ...manifest("people"), + routes: [ + { ...route, href: " " as HousekeepingRouteDefinition["href"] }, + ], + }, ]), ).toThrow("empty route href"); expect(() => @@ -491,7 +535,7 @@ describe("housekeeping registry", () => { }); it("rejects duplicate route identities and hrefs across domains", () => { - const route = { + const route: HousekeepingRouteDefinition = { id: "shared", labelKey: "pages.housekeeping.domains.people.title", href: "/ase-next/shared", @@ -500,18 +544,28 @@ describe("housekeeping registry", () => { expect(() => createHousekeepingRegistry([ - { ...manifest("people"), routes: [route] }, + { + ...manifest("people"), + landingRouteId: "shared", + routes: [route], + }, { ...manifest("content"), + landingRouteId: "shared", routes: [{ ...route, href: "/ase-next/content/shared" }], }, ]), ).toThrow("duplicate route id"); expect(() => createHousekeepingRegistry([ - { ...manifest("people"), routes: [route] }, + { + ...manifest("people"), + landingRouteId: "shared", + routes: [route], + }, { ...manifest("content"), + landingRouteId: "content-shared", routes: [{ ...route, id: "content-shared" }], }, ]), diff --git a/src/features/housekeeping/foundation/registry.ts b/src/features/housekeeping/foundation/registry.ts index d72ccce4..d5105e55 100644 --- a/src/features/housekeeping/foundation/registry.ts +++ b/src/features/housekeeping/foundation/registry.ts @@ -80,6 +80,20 @@ export function createHousekeepingRegistry( validateNonEmpty(route.labelKey, "route label key"); validateCapability(route.capability); } + + if (manifest.routes.length === 0) { + if (manifest.landingRouteId !== null) { + throw new Error( + `landing route without routes: ${manifest.landingRouteId}`, + ); + } + } else if (manifest.landingRouteId === null) { + throw new Error(`missing landing route: ${manifest.id}`); + } else if ( + !manifest.routes.some((route) => route.id === manifest.landingRouteId) + ) { + throw new Error(`invalid landing route: ${manifest.landingRouteId}`); + } } return { domains: Object.freeze([...manifests]) }; 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..8a6f0556 --- /dev/null +++ b/src/features/housekeeping/foundation/routing/match-route.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { anyCapability, type HousekeepingDomainManifest } from "../contracts"; +import { createHousekeepingRegistry } from "../registry"; +import { matchHousekeepingRoute } from "./match-route"; + +const peopleManifest = { + id: "people", + labelKey: "pages.housekeeping.domains.people.title", + descriptionKey: "pages.housekeeping.domains.people.description", + iconId: "users", + previewHref: "/ase-next/people", + capability: anyCapability(PERMS.USERS_VIEW), + landingRouteId: "people.users", + routes: [ + { + id: "people.users", + labelKey: "pages.housekeeping.people.users.title", + href: "/ase-next/people/users", + capability: anyCapability(PERMS.USERS_VIEW), + }, + { + id: "people.user-settings", + labelKey: "pages.housekeeping.people.userSettings.title", + href: "/ase-next/people/users/settings", + capability: anyCapability(PERMS.USERS_VIEW), + }, + { + id: "people.user-detail", + labelKey: "pages.housekeeping.people.userDetail.title", + href: "/ase-next/people/users/:id", + capability: anyCapability(PERMS.USERS_VIEW), + }, + ], + searchProviders: [], + inboxSources: [], + widgets: [], +} satisfies HousekeepingDomainManifest; + +const registry = createHousekeepingRegistry([peopleManifest]); + +describe("matchHousekeepingRoute", () => { + it("matches concrete and dynamic preview routes", () => { + expect(matchHousekeepingRoute(registry, "/ase-next/people/users")).toEqual({ + routeId: "people.users", + domain: "people", + params: {}, + canonicalHref: "/ase-next/people/users", + }); + expect( + matchHousekeepingRoute(registry, "/ase-next/people/users/42"), + ).toEqual({ + routeId: "people.user-detail", + domain: "people", + params: { id: "42" }, + canonicalHref: "/ase-next/people/users/42", + }); + }); + + it("prefers a literal route over a dynamic route", () => { + expect( + matchHousekeepingRoute(registry, "/ase-next/people/users/settings"), + ).toMatchObject({ + routeId: "people.user-settings", + params: {}, + }); + }); + + it("decodes a parameter exactly once", () => { + expect( + matchHousekeepingRoute(registry, "/ase-next/people/users/%34%32"), + ).toMatchObject({ + routeId: "people.user-detail", + params: { id: "42" }, + canonicalHref: "/ase-next/people/users/42", + }); + }); + + it.each([ + "/ase-next/people", + "/ase-next/people/unknown", + "/ase-next/people/users/", + "/ase-next/people/users?rank=7", + "/ase-next/people/users#active", + "/ase-next/people\\users", + "/ase-next/people//users", + "/ase-next/people/./users", + "/ase-next/people/users/..", + "/ase-next/people/users/%2F", + "/ase-next/people/users/%252F", + "/ase-next/people/users/%00", + ])("rejects an unregistered canonical path: %s", (pathname) => { + expect(matchHousekeepingRoute(registry, pathname)).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..d43d313e --- /dev/null +++ b/src/features/housekeeping/foundation/routing/match-route.ts @@ -0,0 +1,132 @@ +import type { HousekeepingPreviewHref } from "../contracts"; +import type { HousekeepingRegistry } from "../registry"; +import type { HousekeepingRouteMatch } from "./route-handler"; + +const PREVIEW_PREFIX = "/ase-next/"; +const ENCODED_PATH_SEPARATOR = /%(?:2f|5c)/i; + +function containsControlCharacter(value: string): boolean { + for (const character of value) { + const codeUnit = character.charCodeAt(0); + if (codeUnit <= 0x1f || codeUnit === 0x7f) return true; + } + + return false; +} + +interface RouteCandidate { + domain: HousekeepingRouteMatch["domain"]; + routeId: string; + patternSegments: readonly string[]; + dynamicSegmentCount: number; +} + +export function matchHousekeepingRoute( + registry: HousekeepingRegistry, + canonicalPath: string, +): HousekeepingRouteMatch | null { + const pathSegments = parseCanonicalPath(canonicalPath); + if (!pathSegments) return null; + + const candidates = registry.domains + .flatMap((domain) => + domain.routes.map((route) => { + const patternSegments = route.href.slice(1).split("/"); + + return { + domain: domain.id, + routeId: route.id, + patternSegments, + dynamicSegmentCount: patternSegments.filter((segment) => + segment.startsWith(":"), + ).length, + }; + }), + ) + .sort( + (left, right) => left.dynamicSegmentCount - right.dynamicSegmentCount, + ); + + for (const candidate of candidates) { + const match = matchCandidate(candidate, pathSegments); + if (match) return match; + } + + return null; +} + +function parseCanonicalPath( + canonicalPath: string, +): readonly { raw: string; decoded: string }[] | null { + if ( + !canonicalPath.startsWith(PREVIEW_PREFIX) || + canonicalPath.endsWith("/") || + canonicalPath.includes("?") || + canonicalPath.includes("#") || + canonicalPath.includes("\\") + ) { + return null; + } + + const rawSegments = canonicalPath.slice(1).split("/"); + if (rawSegments.some((segment) => !segment)) return null; + + const parsedSegments: { raw: string; decoded: string }[] = []; + for (const raw of rawSegments) { + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + return null; + } + + if ( + !decoded || + decoded === "." || + decoded === ".." || + decoded.includes("/") || + decoded.includes("\\") || + ENCODED_PATH_SEPARATOR.test(decoded) || + containsControlCharacter(decoded) + ) { + return null; + } + + parsedSegments.push({ raw, decoded }); + } + + return parsedSegments; +} + +function matchCandidate( + candidate: RouteCandidate, + pathSegments: readonly { raw: string; decoded: string }[], +): HousekeepingRouteMatch | null { + if (candidate.patternSegments.length !== pathSegments.length) return null; + + const params: Record = {}; + const canonicalSegments: string[] = []; + + for (const [index, patternSegment] of candidate.patternSegments.entries()) { + const pathSegment = pathSegments[index]; + if (!pathSegment) return null; + + if (patternSegment.startsWith(":")) { + const parameterName = patternSegment.slice(1); + if (!parameterName) return null; + params[parameterName] = pathSegment.decoded; + canonicalSegments.push(encodeURIComponent(pathSegment.decoded)); + continue; + } + + if (pathSegment.raw !== patternSegment) return null; + canonicalSegments.push(patternSegment); + } + + return { + routeId: candidate.routeId, + domain: candidate.domain, + params, + canonicalHref: `/${canonicalSegments.join("/")}` as HousekeepingPreviewHref, + }; +} diff --git a/src/features/housekeeping/foundation/routing/route-handler.ts b/src/features/housekeeping/foundation/routing/route-handler.ts new file mode 100644 index 00000000..12615e06 --- /dev/null +++ b/src/features/housekeeping/foundation/routing/route-handler.ts @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; +import type { HousekeepingDomainId } from "../../migration/types"; +import type { + HousekeepingCapabilityContext, + HousekeepingPreviewHref, +} from "../contracts"; + +export interface HousekeepingRouteMatch { + routeId: string; + domain: HousekeepingDomainId; + params: Readonly>; + canonicalHref: HousekeepingPreviewHref; +} + +export interface HousekeepingRouteRenderInput { + context: HousekeepingCapabilityContext; + match: HousekeepingRouteMatch; + searchParams?: Readonly< + Record + >; + translate: (key: string) => string; +} + +export interface HousekeepingRouteHandler { + routeId: string; + render(input: HousekeepingRouteRenderInput): Promise; +} diff --git a/src/features/housekeeping/foundation/routing/runtime.test.ts b/src/features/housekeeping/foundation/routing/runtime.test.ts new file mode 100644 index 00000000..3ee6eca6 --- /dev/null +++ b/src/features/housekeeping/foundation/routing/runtime.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { PERMS } from "@/lib/permission-slugs"; +import { anyCapability, type HousekeepingDomainManifest } from "../contracts"; +import { createHousekeepingRegistry } from "../registry"; +import type { HousekeepingRouteHandler } from "./route-handler"; +import { createHousekeepingRouteRuntime } from "./runtime"; + +const peopleManifest = { + id: "people", + labelKey: "pages.housekeeping.domains.people.title", + descriptionKey: "pages.housekeeping.domains.people.description", + iconId: "users", + previewHref: "/ase-next/people", + capability: anyCapability(PERMS.USERS_VIEW), + landingRouteId: "people.users", + routes: [ + { + id: "people.users", + labelKey: "pages.housekeeping.people.users.title", + href: "/ase-next/people/users", + capability: anyCapability(PERMS.USERS_VIEW), + }, + { + id: "people.user-detail", + labelKey: "pages.housekeeping.people.userDetail.title", + href: "/ase-next/people/users/:id", + capability: anyCapability(PERMS.USERS_VIEW), + }, + ], + searchProviders: [], + inboxSources: [], + widgets: [], +} satisfies HousekeepingDomainManifest; + +const registry = createHousekeepingRegistry([peopleManifest]); + +const handler = (routeId: string): HousekeepingRouteHandler => ({ + routeId, + render: async () => `Rendered ${routeId}`, +}); + +describe("createHousekeepingRouteRuntime", () => { + it("indexes handlers and delegates canonical route matching", () => { + const runtime = createHousekeepingRouteRuntime(registry, [ + handler("people.users"), + handler("people.user-detail"), + ]); + + expect([...runtime.handlers]).toHaveLength(2); + expect(runtime.match("/ase-next/people/users/42")).toMatchObject({ + routeId: "people.user-detail", + params: { id: "42" }, + }); + }); + + it("rejects a route without a handler", () => { + expect(() => createHousekeepingRouteRuntime(registry, [])).toThrow( + "missing route handler: people.users", + ); + }); + + it("rejects duplicate handlers", () => { + expect(() => + createHousekeepingRouteRuntime(registry, [ + handler("people.users"), + handler("people.users"), + ]), + ).toThrow("duplicate route handler: people.users"); + }); + + it("rejects a handler without a route", () => { + expect(() => + createHousekeepingRouteRuntime(registry, [handler("people.unknown")]), + ).toThrow("handler without route: people.unknown"); + }); +}); diff --git a/src/features/housekeeping/foundation/routing/runtime.ts b/src/features/housekeeping/foundation/routing/runtime.ts new file mode 100644 index 00000000..176897fe --- /dev/null +++ b/src/features/housekeeping/foundation/routing/runtime.ts @@ -0,0 +1,47 @@ +import type { HousekeepingRegistry } from "../registry"; +import { matchHousekeepingRoute } from "./match-route"; +import type { + HousekeepingRouteHandler, + HousekeepingRouteMatch, +} from "./route-handler"; + +export interface HousekeepingRouteRuntime { + registry: HousekeepingRegistry; + handlers: ReadonlyMap; + match(pathname: string): HousekeepingRouteMatch | null; +} + +export function createHousekeepingRouteRuntime( + registry: HousekeepingRegistry, + handlers: readonly HousekeepingRouteHandler[], +): HousekeepingRouteRuntime { + const declaredRouteIds = new Set( + registry.domains.flatMap((domain) => + domain.routes.map((route) => route.id), + ), + ); + const handlerMap = new Map(); + + for (const handler of handlers) { + if (handlerMap.has(handler.routeId)) { + throw new Error(`duplicate route handler: ${handler.routeId}`); + } + if (!declaredRouteIds.has(handler.routeId)) { + throw new Error(`handler without route: ${handler.routeId}`); + } + + handlerMap.set(handler.routeId, handler); + } + + for (const routeId of declaredRouteIds) { + if (!handlerMap.has(routeId)) { + throw new Error(`missing route handler: ${routeId}`); + } + } + + return Object.freeze({ + registry, + handlers: handlerMap, + match: (pathname: string) => matchHousekeepingRoute(registry, pathname), + }); +}