feat(housekeeping): dispatch canonical domain routes
This commit is contained in:
1 parent
2970dff563
commit
e5c230ba35
13 files changed
+680
-82
No files matched your search
@@ -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/<domain>/<segments>` 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`.
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ function namespaceKey(key: string): string {
|
|||||||
return key.slice(MESSAGE_PREFIX.length);
|
return key.slice(MESSAGE_PREFIX.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminNextDomainLayout({
|
export default async function HousekeepingPreviewDomainLayout({
|
||||||
children,
|
children,
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<HousekeepingPageShell
|
|
||||||
title={translate(namespaceKey(activeDomain.labelKey) as never)}
|
|
||||||
description={translate(
|
|
||||||
namespaceKey(activeDomain.descriptionKey) as never,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<HousekeepingPageState
|
|
||||||
state="empty"
|
|
||||||
title={translate("states.empty.title")}
|
|
||||||
description={translate("states.empty.description")}
|
|
||||||
/>
|
|
||||||
</HousekeepingPageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+14
-10
@@ -5,19 +5,23 @@ import { toHousekeepingHref } from "@/features/housekeeping/foundation/routing/h
|
|||||||
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests";
|
||||||
|
|
||||||
export default async function AdminNextPage() {
|
export default async function HousekeepingPreviewRootPage() {
|
||||||
const context = await getHousekeepingCapabilityContext();
|
const context = await getHousekeepingCapabilityContext();
|
||||||
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
|
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(
|
const route = domain.routes.find((entry) =>
|
||||||
firstVisibleDomain.canonicalHref,
|
satisfiesCapability(context, entry.capability),
|
||||||
"preview",
|
);
|
||||||
);
|
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();
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,13 @@ const routeMocks = vi.hoisted(() => {
|
|||||||
redirect: vi.fn((href: string): never => {
|
redirect: vi.fn((href: string): never => {
|
||||||
throw new Error(`NEXT_REDIRECT:${href}`);
|
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,
|
translate,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -77,9 +84,62 @@ vi.mock("@/actions", () => {
|
|||||||
vi.mock("@/app/actions", () => {
|
vi.mock("@/app/actions", () => {
|
||||||
throw new Error("preview routes must not import 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 AdminNextDomainLayout from "@/app/ase-next/[domain]/layout";
|
||||||
import AdminNextDomainPage from "@/app/ase-next/[domain]/page";
|
|
||||||
import AdminNextLayout from "@/app/ase-next/layout";
|
import AdminNextLayout from "@/app/ase-next/layout";
|
||||||
import AdminNextPage from "@/app/ase-next/page";
|
import AdminNextPage from "@/app/ase-next/page";
|
||||||
|
|
||||||
@@ -87,7 +147,7 @@ const routeFiles = [
|
|||||||
"src/app/ase-next/layout.tsx",
|
"src/app/ase-next/layout.tsx",
|
||||||
"src/app/ase-next/page.tsx",
|
"src/app/ase-next/page.tsx",
|
||||||
"src/app/ase-next/[domain]/layout.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;
|
] as const;
|
||||||
|
|
||||||
const forbiddenModuleRoots = [
|
const forbiddenModuleRoots = [
|
||||||
@@ -407,15 +467,15 @@ describe("/ase-next first visible domain", () => {
|
|||||||
vi.clearAllMocks();
|
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(
|
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(
|
||||||
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
|
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(AdminNextPage()).rejects.toThrow(
|
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(
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
@@ -428,9 +488,9 @@ describe("/ase-next first visible domain", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(AdminNextPage()).rejects.toThrow(
|
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(
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
@@ -515,34 +575,86 @@ describe("/ase-next/[domain] layout", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("/ase-next/[domain] page", () => {
|
describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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(
|
const html = await renderRoute(
|
||||||
AdminNextDomainPage({
|
AdminNextDomainPage({
|
||||||
params: Promise.resolve({ domain: "people" }),
|
params: Promise.resolve({ domain: "people", segments: ["users"] }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(html).toContain("HK::people-title");
|
expect(html).toContain("Rendered people.users for refreshed-moderator");
|
||||||
expect(html).toContain("Localized People description");
|
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||||
expect(html).toContain("Localized empty title");
|
1,
|
||||||
expect(html).toContain("Localized empty description");
|
);
|
||||||
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
|
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledTimes(1);
|
||||||
expect(routeMocks.getTranslations).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(
|
await expect(
|
||||||
AdminNextDomainPage({
|
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");
|
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
expect(routeMocks.getHousekeepingCapabilityContext).not.toHaveBeenCalled();
|
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",
|
"nested template-expression dynamic action import",
|
||||||
"src/app/ase-next/[domain]/page.tsx",
|
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||||
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../actions/nested")}\` : ""}\`;`,
|
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../../actions/nested")}\` : ""}\`;`,
|
||||||
"src/actions/nested",
|
"src/actions/nested",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -759,8 +871,8 @@ describe("preview route import boundary", () => {
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"domain relative permissions export",
|
"domain relative permissions export",
|
||||||
"src/app/ase-next/[domain]/page.tsx",
|
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||||
'export { getAdminContext } from "../../../lib/permissions";',
|
'export { getAdminContext } from "../../../../lib/permissions";',
|
||||||
"src/lib/permissions",
|
"src/lib/permissions",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -269,6 +269,32 @@ describe("housekeeping registry", () => {
|
|||||||
).toThrow("duplicate route href");
|
).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", () => {
|
it("rejects empty route fields and unknown capability slugs", () => {
|
||||||
const route = {
|
const route = {
|
||||||
id: "users",
|
id: "users",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export function createHousekeepingRegistry(
|
|||||||
const domainIds = new Set<string>();
|
const domainIds = new Set<string>();
|
||||||
const routeIds = new Set<string>();
|
const routeIds = new Set<string>();
|
||||||
const routeHrefs = new Set<string>();
|
const routeHrefs = new Set<string>();
|
||||||
|
const routeShapes = new Set<string>();
|
||||||
const registryEntryIds = new Set<string>();
|
const registryEntryIds = new Set<string>();
|
||||||
|
|
||||||
for (const manifest of manifests) {
|
for (const manifest of manifests) {
|
||||||
@@ -83,6 +84,12 @@ export function createHousekeepingRegistry(
|
|||||||
}
|
}
|
||||||
routeHrefs.add(route.href);
|
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");
|
validateNonEmpty(route.labelKey, "route label key");
|
||||||
validateCapability(route.capability);
|
validateCapability(route.capability);
|
||||||
}
|
}
|
||||||
@@ -91,6 +98,20 @@ export function createHousekeepingRegistry(
|
|||||||
return { domains: Object.freeze([...manifests]) };
|
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(
|
function validateOwnedRegistryEntries(
|
||||||
entries: readonly OwnedRegistryEntry[],
|
entries: readonly OwnedRegistryEntry[],
|
||||||
owner: HousekeepingDomainManifest["id"],
|
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,
|
children: null,
|
||||||
params: Promise.resolve({ domain: "operations" }),
|
params: Promise.resolve({ domain: "operations" }),
|
||||||
});
|
});
|
||||||
await expect(AdminNextPage()).rejects.toThrow(
|
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
||||||
"NEXT_REDIRECT:/ase-next/operations",
|
|
||||||
);
|
|
||||||
const commandContext = await requireHousekeepingCapability(
|
const commandContext = await requireHousekeepingCapability(
|
||||||
anyCapability("admin.dashboard"),
|
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([]);
|
||||||
Reference in new issue
Block a user