feat(housekeeping): validate preview route runtime
This commit is contained in:
1 parent
d868c990bf
commit
543607a80e
17 files changed
+466
-6
No files matched your search
@@ -26,6 +26,7 @@ export const contentManifest = {
|
|||||||
PERMS.SETTINGS_VIEW,
|
PERMS.SETTINGS_VIEW,
|
||||||
PERMS.SETTINGS_EDIT,
|
PERMS.SETTINGS_EDIT,
|
||||||
),
|
),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const economyManifest = {
|
|||||||
PERMS.CATALOG_EDIT,
|
PERMS.CATALOG_EDIT,
|
||||||
PERMS.SHOP_EDIT,
|
PERMS.SHOP_EDIT,
|
||||||
),
|
),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const hotelManifest = {
|
|||||||
PERMS.PAGES_VIEW,
|
PERMS.PAGES_VIEW,
|
||||||
PERMS.CATALOG_EDIT,
|
PERMS.CATALOG_EDIT,
|
||||||
),
|
),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const operationsManifest = {
|
|||||||
iconId: "inbox",
|
iconId: "inbox",
|
||||||
previewHref: "/ase-next/operations",
|
previewHref: "/ase-next/operations",
|
||||||
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
|
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const peopleManifest = {
|
|||||||
PERMS.MOD_CFH_EDIT,
|
PERMS.MOD_CFH_EDIT,
|
||||||
PERMS.MOD_TICKETS_EDIT,
|
PERMS.MOD_TICKETS_EDIT,
|
||||||
),
|
),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const systemManifest = {
|
|||||||
PERMS.SETTINGS_EDIT,
|
PERMS.SETTINGS_EDIT,
|
||||||
PERMS.NOTIFICATIONS_EDIT,
|
PERMS.NOTIFICATIONS_EDIT,
|
||||||
),
|
),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ const manifest: HousekeepingDomainManifest = {
|
|||||||
iconId: "users",
|
iconId: "users",
|
||||||
previewHref: "/ase-next/people",
|
previewHref: "/ase-next/people",
|
||||||
capability,
|
capability,
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [searchProvider],
|
searchProviders: [searchProvider],
|
||||||
inboxSources: [inboxSource],
|
inboxSources: [inboxSource],
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import type { HousekeepingInboxSource } from "./inbox";
|
|||||||
import type { HousekeepingSearchProvider } from "./search";
|
import type { HousekeepingSearchProvider } from "./search";
|
||||||
import type { HousekeepingWidgetDefinition } from "./widget";
|
import type { HousekeepingWidgetDefinition } from "./widget";
|
||||||
|
|
||||||
|
export type HousekeepingPreviewHref = `/ase-next${"" | `/${string}`}`;
|
||||||
|
|
||||||
export interface HousekeepingRouteDefinition {
|
export interface HousekeepingRouteDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
href: string;
|
href: HousekeepingPreviewHref;
|
||||||
capability: CapabilityRequirement;
|
capability: CapabilityRequirement;
|
||||||
matchPrefixes?: readonly string[];
|
matchPrefixes?: readonly string[];
|
||||||
}
|
}
|
||||||
@@ -19,6 +21,7 @@ export interface HousekeepingDomainManifest {
|
|||||||
iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings";
|
iconId: "inbox" | "users" | "file-text" | "gem" | "hotel" | "settings";
|
||||||
previewHref: `/ase-next/${HousekeepingDomainId}`;
|
previewHref: `/ase-next/${HousekeepingDomainId}`;
|
||||||
capability: CapabilityRequirement;
|
capability: CapabilityRequirement;
|
||||||
|
landingRouteId: string | null;
|
||||||
routes: readonly HousekeepingRouteDefinition[];
|
routes: readonly HousekeepingRouteDefinition[];
|
||||||
searchProviders: readonly HousekeepingSearchProvider[];
|
searchProviders: readonly HousekeepingSearchProvider[];
|
||||||
inboxSources: readonly HousekeepingInboxSource[];
|
inboxSources: readonly HousekeepingInboxSource[];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export {
|
|||||||
export type { HousekeepingCommand } from "./command";
|
export type { HousekeepingCommand } from "./command";
|
||||||
export type {
|
export type {
|
||||||
HousekeepingDomainManifest,
|
HousekeepingDomainManifest,
|
||||||
|
HousekeepingPreviewHref,
|
||||||
HousekeepingRouteDefinition,
|
HousekeepingRouteDefinition,
|
||||||
} from "./domain";
|
} from "./domain";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe("housekeeping navigation", () => {
|
|||||||
iconId: "users",
|
iconId: "users",
|
||||||
previewHref: "/ase-next/people",
|
previewHref: "/ase-next/people",
|
||||||
capability: anyCapability(PERMS.MOD_CFH_VIEW),
|
capability: anyCapability(PERMS.MOD_CFH_VIEW),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
@@ -37,6 +38,7 @@ describe("housekeeping navigation", () => {
|
|||||||
iconId: "gem",
|
iconId: "gem",
|
||||||
previewHref: "/ase-next/economy",
|
previewHref: "/ase-next/economy",
|
||||||
capability: anyCapability(PERMS.CATALOG_VIEW),
|
capability: anyCapability(PERMS.CATALOG_VIEW),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
@@ -71,6 +73,7 @@ describe("housekeeping navigation", () => {
|
|||||||
iconId: "users",
|
iconId: "users",
|
||||||
previewHref: "/ase-next/people",
|
previewHref: "/ase-next/people",
|
||||||
capability: anyCapability(PERMS.USERS_VIEW),
|
capability: anyCapability(PERMS.USERS_VIEW),
|
||||||
|
landingRouteId: "users",
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
id: "users",
|
id: "users",
|
||||||
@@ -96,6 +99,7 @@ describe("housekeeping navigation", () => {
|
|||||||
iconId: "settings",
|
iconId: "settings",
|
||||||
previewHref: "/ase-next/system",
|
previewHref: "/ase-next/system",
|
||||||
capability: anyCapability(PERMS.SETTINGS_VIEW),
|
capability: anyCapability(PERMS.SETTINGS_VIEW),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type CapabilityRequirement,
|
type CapabilityRequirement,
|
||||||
type HousekeepingDomainManifest,
|
type HousekeepingDomainManifest,
|
||||||
type HousekeepingInboxSource,
|
type HousekeepingInboxSource,
|
||||||
|
type HousekeepingRouteDefinition,
|
||||||
type HousekeepingSearchProvider,
|
type HousekeepingSearchProvider,
|
||||||
type HousekeepingWidgetDefinition,
|
type HousekeepingWidgetDefinition,
|
||||||
ok,
|
ok,
|
||||||
@@ -27,6 +28,7 @@ const manifest = (
|
|||||||
iconId: "settings",
|
iconId: "settings",
|
||||||
previewHref: `/ase-next/${id}`,
|
previewHref: `/ase-next/${id}`,
|
||||||
capability: anyCapability(capabilitySlug),
|
capability: anyCapability(capabilitySlug),
|
||||||
|
landingRouteId: null,
|
||||||
routes: [],
|
routes: [],
|
||||||
searchProviders: [],
|
searchProviders: [],
|
||||||
inboxSources: [],
|
inboxSources: [],
|
||||||
@@ -221,6 +223,43 @@ describe("housekeeping registry", () => {
|
|||||||
).toThrow("empty description key");
|
).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", () => {
|
it("rejects duplicate route identities and hrefs", () => {
|
||||||
const base = manifest("people");
|
const base = manifest("people");
|
||||||
|
|
||||||
@@ -269,7 +308,7 @@ describe("housekeeping registry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects empty route fields and unknown capability slugs", () => {
|
it("rejects empty route fields and unknown capability slugs", () => {
|
||||||
const route = {
|
const route: HousekeepingRouteDefinition = {
|
||||||
id: "users",
|
id: "users",
|
||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
href: "/ase-next/people/users",
|
href: "/ase-next/people/users",
|
||||||
@@ -283,7 +322,12 @@ describe("housekeeping registry", () => {
|
|||||||
).toThrow("empty route id");
|
).toThrow("empty route id");
|
||||||
expect(() =>
|
expect(() =>
|
||||||
createHousekeepingRegistry([
|
createHousekeepingRegistry([
|
||||||
{ ...manifest("people"), routes: [{ ...route, href: " " }] },
|
{
|
||||||
|
...manifest("people"),
|
||||||
|
routes: [
|
||||||
|
{ ...route, href: " " as HousekeepingRouteDefinition["href"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
]),
|
]),
|
||||||
).toThrow("empty route href");
|
).toThrow("empty route href");
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@@ -491,7 +535,7 @@ describe("housekeeping registry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate route identities and hrefs across domains", () => {
|
it("rejects duplicate route identities and hrefs across domains", () => {
|
||||||
const route = {
|
const route: HousekeepingRouteDefinition = {
|
||||||
id: "shared",
|
id: "shared",
|
||||||
labelKey: "pages.housekeeping.domains.people.title",
|
labelKey: "pages.housekeeping.domains.people.title",
|
||||||
href: "/ase-next/shared",
|
href: "/ase-next/shared",
|
||||||
@@ -500,18 +544,28 @@ describe("housekeeping registry", () => {
|
|||||||
|
|
||||||
expect(() =>
|
expect(() =>
|
||||||
createHousekeepingRegistry([
|
createHousekeepingRegistry([
|
||||||
{ ...manifest("people"), routes: [route] },
|
{
|
||||||
|
...manifest("people"),
|
||||||
|
landingRouteId: "shared",
|
||||||
|
routes: [route],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
...manifest("content"),
|
...manifest("content"),
|
||||||
|
landingRouteId: "shared",
|
||||||
routes: [{ ...route, href: "/ase-next/content/shared" }],
|
routes: [{ ...route, href: "/ase-next/content/shared" }],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
).toThrow("duplicate route id");
|
).toThrow("duplicate route id");
|
||||||
expect(() =>
|
expect(() =>
|
||||||
createHousekeepingRegistry([
|
createHousekeepingRegistry([
|
||||||
{ ...manifest("people"), routes: [route] },
|
{
|
||||||
|
...manifest("people"),
|
||||||
|
landingRouteId: "shared",
|
||||||
|
routes: [route],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
...manifest("content"),
|
...manifest("content"),
|
||||||
|
landingRouteId: "content-shared",
|
||||||
routes: [{ ...route, id: "content-shared" }],
|
routes: [{ ...route, id: "content-shared" }],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -80,6 +80,20 @@ export function createHousekeepingRegistry(
|
|||||||
validateNonEmpty(route.labelKey, "route label key");
|
validateNonEmpty(route.labelKey, "route label key");
|
||||||
validateCapability(route.capability);
|
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]) };
|
return { domains: Object.freeze([...manifests]) };
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string> = {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Record<string, string>>;
|
||||||
|
canonicalHref: HousekeepingPreviewHref;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HousekeepingRouteRenderInput {
|
||||||
|
context: HousekeepingCapabilityContext;
|
||||||
|
match: HousekeepingRouteMatch;
|
||||||
|
searchParams?: Readonly<
|
||||||
|
Record<string, string | readonly string[] | undefined>
|
||||||
|
>;
|
||||||
|
translate: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HousekeepingRouteHandler {
|
||||||
|
routeId: string;
|
||||||
|
render(input: HousekeepingRouteRenderInput): Promise<ReactNode>;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, HousekeepingRouteHandler>;
|
||||||
|
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<string, HousekeepingRouteHandler>();
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user